__( 'WebCalendar', 'agenticdaisy-calendar' ), 'description' => __( 'Calendar event management abilities provided by the WebCalendar plugin.', 'agenticdaisy-calendar' ), ] ); } /** * Register all WebCalendar abilities. */ public function register_abilities() : void { $this->register_list_events(); $this->register_get_event(); $this->register_list_users(); // Mutating abilities are skipped entirely in read-only mode, so an AI // reaching the calendar through the WordPress Abilities API cannot write. if ( !$this->is_read_only() ) { $this->register_create_event(); $this->register_update_event(); $this->register_delete_event(); } $this->register_list_categories(); $this->register_get_calendar_info(); $this->register_list_venues(); $this->register_list_organizers(); $this->register_get_event_registration(); $this->register_list_attendees(); $this->register_describe_schema(); } /** * Whether AI access is restricted to read-only tools. * * Mirrors McpServer::is_read_only() (option + filter) so both AI exposure * paths — the built-in MCP server and the WordPress Abilities API — honor * the same setting. * * @return bool */ private function is_read_only() : bool { $raw = get_option( 'webcal_mcp_read_only', 'no' ); $enabled = is_scalar( $raw ) && (string) $raw === 'yes'; /** This filter is documented in src/MCP/McpServer.php */ return (bool) apply_filters( 'webcal_mcp_read_only', $enabled ); } /** * List events in a date range. */ private function register_list_events() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/list-events', [ 'label' => __( 'List Events', 'agenticdaisy-calendar' ), 'description' => __( 'Retrieve the events that occur within a date range, sorted by start time. Repeating events are expanded: each occurrence in the range is its own entry, and every occurrence carries the ID of the whole series. Only events the connected user may see are returned. `total` counts all matches; when it exceeds the number of entries returned, narrow the date range to see the rest (there is no offset parameter).', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'start_date' => [ 'type' => 'string', 'description' => __( 'Start date in ISO 8601 format (e.g. 2026-03-01).', 'agenticdaisy-calendar' ), ], 'end_date' => [ 'type' => 'string', 'description' => __( 'End date in ISO 8601 format (e.g. 2026-03-31). At most about a year after start_date, and within ten years from now.', 'agenticdaisy-calendar' ), ], 'user' => [ 'type' => 'string', 'description' => __( 'Filter by WordPress user login. Omit for all users.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Filter by category ID.', 'agenticdaisy-calendar' ), ], 'max_results' => [ 'type' => 'integer', 'description' => __( 'Maximum number of entries to return (default 50, max 200).', 'agenticdaisy-calendar' ), 'default' => 50, 'minimum' => 1, 'maximum' => 200, ], ], 'required' => ['start_date', 'end_date'], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'total' => [ 'type' => 'integer', 'description' => __( 'Total number of events found.', 'agenticdaisy-calendar' ), ], 'events' => [ 'type' => 'array', 'items' => $this->event_schema(), ], ], ], 'execute_callback' => [$this, 'execute_list_events'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute list-events ability. * * @param array $input Input parameters. * @return array */ public function execute_list_events( array $input ) : array { try { $container = Container::get_instance(); $event_service = $container->get_event_service(); $transformer = $container->get_event_transformer(); $category_repo = $container->get_category_repository(); $start = new DateTimeImmutable(( is_string( $input['start_date'] ?? null ) ? $input['start_date'] : 'now' )); $end = new DateTimeImmutable(( is_string( $input['end_date'] ?? null ) ? $input['end_date'] : '+30 days' )); $range = new DateRange($start, $end); // Recurring events are expanded over this window, from DTSTART. if ( !ExpansionWindow::allows( $start, $end ) ) { return [ 'error' => __( 'That date range is too large. Ask for at most about a year, ending within ten years from now.', 'agenticdaisy-calendar' ), ]; } $users_filter = null; if ( !empty( $input['user'] ) && is_string( $input['user'] ) ) { $users_filter = [sanitize_text_field( $input['user'] )]; } // Scope to what the calling user may see. The ability is gated on // webcal_can_view, which reaches Subscriber, so the capability // check alone does not decide *which* events — and unlike the MCP // endpoint this surface is registered with WordPress directly, so // it is not covered by the webcal_mcp_audience setting either. // // limitedToUsers( [] ) is no calendar restriction, which is what a // null filter meant before — hence the ?? []. $events = EventVisibility::filter( $event_service->getEventsInDateRange( $range, EventVisibility::scope_for_viewer()->limitedToUsers( $users_filter ?? [] ) ) ); // Optional category filter (post-query) $raw_cat_id = $input['category_id'] ?? null; if ( is_numeric( $raw_cat_id ) && (int) $raw_cat_id > 0 ) { $cat_id = (int) $raw_cat_id; $user_login = ( is_user_logged_in() ? wp_get_current_user()->user_login : '' ); $events = array_filter( $events, function ( $event ) use($category_repo, $cat_id, $user_login) { $cats = $category_repo->getForEvent( $event->id(), ( $user_login ?: $event->createdBy() ) ); foreach ( $cats as $cat ) { if ( $cat->id() === $cat_id ) { return true; } } return false; } ); $events = array_values( $events ); } // Expand recurring events into individual occurrences within the date range. // Without this, recurring events appear with their original start date, // which may be outside the requested range. $recurrence_svc = $container->get_recurrence_service(); $queue = []; foreach ( $events as $event ) { if ( $event->recurrence()->isRepeating() ) { $occurrences = $recurrence_svc->expand( $event, $range ); foreach ( $occurrences as $occurrence ) { $queue[] = [ 'event' => $event, 'occurrence' => $occurrence, ]; } } else { $queue[] = [ 'event' => $event, 'occurrence' => null, ]; } } // Sort by occurrence/event start date ascending. usort( $queue, function ( $a, $b ) { /** @var \DateTimeImmutable $start_a */ $start_a = ( $a['occurrence'] !== null ? $a['occurrence']->getStart() : $a['event']->start() ); /** @var \DateTimeImmutable $start_b */ $start_b = ( $b['occurrence'] !== null ? $b['occurrence']->getStart() : $b['event']->start() ); return $start_a <=> $start_b; } ); $raw_max = $input['max_results'] ?? 0; $max = ( is_numeric( $raw_max ) && (int) $raw_max > 0 ? min( (int) $raw_max, 200 ) : 50 ); $total = count( $queue ); $queue = array_slice( $queue, 0, $max ); $result = []; foreach ( $queue as $item ) { if ( $item['occurrence'] !== null ) { $result[] = $this->summarize_occurrence( $item['occurrence'], $item['event'], $transformer ); } else { $result[] = $this->summarize_event( $item['event'], $transformer ); } } return [ 'total' => $total, 'events' => $result, ]; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to list events: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Get a single event by ID. */ private function register_get_event() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/get-event', [ 'label' => __( 'Get Event', 'agenticdaisy-calendar' ), 'description' => __( 'Retrieve full details of a single event by ID, including its description, participants and attachments. For a repeating event this returns the series (its first start date and its rrule), not one occurrence. A missing ID and an event the connected user may not see both return "Event not found."', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID.', 'agenticdaisy-calendar' ), ], ], 'required' => ['event_id'], ], 'output_schema' => [ 'type' => 'object', 'properties' => array_merge( $this->event_detail_properties(), [ 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], 'attachments' => [ 'type' => 'array', ], ] ), ], 'execute_callback' => [$this, 'execute_get_event'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute get-event ability. * * @param array $input Input parameters. * @return array */ public function execute_get_event( array $input ) : array { try { $container = Container::get_instance(); $event_service = $container->get_event_service(); $transformer = $container->get_event_transformer(); $raw_id = $input['event_id'] ?? 0; $id = ( is_numeric( $raw_id ) ? (int) $raw_id : 0 ); $event = $event_service->getEventById( new EventId($id) ); if ( !$event ) { return [ 'error' => __( 'Event not found.', 'agenticdaisy-calendar' ), ]; } // An id is not an entitlement. Without this the ability reads back // any event on the site — including other people's Private and // Confidential ones — for anyone holding webcal_can_view. The // message deliberately matches the not-found case so the ability // cannot be used to probe which ids exist. if ( !EventVisibility::can_view( $event ) ) { return [ 'error' => __( 'Event not found.', 'agenticdaisy-calendar' ), ]; } $data = $transformer->transform_event( $event ); // Coalesce nulls for Abilities API schema validation $data['location'] = $data['location'] ?? ''; $data['category_id'] = $data['category_id'] ?? 0; $data['category_color'] = $data['category_color'] ?? ''; $data['featured_image_id'] = $data['featured_image_id'] ?? 0; $data['featured_image_url'] = $data['featured_image_url'] ?? ''; return $data; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to get event: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Create a new event. */ private function register_create_event() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/create-event', [ 'label' => __( 'Create Event', 'agenticdaisy-calendar' ), 'description' => __( 'Create a calendar event. The connected user becomes its owner and is always added as a participant. Pass `rrule` to make it repeat. Participant logins that match neither a WordPress user nor a shared or remote calendar are skipped and returned in `invalid_participants`; the event is still created.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'title' => [ 'type' => 'string', 'description' => __( 'Event title.', 'agenticdaisy-calendar' ), ], 'start' => [ 'type' => 'string', 'description' => __( 'Start date/time in ISO 8601 format (e.g. 2026-03-15T14:00:00). Times without a timezone offset are interpreted as the site\'s configured timezone. A date-only value (e.g. 2026-03-15) creates an all-day event.', 'agenticdaisy-calendar' ), ], 'duration' => [ 'type' => 'integer', 'description' => __( 'Duration in minutes (default 60). Ignored for all-day events.', 'agenticdaisy-calendar' ), 'default' => 60, ], 'all_day' => [ 'type' => 'boolean', 'description' => __( 'Whether this is an all-day event.', 'agenticdaisy-calendar' ), 'default' => false, ], 'description' => [ 'type' => 'string', 'description' => __( 'Event description (plain text or HTML).', 'agenticdaisy-calendar' ), ], 'location' => [ 'type' => 'string', 'description' => __( 'Event location.', 'agenticdaisy-calendar' ), ], 'access' => [ 'type' => 'string', 'description' => __( 'Access level: P (Public), C (Confidential), or R (Private). Default: P.', 'agenticdaisy-calendar' ), 'enum' => ['P', 'C', 'R'], 'default' => 'P', ], 'rrule' => [ 'type' => 'string', 'description' => __( 'RFC 5545 recurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR).', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID to assign.', 'agenticdaisy-calendar' ), ], 'uid' => [ 'type' => 'string', 'description' => __( 'Optional idempotency key, stored as the event\'s iCalendar UID. Send the same value when retrying and the original event is returned instead of a duplicate; the response says created: false. Omit it and one is generated.', 'agenticdaisy-calendar' ), ], 'venue_id' => [ 'type' => 'integer', 'description' => __( 'Attach a venue by ID, from list-venues. Send 0 to detach it; omit to leave it unchanged. A venue is a reusable place with an address, separate from the event\'s free-text location.', 'agenticdaisy-calendar' ), ], 'organizer_id' => [ 'type' => 'integer', 'description' => __( 'Attach an organizer by ID, from list-organizers. Send 0 to detach it; omit to leave it unchanged. An organizer is a contact shown on the event page, not a participant.', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], 'description' => __( 'Logins of WordPress users or shared/remote calendars to add as participants, in addition to the owner.', 'agenticdaisy-calendar' ), ], ], 'required' => ['title', 'start'], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', 'description' => __( 'Created event ID.', 'agenticdaisy-calendar' ), ], 'message' => [ 'type' => 'string', ], 'invalid_participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], ], 'execute_callback' => [$this, 'execute_create_event'], 'permission_callback' => fn() => current_user_can( 'webcal_can_edit' ), ] ); } /** * Execute create-event ability. * * @param array $input Input parameters. * @return array */ public function execute_create_event( array $input ) : array { try { $container = Container::get_instance(); $event_service = $container->get_event_service(); // Infer all-day when explicit flag is set, or when start is date-only // (no time component). This prevents date-only starts like "2026-03-12" // from creating midnight timed events that get buried in month view. $start_str = ( is_string( $input['start'] ?? null ) ? $input['start'] : 'now' ); $is_all_day = !empty( $input['all_day'] ) || !str_contains( $start_str, 'T' ) && !preg_match( '/\\d{2}:\\d{2}/', $start_str ); $creator = wp_get_current_user()->user_login; // A client-supplied uid makes create idempotent. An assistant that // times out and retries sends the same arguments again; without a // key the server has no way to tell that from a genuine request to // book the same thing twice, and duplicates are the result. The // uid is the event's iCalendar identity, so this doubles as a way // to create an event under an id the caller already knows. $supplied_uid = ( is_string( $input['uid'] ?? null ) ? trim( $input['uid'] ) : '' ); $uid = ( '' !== $supplied_uid ? $supplied_uid : wp_generate_uuid4() ); if ( '' !== $supplied_uid ) { $already = $container->get_event_repository()->findByUid( $uid ); if ( null !== $already ) { return [ 'id' => $already->id()->value(), 'created' => false, 'message' => __( 'An event with that uid already exists; returning it unchanged.', 'agenticdaisy-calendar' ), ]; } } $recurrence = new Recurrence(); $rrule_str = ( is_string( $input['rrule'] ?? null ) ? $input['rrule'] : '' ); if ( $rrule_str !== '' ) { $recurrence = new Recurrence(new RecurrenceRule($rrule_str)); } $access_str = ( is_string( $input['access'] ?? null ) ? $input['access'] : 'P' ); $access = AccessLevel::tryFrom( $access_str ) ?? AccessLevel::PUBLIC; $event = new Event( id: new EventId(0), uid: $uid, name: sanitize_text_field( ( is_string( $input['title'] ?? null ) ? $input['title'] : '' ) ), description: wp_kses_post( ( is_string( $input['description'] ?? null ) ? $input['description'] : '' ) ), location: sanitize_text_field( ( is_string( $input['location'] ?? null ) ? $input['location'] : '' ) ), start: new DateTimeImmutable($start_str), duration: ( $is_all_day ? 1440 : (( is_numeric( $input['duration'] ?? null ) && (int) $input['duration'] > 0 ? (int) $input['duration'] : 60 )) ), createdBy: $creator, type: ( $recurrence->isRepeating() ? EventType::REPEATING_EVENT : EventType::EVENT ), access: $access, recurrence: $recurrence, allDay: $is_all_day, venueId: $this->resolve_venue_id( $input['venue_id'] ?? null, null ), organizerId: $this->resolve_organizer_id( $input['organizer_id'] ?? null, null ) ); $user_repo = $container->get_user_repository(); $actor = $user_repo->findByLogin( $creator ); if ( $actor === null ) { $actor = new User( login: $creator, firstName: $creator, lastName: '', email: '', isAdmin: true, isEnabled: true ); } $event_repo = $container->get_event_repository(); try { $event_service->createEvent( $event, $actor ); } catch ( \Throwable $e ) { // webcal_entry.cal_uid carries a UNIQUE KEY, and that index — // not the check above — is what actually makes this safe: two // retries arriving together both pass the check and one of the // inserts loses. Re-read rather than report a failure for an // event that is now there. $raced = $event_repo->findByUid( $uid ); if ( null === $raced ) { throw $e; } return [ 'id' => $raced->id()->value(), 'created' => false, 'message' => __( 'An event with that uid already exists; returning it unchanged.', 'agenticdaisy-calendar' ), ]; } // Retrieve saved event to get the generated ID $saved_event = $event_repo->findByUid( $event->uid() ); $event_id = ( $saved_event ? $saved_event->id()->value() : 0 ); // Handle category $raw_cat = $input['category_id'] ?? null; if ( $event_id > 0 && is_numeric( $raw_cat ) && (int) $raw_cat > 0 ) { $action_service = $container->get_event_action_service(); $action_service->save_category( $event_id, (int) $raw_cat ); } // Handle participants $invalid_participants = []; if ( $event_id > 0 ) { $participant_list = [$creator]; if ( !empty( $input['participants'] ) && is_array( $input['participants'] ) ) { $result = $this->validate_participants( $input['participants'] ); foreach ( $result['valid'] as $login ) { if ( !in_array( $login, $participant_list, true ) ) { $participant_list[] = $login; } } $invalid_participants = $result['invalid']; } $container->get_participant_repository()->save_participants( $event_id, $participant_list ); } $response = [ 'id' => $event_id, 'created' => true, 'message' => __( 'Event created successfully.', 'agenticdaisy-calendar' ), ]; if ( !empty( $invalid_participants ) ) { $response['invalid_participants'] = $invalid_participants; } return $response; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to create event: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Update an existing event. */ private function register_update_event() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/update-event', [ 'label' => __( 'Update Event', 'agenticdaisy-calendar' ), 'description' => __( 'Update an existing event. Only the fields you pass change; everything else is kept, including details this tool does not expose such as venue and organizer. A repeating event is updated as a whole series; a single occurrence cannot be edited on its own. Users without permission to edit others\' events can update only events they created.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID to update.', 'agenticdaisy-calendar' ), ], 'title' => [ 'type' => 'string', 'description' => __( 'New event title.', 'agenticdaisy-calendar' ), ], 'start' => [ 'type' => 'string', 'description' => __( 'New start date/time in ISO 8601 format (e.g. 2026-03-15T14:00:00). Times without a timezone offset are interpreted as the site\'s configured timezone. A date-only value (e.g. 2026-03-15) creates an all-day event.', 'agenticdaisy-calendar' ), ], 'duration' => [ 'type' => 'integer', 'description' => __( 'New duration in minutes.', 'agenticdaisy-calendar' ), ], 'all_day' => [ 'type' => 'boolean', 'description' => __( 'Whether this is an all-day event.', 'agenticdaisy-calendar' ), ], 'description' => [ 'type' => 'string', 'description' => __( 'New event description.', 'agenticdaisy-calendar' ), ], 'location' => [ 'type' => 'string', 'description' => __( 'New event location.', 'agenticdaisy-calendar' ), ], 'access' => [ 'type' => 'string', 'description' => __( 'Access level: P (Public), C (Confidential), or R (Private).', 'agenticdaisy-calendar' ), 'enum' => ['P', 'C', 'R'], ], 'rrule' => [ 'type' => 'string', 'description' => __( 'New RFC 5545 recurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO). An empty string makes the event non-repeating.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID to assign (0 to remove).', 'agenticdaisy-calendar' ), ], 'venue_id' => [ 'type' => 'integer', 'description' => __( 'Attach a venue by ID, from list-venues. Send 0 to detach it; omit to leave it unchanged. A venue is a reusable place with an address, separate from the event\'s free-text location.', 'agenticdaisy-calendar' ), ], 'organizer_id' => [ 'type' => 'integer', 'description' => __( 'Attach an organizer by ID, from list-organizers. Send 0 to detach it; omit to leave it unchanged. An organizer is a contact shown on the event page, not a participant.', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], 'description' => __( 'Logins of WordPress users or shared/remote calendars to set as participants. Replaces the existing list; the owner is always kept.', 'agenticdaisy-calendar' ), ], ], 'required' => ['event_id'], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', ], 'message' => [ 'type' => 'string', ], 'invalid_participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], ], ], ], 'execute_callback' => [$this, 'execute_update_event'], 'permission_callback' => fn() => current_user_can( 'webcal_can_edit' ), ] ); } /** * Execute update-event ability. * * @param array $input Input parameters. * @return array */ public function execute_update_event( array $input ) : array { $container = Container::get_instance(); $event_service = $container->get_event_service(); $raw_id = $input['event_id'] ?? 0; $id = ( is_numeric( $raw_id ) ? (int) $raw_id : 0 ); $existing = $event_service->getEventById( new EventId($id) ); if ( !$existing ) { return [ 'error' => __( 'Event not found.', 'agenticdaisy-calendar' ), ]; } // Ownership check for non-admins if ( !current_user_can( 'webcal_can_edit_others' ) && $existing->createdBy() !== wp_get_current_user()->user_login ) { return [ 'error' => __( 'You do not have permission to edit this event.', 'agenticdaisy-calendar' ), ]; } try { $is_all_day = ( array_key_exists( 'all_day', $input ) ? !empty( $input['all_day'] ) : $existing->isAllDay() ); $access = $existing->access(); if ( isset( $input['access'] ) && is_string( $input['access'] ) ) { $access = AccessLevel::tryFrom( $input['access'] ) ?? $existing->access(); } $recurrence = $existing->recurrence(); if ( isset( $input['rrule'] ) && is_string( $input['rrule'] ) ) { $recurrence = ( $input['rrule'] === '' ? new Recurrence() : new Recurrence(new RecurrenceRule($input['rrule'])) ); } // Only the editable fields are named; with() carries the rest. // save() is a full-row UPDATE, so anything omitted from a // hand-rolled rebuild is written back as NULL — which let an // agent-driven edit erase an event's venue, organizer and // conference details, and clear the TENTATIVE status that holds // a frontend submission back from the calendar. $event = $existing->with( name: sanitize_text_field( ( isset( $input['title'] ) && is_string( $input['title'] ) ? $input['title'] : $existing->name() ) ), description: wp_kses_post( ( isset( $input['description'] ) && is_string( $input['description'] ) ? $input['description'] : $existing->description() ) ), location: sanitize_text_field( ( isset( $input['location'] ) && is_string( $input['location'] ) ? $input['location'] : $existing->location() ) ), start: ( isset( $input['start'] ) && is_string( $input['start'] ) ? new DateTimeImmutable($input['start']) : $existing->start() ), duration: ( $is_all_day ? 1440 : (( is_numeric( $input['duration'] ?? null ) && (int) $input['duration'] > 0 ? (int) $input['duration'] : $existing->duration() )) ), type: ( $recurrence->isRepeating() ? EventType::REPEATING_EVENT : EventType::EVENT ), access: $access, recurrence: $recurrence, allDay: $is_all_day, venueId: $this->resolve_venue_id( $input['venue_id'] ?? null, $existing->venueId() ), organizerId: $this->resolve_organizer_id( $input['organizer_id'] ?? null, $existing->organizerId() ) ); $creator = wp_get_current_user()->user_login; $user_repo = $container->get_user_repository(); $actor = $user_repo->findByLogin( $creator ); if ( $actor === null ) { $actor = new User( login: $creator, firstName: $creator, lastName: '', email: '', isAdmin: true, isEnabled: true ); } $event_service->updateEvent( $event, $actor ); // Handle category if ( array_key_exists( 'category_id', $input ) ) { $raw_cat_val = $input['category_id'] ?? 0; $cat_id = ( is_numeric( $raw_cat_val ) ? (int) $raw_cat_val : 0 ); $action_service = $container->get_event_action_service(); if ( $cat_id > 0 ) { $action_service->save_category( $id, $cat_id ); } else { $action_service->delete_category( $id ); } } // Handle participants (only when explicitly provided — partial update semantics) $invalid_participants = []; if ( array_key_exists( 'participants', $input ) && is_array( $input['participants'] ) ) { $event_creator = $existing->createdBy(); $participant_list = [$event_creator]; $result = $this->validate_participants( $input['participants'] ); foreach ( $result['valid'] as $login ) { if ( !in_array( $login, $participant_list, true ) ) { $participant_list[] = $login; } } $invalid_participants = $result['invalid']; $container->get_participant_repository()->save_participants( $id, $participant_list ); } $response = [ 'id' => $id, 'message' => __( 'Event updated successfully.', 'agenticdaisy-calendar' ), ]; if ( !empty( $invalid_participants ) ) { $response['invalid_participants'] = $invalid_participants; } return $response; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to update event: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Delete an event. */ private function register_delete_event() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/delete-event', [ 'label' => __( 'Delete Event', 'agenticdaisy-calendar' ), 'description' => __( 'Permanently delete an event by ID; this cannot be undone. Deleting a repeating event deletes every occurrence, since occurrences share the series ID. Users without permission to delete others\' events can delete only events they created.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID to delete.', 'agenticdaisy-calendar' ), ], ], 'required' => ['event_id'], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'message' => [ 'type' => 'string', ], ], ], 'execute_callback' => [$this, 'execute_delete_event'], 'permission_callback' => fn() => current_user_can( 'webcal_can_delete' ), ] ); } /** * Execute delete-event ability. * * @param array $input Input parameters. * @return array */ public function execute_delete_event( array $input ) : array { $container = Container::get_instance(); $event_service = $container->get_event_service(); $raw_id = $input['event_id'] ?? 0; $id = ( is_numeric( $raw_id ) ? (int) $raw_id : 0 ); $event = $event_service->getEventById( new EventId($id) ); if ( !$event ) { return [ 'error' => __( 'Event not found.', 'agenticdaisy-calendar' ), ]; } // Ownership check if ( !current_user_can( 'webcal_can_delete_others' ) && $event->createdBy() !== wp_get_current_user()->user_login ) { return [ 'error' => __( 'You do not have permission to delete this event.', 'agenticdaisy-calendar' ), ]; } try { $creator = wp_get_current_user()->user_login; $user_repo = $container->get_user_repository(); $actor = $user_repo->findByLogin( $creator ); if ( $actor === null ) { $actor = new User( login: $creator, firstName: $creator, lastName: '', email: '', isAdmin: true, isEnabled: true ); } $event_service->deleteEvent( new EventId($id), $actor ); \WebCalendar\WP\Database\EventSideData::purge( $id ); return [ 'message' => __( 'Event deleted successfully.', 'agenticdaisy-calendar' ), ]; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to delete event: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * List categories. */ private function register_list_users() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/list-users', [ 'label' => __( 'List Users', 'agenticdaisy-calendar' ), 'description' => __( 'List calendar users and shared/remote calendars (login, display name, and type) so events can be filtered by user and participants can be specified by login.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional search term to filter by login, display name, or email.', 'agenticdaisy-calendar' ), ], ], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'total' => [ 'type' => 'integer', ], 'users' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'login' => [ 'type' => 'string', ], 'name' => [ 'type' => 'string', ], 'type' => [ 'type' => 'string', 'enum' => ['user', 'shared_calendar', 'remote_calendar'], ], ], ], ], ], ], 'execute_callback' => [$this, 'execute_list_users'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute list-users ability. * * Returns everything that can appear in the `user` event filter and the * `participants` field — the same set the admin participant picker uses — * each tagged with a `type`: * - `user` — a WordPress user with `webcal_can_view` * - `shared_calendar` — an internal shared calendar (resource, no URL) * - `remote_calendar` — an external subscription (resource with a URL) * Read-only. Resources are optional: if they cannot be loaded the WordPress * users are still returned. * * @param array $input Input parameters (optional `search`). * @return array */ public function execute_list_users( array $input = [] ) : array { try { $search = ( isset( $input['search'] ) && is_scalar( $input['search'] ) ? trim( (string) $input['search'] ) : '' ); $query = new \WP_User_Query([ 'search' => ( $search !== '' ? '*' . $search . '*' : '' ), 'search_columns' => UserSearch::columns(), 'number' => 100, 'fields' => ['ID', 'user_login', 'display_name'], 'capability' => 'webcal_can_view', 'orderby' => 'display_name', ]); $users = []; /** @var \WP_User $user */ foreach ( $query->get_results() as $user ) { $users[] = [ 'login' => $user->user_login, 'name' => $user->display_name, 'type' => 'user', ]; } // Shared + remote calendars are stored together as resources and are // valid participant/user-filter values too. Optional — degrade to // users-only if the repository is unavailable. try { $resource_repo = Container::get_instance()->get_resource_repository(); foreach ( $resource_repo->findAll() as $resource ) { $login = $resource->login(); $name = $resource->name(); if ( $search !== '' && stripos( $login, $search ) === false && stripos( $name, $search ) === false ) { continue; } $users[] = [ 'login' => $login, 'name' => $name, 'type' => ( $resource->url() === null ? 'shared_calendar' : 'remote_calendar' ), ]; } } catch ( \Exception $e ) { // Resources unavailable; WordPress users are returned on their own. unset($e); } return [ 'total' => count( $users ), 'users' => $users, ]; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to list users: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } private function register_list_categories() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/list-categories', [ 'label' => __( 'List Categories', 'agenticdaisy-calendar' ), 'description' => __( 'Retrieve the event categories visible to the connected user (ID, name and color). The ID is the value the category_id fields of the other calendar tools take.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [], ], 'output_schema' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', ], 'name' => [ 'type' => 'string', ], 'color' => [ 'type' => 'string', ], ], ], ], 'execute_callback' => [$this, 'execute_list_categories'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute list-categories ability. * * @param array $input Input parameters. * @return array */ public function execute_list_categories( array $input = [] ) : array { try { $container = Container::get_instance(); $category_svc = $container->get_category_service(); $user_login = ( is_user_logged_in() ? wp_get_current_user()->user_login : '' ); $categories = $category_svc->getCategoriesForUser( $user_login ); $result = []; foreach ( $categories as $cat ) { $result[] = [ 'id' => $cat->id(), 'name' => $cat->name(), 'color' => $cat->color() ?? '', ]; } return $result; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to list categories: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Get calendar info and statistics. */ private function register_get_calendar_info() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/get-calendar-info', [ 'label' => __( 'Get Calendar Info', 'agenticdaisy-calendar' ), 'description' => __( 'Get basic site information: plugin version, the number of events the connected user may see, the number of categories visible to the connected user, the time format setting (12 or 24 hour), and the site name.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [], ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'plugin_version' => [ 'type' => 'string', ], 'total_events' => [ 'type' => 'integer', ], 'total_categories' => [ 'type' => 'integer', ], 'time_format' => [ 'type' => 'string', ], 'site_name' => [ 'type' => 'string', ], ], ], 'execute_callback' => [$this, 'execute_get_calendar_info'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute get-calendar-info ability. * * @param array $input Input parameters. * @return array */ public function execute_get_calendar_info( array $input = [] ) : array { try { $container = Container::get_instance(); $category_svc = $container->get_category_service(); $user_login = ( is_user_logged_in() ? wp_get_current_user()->user_login : '' ); // Counted in SQL under the same rules as the other tools' results, // so the total never includes events this user may not see. $total = EventVisibility::count_visible_events(); $categories = $category_svc->getCategoriesForUser( $user_login ); return [ 'plugin_version' => ( defined( 'WEBCALENDAR_WP_VERSION' ) ? WEBCALENDAR_WP_VERSION : 'unknown' ), 'total_events' => $total, 'total_categories' => count( $categories ), 'time_format' => webcal_time_format(), 'site_name' => get_bloginfo( 'name' ), ]; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to get calendar info: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Validate participant logins. * * A participant is a WordPress user or a shared/remote calendar — the * same set list-users returns and the admin participant picker offers. * * @param array $logins Raw login strings. * @return array{valid: string[], invalid: string[]} */ private function validate_participants( array $logins ) : array { $valid = []; $invalid = []; foreach ( $logins as $login ) { if ( !is_string( $login ) ) { continue; } $sanitized = sanitize_text_field( $login ); if ( $sanitized === '' ) { continue; } if ( get_user_by( 'login', $sanitized ) || $this->is_calendar_resource( $sanitized ) ) { $valid[] = $sanitized; } else { $invalid[] = $sanitized; } } return [ 'valid' => $valid, 'invalid' => $invalid, ]; } /** * Whether a login names a shared or remote calendar. * * Resources are optional, as in list-users: if they cannot be loaded the * login is treated as unknown rather than failing the whole call. * * @param string $login Sanitized login. * @return bool */ private function is_calendar_resource( string $login ) : bool { try { return Container::get_instance()->get_resource_repository()->findByLogin( $login ) !== null; } catch ( \Exception $e ) { unset($e); return false; } } /** * Summarize an event for list output (compact format). * * @param Event $event Event entity. * @param \WebCalendar\WP\REST\EventTransformer $transformer Transformer instance. * @return array */ private function summarize_event( Event $event, REST\EventTransformer $transformer ) : array { $data = $transformer->transform_event( $event ); return $this->compact_event_data( $data ); } /** * Summarize a recurring event occurrence for list output (compact format). * * Uses the occurrence's actual start/end dates instead of the base event's * original dates, so results fall within the requested date range. * * @param \WebCalendarDeps\Icalendar\Recurrence\Occurrence $occurrence Recurrence occurrence. * @param Event $event Base event entity. * @param \WebCalendar\WP\REST\EventTransformer $transformer Transformer instance. * @return array */ private function summarize_occurrence( \WebCalendarDeps\Icalendar\Recurrence\Occurrence $occurrence, Event $event, REST\EventTransformer $transformer ) : array { $data = $transformer->transform_occurrence( $occurrence, $event ); return $this->compact_event_data( $data ); } /** * Extract the compact subset of event data for MCP tool output. * * @param array $data Full transformed event data. * @return array */ private function compact_event_data( array $data ) : array { return [ 'id' => $data['id'] ?? 0, 'title' => $data['title'] ?? '', 'start' => $data['start'] ?? '', 'end' => $data['end'] ?? '', 'allDay' => $data['allDay'] ?? false, 'location' => $data['location'] ?? '', 'category_id' => $data['category_id'] ?? 0, 'createdBy' => $data['createdBy'] ?? '', 'access' => $data['access'] ?? '', 'rrule' => $data['rrule'] ?? '', 'url' => $data['url'] ?? '', ]; } /** * Event properties array for use in array_merge (typed return). * * @return array> */ private function event_detail_properties() : array { return [ 'id' => [ 'type' => 'integer', ], 'title' => [ 'type' => 'string', ], 'start' => [ 'type' => 'string', ], 'end' => [ 'type' => 'string', ], 'allDay' => [ 'type' => 'boolean', ], 'location' => [ 'type' => 'string', ], 'description' => [ 'type' => 'string', ], 'category_id' => [ 'type' => 'integer', ], 'createdBy' => [ 'type' => 'string', ], 'access' => [ 'type' => 'string', ], 'rrule' => [ 'type' => 'string', ], 'url' => [ 'type' => 'string', ], ]; } /** * JSON Schema fragment for an event in output schemas. * * @return array */ private function event_schema() : array { return [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', ], 'title' => [ 'type' => 'string', ], 'start' => [ 'type' => 'string', ], 'end' => [ 'type' => 'string', ], 'allDay' => [ 'type' => 'boolean', ], 'location' => [ 'type' => 'string', ], 'category_id' => [ 'type' => 'integer', ], 'createdBy' => [ 'type' => 'string', ], 'access' => [ 'type' => 'string', ], 'rrule' => [ 'type' => 'string', ], 'url' => [ 'type' => 'string', ], ], ]; } /** * List venues. */ private function register_list_venues() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/list-venues', [ 'label' => __( 'List Venues', 'agenticdaisy-calendar' ), 'description' => __( 'Retrieve the venues on this site: reusable places with an address, which events are attached to by ID rather than by repeating the address. The ID is what the venue_id field of the event tools takes. A venue is not a location string — an event can have both, and the free-text location is returned separately by get-event.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional term matched against the venue name; omit to list all.', 'agenticdaisy-calendar' ), ], ], ], 'output_schema' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', ], 'name' => [ 'type' => 'string', ], 'address' => [ 'type' => 'string', ], 'city' => [ 'type' => 'string', ], 'country' => [ 'type' => 'string', ], 'url' => [ 'type' => 'string', ], 'phone' => [ 'type' => 'string', ], ], ], ], 'execute_callback' => [$this, 'execute_list_venues'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute list-venues ability. * * @param array $input Input parameters. * @return array * * @psalm-suppress PossiblyUnusedMethod -- invoked through the ability's and the * MCP tool's execute_callback, never by name. */ public function execute_list_venues( array $input = [] ) : array { try { $search = ( is_string( $input['search'] ?? null ) ? trim( $input['search'] ) : '' ); $result = []; foreach ( Container::get_instance()->get_venue_repository()->findAll() as $venue ) { if ( '' !== $search && stripos( $venue->name(), $search ) === false ) { continue; } $result[] = [ 'id' => $venue->id()->value(), 'name' => $venue->name(), 'address' => $venue->address() ?? '', 'city' => $venue->city() ?? '', 'country' => $venue->country() ?? '', 'url' => $venue->url() ?? '', 'phone' => $venue->phone() ?? '', ]; } return $result; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to list venues: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * List organizers. */ private function register_list_organizers() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/list-organizers', [ 'label' => __( 'List Organizers', 'agenticdaisy-calendar' ), 'description' => __( 'Retrieve the organizers on this site: reusable contacts shown on an event page as who is running it. The ID is what the organizer_id field of the event tools takes. An organizer is not a participant — organizers are contact details displayed publicly, participants are calendar users invited to attend.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional term matched against the organizer name; omit to list all.', 'agenticdaisy-calendar' ), ], ], ], 'output_schema' => [ 'type' => 'array', 'items' => [ 'type' => 'object', 'properties' => [ 'id' => [ 'type' => 'integer', ], 'name' => [ 'type' => 'string', ], 'email' => [ 'type' => 'string', ], 'phone' => [ 'type' => 'string', ], 'url' => [ 'type' => 'string', ], ], ], ], 'execute_callback' => [$this, 'execute_list_organizers'], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute list-organizers ability. * * @param array $input Input parameters. * @return array * * @psalm-suppress PossiblyUnusedMethod -- invoked through the ability's and the * MCP tool's execute_callback, never by name. */ public function execute_list_organizers( array $input = [] ) : array { try { $search = ( is_string( $input['search'] ?? null ) ? trim( $input['search'] ) : '' ); $result = []; foreach ( Container::get_instance()->get_organizer_repository()->findAll() as $organizer ) { if ( '' !== $search && stripos( $organizer->name(), $search ) === false ) { continue; } $result[] = [ 'id' => $organizer->id()->value(), 'name' => $organizer->name(), 'email' => $organizer->email() ?? '', 'phone' => $organizer->phone() ?? '', 'url' => $organizer->url() ?? '', ]; } return $result; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to list organizers: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Read an event's registration state. * * Registration is Starter+, and `src/Rsvp/` is stripped from the free * build entirely — so both the registration and the execution sit inside * can_use_premium_code blocks. The Freemius preprocessor removes the whole * `if`, leaving a method that registers nothing and an executor that * reports the feature is unavailable, with no reference to a class that * is not in the zip. See docs/FREEMIUS_GUIDELINES.md § 2.1. */ private function register_get_event_registration() : void { } /** * Execute get-event-registration ability. * * @param array $input Input parameters. * @return array * * @psalm-suppress PossiblyUnusedMethod -- invoked through the ability's and the * MCP tool's execute_callback, never by name. */ public function execute_get_event_registration( array $input ) : array { return [ 'error' => __( 'Event registration is not available on this site.', 'agenticdaisy-calendar' ), ]; } /** * List the people registered for an event. */ private function register_list_attendees() : void { } /** * Execute list-attendees ability. * * @param array $input Input parameters. * @return array * * @psalm-suppress PossiblyUnusedMethod -- invoked through the ability's and the * MCP tool's execute_callback, never by name. */ public function execute_list_attendees( array $input ) : array { return [ 'error' => __( 'Event registration is not available on this site.', 'agenticdaisy-calendar' ), ]; } /** * Resolve a venue_id argument against the three cases the REST layer uses. * * Absent keeps whatever the event already has, a non-positive value clears * the venue, and anything else has to name a venue that exists. Silently * accepting an unknown id would attach the event to nothing and report * success, which an assistant has no way to notice. * * @param mixed $raw Raw argument. * @param \WebCalendar\Core\Domain\ValueObject\VenueId|null $current Current value. * @return \WebCalendar\Core\Domain\ValueObject\VenueId|null * @throws \InvalidArgumentException When the id names no venue. */ private function resolve_venue_id( $raw, $current ) { if ( null === $raw ) { return $current; } $id = ( is_numeric( $raw ) ? (int) $raw : 0 ); if ( $id <= 0 ) { return null; } $venue_id = new \WebCalendar\Core\Domain\ValueObject\VenueId($id); if ( null === Container::get_instance()->get_venue_repository()->findById( $venue_id ) ) { // Built first, then escaped whole: the escaping sniff treats a // thrown message as output, and escaping only the format string // leaves the interpolated id unaccounted for. $message = sprintf( /* translators: %d: venue ID */ __( 'No venue with ID %d exists. Use list-venues to find one.', 'agenticdaisy-calendar' ), $id ); throw new \InvalidArgumentException(esc_html( $message )); } return $venue_id; } /** * Resolve an organizer_id argument. Same three cases as the venue. * * @param mixed $raw Raw argument. * @param \WebCalendar\Core\Domain\ValueObject\OrganizerId|null $current Current value. * @return \WebCalendar\Core\Domain\ValueObject\OrganizerId|null * @throws \InvalidArgumentException When the id names no organizer. */ private function resolve_organizer_id( $raw, $current ) { if ( null === $raw ) { return $current; } $id = ( is_numeric( $raw ) ? (int) $raw : 0 ); if ( $id <= 0 ) { return null; } $organizer_id = new \WebCalendar\Core\Domain\ValueObject\OrganizerId($id); if ( null === Container::get_instance()->get_organizer_repository()->findById( $organizer_id ) ) { // Built first, then escaped whole: the escaping sniff treats a // thrown message as output, and escaping only the format string // leaves the interpolated id unaccounted for. $message = sprintf( /* translators: %d: organizer ID */ __( 'No organizer with ID %d exists. Use list-organizers to find one.', 'agenticdaisy-calendar' ), $id ); throw new \InvalidArgumentException(esc_html( $message )); } return $organizer_id; } /** * Describe the calendar's contracts, so a client can look them up rather * than guess. * * The tool descriptions say what each argument means; they cannot say what * this site is configured to do. An assistant asked for "tomorrow at 9" * needs the site's timezone, one writing an event needs to know that * access is a single letter and which letter means private, and one * deciding whether to offer registration needs to know whether the feature * is available here at all — none of which is discoverable from a tool * list. Static facts and site state in one call, so the answer cannot * drift from the site the way a written note does. */ private function register_describe_schema() : void { if ( !function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( 'webcalendar/describe-schema', [ 'label' => __( 'Describe Calendar Schema', 'agenticdaisy-calendar' ), 'description' => __( 'Describe this calendar\'s conventions and settings: the site timezone and date formats that bare times are read in, the fields an event has and which are required, what the single-letter access and type codes mean, the paging and rate limits, and which optional features are available on this site. Call it once before creating or interpreting events rather than assuming defaults.', 'agenticdaisy-calendar' ), 'category' => self::CATEGORY, 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'output_schema' => [ 'type' => 'object', 'properties' => [ 'site' => [ 'type' => 'object', ], 'event_fields' => [ 'type' => 'array', ], 'enums' => [ 'type' => 'object', ], 'limits' => [ 'type' => 'object', ], 'features' => [ 'type' => 'object', ], ], ], 'execute_callback' => [$this, 'execute_describe_schema'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ] ); } /** * Execute describe-schema ability. * * @param array $input Input parameters. * @return array * * @psalm-suppress PossiblyUnusedMethod -- invoked through the ability's and the * MCP tool's execute_callback, never by name. * @psalm-suppress UnusedParam -- the ability contract passes input to every * executor; this one takes none. */ public function execute_describe_schema( array $input = [] ) : array { try { return [ 'site' => $this->schema_site(), 'event_fields' => $this->schema_event_fields(), 'enums' => $this->schema_enums(), 'limits' => $this->schema_limits(), 'features' => $this->schema_features(), ]; } catch ( \Exception $e ) { return [ 'error' => __( 'Failed to describe the schema: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ]; } } /** * Site settings that change how a client should read and write times. * * @return array */ private function schema_site() : array { return [ 'timezone' => wp_timezone_string(), 'gmt_offset' => $this->numeric_option( 'gmt_offset' ), 'date_format' => $this->string_option( 'date_format', 'F j, Y' ), 'time_format' => $this->string_option( 'time_format', 'g:i a' ), 'week_starts_on' => (int) $this->numeric_option( 'start_of_week' ), 'plugin_version' => ( defined( 'WEBCALENDAR_WP_VERSION' ) ? WEBCALENDAR_WP_VERSION : '' ), ]; } /** * A site option that should be a string, narrowed for the analysers. */ private function string_option( string $key, string $fallback ) : string { $raw = get_option( $key, $fallback ); return ( is_scalar( $raw ) ? (string) $raw : $fallback ); } /** * A site option that should be a number, narrowed for the analysers. */ private function numeric_option( string $key ) : float { $raw = get_option( $key, 0 ); return ( is_numeric( $raw ) ? (float) $raw : 0.0 ); } /** * The fields an event has, as the tools accept and return them. * * @return array> */ private function schema_event_fields() : array { return [ [ 'name' => 'title', 'type' => 'string', 'required' => true, 'notes' => __( 'Plain text; markup is stripped.', 'agenticdaisy-calendar' ), ], [ 'name' => 'start', 'type' => 'string', 'required' => true, 'notes' => __( 'ISO 8601. Without a timezone it is read in the site timezone above. A date with no time is treated as all-day.', 'agenticdaisy-calendar' ), ], [ 'name' => 'duration', 'type' => 'integer', 'required' => false, 'notes' => __( 'Minutes, not an end time. Defaults to 60, and is forced to 1440 for an all-day event.', 'agenticdaisy-calendar' ), ], [ 'name' => 'description', 'type' => 'string', 'required' => false, 'notes' => __( 'Limited HTML is allowed.', 'agenticdaisy-calendar' ), ], [ 'name' => 'location', 'type' => 'string', 'required' => false, 'notes' => __( 'Free text. Separate from venue_id, which points at a reusable place with an address; an event can have both.', 'agenticdaisy-calendar' ), ], [ 'name' => 'access', 'type' => 'string', 'required' => false, 'notes' => __( 'One of the access codes below. Defaults to P.', 'agenticdaisy-calendar' ), ], [ 'name' => 'rrule', 'type' => 'string', 'required' => false, 'notes' => __( 'RFC 5545 recurrence rule, e.g. FREQ=WEEKLY;BYDAY=MO. An empty string on update stops the event repeating. Updating or deleting a repeating event acts on the whole series.', 'agenticdaisy-calendar' ), ], [ 'name' => 'category_id', 'type' => 'integer', 'required' => false, 'notes' => __( 'From list-categories. 0 removes the category.', 'agenticdaisy-calendar' ), ], [ 'name' => 'venue_id', 'type' => 'integer', 'required' => false, 'notes' => __( 'From list-venues. 0 detaches; omit to leave unchanged.', 'agenticdaisy-calendar' ), ], [ 'name' => 'organizer_id', 'type' => 'integer', 'required' => false, 'notes' => __( 'From list-organizers. 0 detaches; omit to leave unchanged.', 'agenticdaisy-calendar' ), ], [ 'name' => 'participants', 'type' => 'array', 'required' => false, 'notes' => __( 'Logins from list-users. On update this replaces the existing list; the owner is always kept.', 'agenticdaisy-calendar' ), ] ]; } /** * The single-letter codes the calendar stores, and what they mean. * * @return array> */ private function schema_enums() : array { return [ 'access' => [ 'P' => __( 'Public — anyone who can see the calendar.', 'agenticdaisy-calendar' ), 'C' => __( 'Confidential — the time is visible to others, the details are not.', 'agenticdaisy-calendar' ), 'R' => __( 'Private — visible only to the owner and people who may view all events. Note that the code is R, not P.', 'agenticdaisy-calendar' ), ], 'event_type' => [ 'E' => __( 'Event.', 'agenticdaisy-calendar' ), 'M' => __( 'Repeating event.', 'agenticdaisy-calendar' ), 'T' => __( 'Task.', 'agenticdaisy-calendar' ), 'N' => __( 'Repeating task.', 'agenticdaisy-calendar' ), 'J' => __( 'Journal entry.', 'agenticdaisy-calendar' ), 'O' => __( 'Repeating journal entry.', 'agenticdaisy-calendar' ), ], ]; } /** * Paging and rate limits, so a client can size its requests. * * @return array */ private function schema_limits() : array { $raw = get_option( 'webcal_rate_limit', 100 ); $limit = ( is_numeric( $raw ) ? (int) $raw : 100 ); return [ 'list_events_default' => 50, 'list_events_max' => 200, 'paging' => __( 'There is no offset. When total exceeds the entries returned, narrow the date range.', 'agenticdaisy-calendar' ), 'rest_requests_per_interval' => $limit, ]; } /** * Which optional features this site can actually use. * * Reported as booleans rather than a tier name: what a client needs to * know is whether a call will work here, and a tier name only answers that * if it also knows the tier ladder. * * This is the plugin's own tier answer. On a real site it matches what is * registered, because a licence satisfies both gates. On a development * stack using the tier override it can read true for a feature whose tools * are absent, since Freemius is the gate that decides registration and no * local setting satisfies it — so treat the tool list, not this map, as * the last word on what can be called. * * @return array */ private function schema_features() : array { return webcal_feature_flags(); } }