] * : Start date (YYYY-MM-DD). Defaults to 30 days ago. * * [--end=] * : End date (YYYY-MM-DD). Defaults to 90 days from now. * * [--fields=] * : Comma-separated columns. Defaults to id,title,start,duration,access. * * [--format=] * : Render format. * --- * default: table * options: * - table * - json * - csv * - yaml * - ids * - count * --- * * ## EXAMPLES * * wp webcal event list * wp webcal event list --start=2026-01-01 --end=2026-12-31 * wp webcal event list --format=json * wp webcal event list --format=ids * * @subcommand list * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * @psalm-suppress UnusedParam -- WP-CLI mandates the $args/$assoc_args signature. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function list_events( array $args, array $assoc_args ): void { $service = Container::get_instance()->get_event_service(); $now = new DateTimeImmutable(); $start = isset( $assoc_args['start'] ) ? new DateTimeImmutable( $assoc_args['start'] ) : $now->modify( '-30 days' ); $end = isset( $assoc_args['end'] ) ? new DateTimeImmutable( $assoc_args['end'] ) : $now->modify( '+90 days' ); // Administrative scope, for the same reason `wp webcal export` uses it: // a shell on the server is already past every check the REST layer // applies, and there is no current user here to narrow against. $events = $service->getEventsInDateRange( new DateRange( $start, $end ), EventScope::administrative() ); $rows = []; foreach ( $events as $event ) { $rows[] = $this->to_row( $event ); } $fields = isset( $assoc_args['fields'] ) ? array_map( 'trim', explode( ',', $assoc_args['fields'] ) ) : [ 'id', 'title', 'start', 'duration', 'access' ]; \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $rows, $fields ); } /** * Show one event. * * ## OPTIONS * * * : The event ID. * * [--fields=] * : Comma-separated fields to show. Defaults to all. * * [--format=] * : Render format. * --- * default: table * options: * - table * - json * - csv * - yaml * --- * * ## EXAMPLES * * wp webcal event get 42 * wp webcal event get 42 --format=json * * @subcommand get * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function get( array $args, array $assoc_args ): void { $event = $this->find_event( $this->id_arg( $args ) ); if ( null === $event ) { return; } $row = $this->to_row( $event ); $fields = isset( $assoc_args['fields'] ) ? array_map( 'trim', explode( ',', $assoc_args['fields'] ) ) : array_keys( $row ); \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', [ $row ], $fields ); } /** * Create an event. * * ## OPTIONS * * --title= * : Event title. * * --start=<datetime> * : Start, as anything DateTimeImmutable accepts, e.g. 2026-10-01T18:30:00. * * [--duration=<minutes>] * : Length in minutes. Defaults to 60. Ignored for --all-day. * * [--description=<text>] * : Event description. * * [--location=<text>] * : Event location. * * [--access=<level>] * : Visibility. Defaults to public. * --- * default: public * options: * - public * - confidential * - private * --- * * [--all-day] * : Make it an all-day event. * * [--user=<login>] * : Owner and actor. Defaults to the current user, or admin under --allow-root. * * [--uid=<uid>] * : Idempotency key, stored as the event's iCalendar UID. Re-running with * the same value returns the original event instead of creating a second * one, so a retried script does not leave duplicates behind. * * [--porcelain] * : Print only the event ID, for scripting. Prints the existing ID when * --uid matched, so a retry still yields the right id. * * ## EXAMPLES * * wp webcal event create --title="Standup" --start=2026-10-01T09:00:00 --duration=15 * wp webcal event create --title="Closed" --start=2026-12-25 --all-day * ID=$(wp webcal event create --title=X --start=2026-10-01T09:00 --porcelain) * # Safe to re-run: the second call returns the first event. * wp webcal event create --title="Standup" --start=2026-10-01T09:00 --uid=standup-2026-10-01 * * @subcommand create * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * @psalm-suppress UnusedParam -- WP-CLI mandates the $args/$assoc_args signature. * * @param array<int, string> $args Positional arguments. * @param array<string, string> $assoc_args Associative arguments. */ public function create( array $args, array $assoc_args ): void { $title = trim( $assoc_args['title'] ?? '' ); if ( '' === $title ) { \WP_CLI::error( 'A --title is required.' ); return; } if ( ! isset( $assoc_args['start'] ) || '' === trim( $assoc_args['start'] ) ) { \WP_CLI::error( 'A --start is required.' ); return; } $start = $this->parse_date( $assoc_args['start'] ); if ( null === $start ) { return; } // Validate the arguments before looking anybody up: a bad --access // should not cost a user-repository round trip, and on a large site // that lookup is the slow part of a command that was always going to // fail. $access = $this->parse_access( $assoc_args['access'] ?? 'public' ); if ( null === $access ) { return; } $actor = $this->resolve_actor( $assoc_args ); if ( null === $actor ) { return; } $all_day = isset( $assoc_args['all-day'] ); $container = Container::get_instance(); $service = $container->get_event_service(); // A supplied --uid makes this safe to re-run. Without one a retried // script creates a second event, because nothing distinguishes a retry // from a genuine request to book the same thing twice. $supplied_uid = trim( $assoc_args['uid'] ?? '' ); $uid = '' !== $supplied_uid ? $supplied_uid : wp_generate_uuid4(); if ( '' !== $supplied_uid ) { $already = $container->get_event_repository()->findByUid( $uid ); if ( null !== $already ) { $existing_id = $already->id()->value(); if ( isset( $assoc_args['porcelain'] ) ) { \WP_CLI::line( (string) $existing_id ); return; } \WP_CLI::success( sprintf( 'Event %d already exists with that uid; nothing created.', $existing_id ) ); return; } } $event = new Event( id: new EventId( 0 ), uid: $uid, name: $title, description: $assoc_args['description'] ?? '', location: $assoc_args['location'] ?? '', start: $start, duration: $all_day ? 1440 : max( 0, (int) ( $assoc_args['duration'] ?? 60 ) ), createdBy: $actor->login(), type: EventType::EVENT, access: $access, allDay: $all_day, ); try { $service->createEvent( $event, $actor ); } catch ( \Throwable $e ) { // webcal_entry.cal_uid is UNIQUE, and that index is what actually // makes --uid safe: two runs racing both pass the check above and // one insert loses. Re-read rather than report a failure for an // event that is now there. $raced = $container->get_event_repository()->findByUid( $uid ); if ( null !== $raced ) { if ( isset( $assoc_args['porcelain'] ) ) { \WP_CLI::line( (string) $raced->id()->value() ); return; } \WP_CLI::success( sprintf( 'Event %d already exists with that uid; nothing created.', $raced->id()->value() ) ); return; } \WP_CLI::error( sprintf( 'Could not create the event: %s', $e->getMessage() ) ); return; } // The entity is readonly, so the saved id never reaches the object we // just passed in. The UID we generated is the only handle back to it — // the same round trip the REST controller makes after createEvent(). $saved = $container->get_event_repository()->findByUid( $uid ); $id = null !== $saved ? $saved->id()->value() : 0; $this->audit( $id, $actor->login(), ActivityLogType::CREATE, $title ); if ( isset( $assoc_args['porcelain'] ) ) { \WP_CLI::line( (string) $id ); return; } \WP_CLI::success( sprintf( 'Created event %d: %s', $id, $title ) ); } /** * Update an event. * * Only the options you pass are changed; everything else is left as it is. * * ## OPTIONS * * <id> * : The event ID. * * [--title=<title>] * : New title. * * [--start=<datetime>] * : New start. * * [--duration=<minutes>] * : New length in minutes. * * [--description=<text>] * : New description. * * [--location=<text>] * : New location. * * [--access=<level>] * : New visibility. * --- * options: * - public * - confidential * - private * --- * * [--user=<login>] * : Actor for the change. Defaults to the current user. * * ## EXAMPLES * * wp webcal event update 42 --title="Renamed" * wp webcal event update 42 --start=2026-10-02T10:00:00 --duration=90 * * @subcommand update * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array<int, string> $args Positional arguments. * @param array<string, string> $assoc_args Associative arguments. */ public function update( array $args, array $assoc_args ): void { $id = $this->id_arg( $args ); $existing = $this->find_event( $id ); if ( null === $existing ) { return; } $actor = $this->resolve_actor( $assoc_args ); if ( null === $actor ) { return; } $start = $existing->start(); if ( isset( $assoc_args['start'] ) ) { $parsed = $this->parse_date( $assoc_args['start'] ); if ( null === $parsed ) { return; } $start = $parsed; } $access = $existing->access(); if ( isset( $assoc_args['access'] ) ) { $parsed_access = $this->parse_access( $assoc_args['access'] ); if ( null === $parsed_access ) { return; } $access = $parsed_access; } $event = $existing->with( name: $assoc_args['title'] ?? $existing->name(), description: $assoc_args['description'] ?? $existing->description(), location: $assoc_args['location'] ?? $existing->location(), start: $start, duration: isset( $assoc_args['duration'] ) ? max( 0, (int) $assoc_args['duration'] ) : $existing->duration(), access: $access, ); try { Container::get_instance()->get_event_service()->updateEvent( $event, $actor ); } catch ( \Throwable $e ) { \WP_CLI::error( sprintf( 'Could not update event %d: %s', $id, $e->getMessage() ) ); return; } $this->audit( $id, $actor->login(), ActivityLogType::UPDATE, $event->name() ); \WP_CLI::success( sprintf( 'Updated event %d.', $id ) ); } /** * Delete an event. * * ## OPTIONS * * <id> * : The event ID. * * [--user=<login>] * : Actor for the deletion. Defaults to the current user. * * [--yes] * : Skip the confirmation prompt. * * ## EXAMPLES * * wp webcal event delete 42 * wp webcal event delete 42 --yes * * @subcommand delete * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * * @param array<int, string> $args Positional arguments. * @param array<string, string> $assoc_args Associative arguments. */ public function delete( array $args, array $assoc_args ): void { $id = $this->id_arg( $args ); $event = $this->find_event( $id ); if ( null === $event ) { return; } $actor = $this->resolve_actor( $assoc_args ); if ( null === $actor ) { return; } // A repeating event takes its whole series with it, which is not // obvious from an id alone — so the prompt says which event it is. \WP_CLI::confirm( sprintf( 'Delete event %d ("%s")?', $id, $event->name() ), $assoc_args ); try { Container::get_instance()->get_event_service()->deleteEvent( new EventId( $id ), $actor ); \WebCalendar\WP\Database\EventSideData::purge( $id ); } catch ( \Throwable $e ) { \WP_CLI::error( sprintf( 'Could not delete event %d: %s', $id, $e->getMessage() ) ); return; } // ActivityLogType has no DELETE case. The REST controller records a // removal as UPDATE as well (EventsController.php:1173), and an audit // trail that disagrees with itself depending on the surface used is // worse than a coarse one. $this->audit( $id, $actor->login(), ActivityLogType::UPDATE, $event->name() ); \WP_CLI::success( sprintf( 'Deleted event %d.', $id ) ); } // ── helpers ────────────────────────────────────────────────────────── /** * The positional id, or an error and 0. * * @param array<int, string> $args Positional arguments. */ private function id_arg( array $args ): int { $id = isset( $args[0] ) ? (int) $args[0] : 0; if ( $id <= 0 ) { \WP_CLI::error( 'An event ID is required.' ); } return $id; } /** * Load an event, or report that it is not there. */ private function find_event( int $id ): ?Event { if ( $id <= 0 ) { return null; } $event = Container::get_instance()->get_event_service()->getEventById( new EventId( $id ) ); if ( null === $event ) { \WP_CLI::error( sprintf( 'No event with ID %d.', $id ) ); return null; } return $event; } /** * Who the change is attributed to. * * Defaults to the current user, which under `--allow-root` is nobody, so * fall back to the first administrator rather than writing an event owned * by the empty string — an ownerless row is invisible to every scoped read * and effectively lost. * * @param array<string, string> $assoc_args Associative arguments. */ private function resolve_actor( array $assoc_args ): ?User { $repo = Container::get_instance()->get_user_repository(); $login = trim( $assoc_args['user'] ?? '' ); if ( '' === $login ) { $current = wp_get_current_user(); $login = $current->exists() ? $current->user_login : ''; } if ( '' === $login ) { $admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'orderby' => 'ID', 'fields' => [ 'user_login' ], ] ); if ( array() === $admins ) { \WP_CLI::error( 'No user to attribute this to. Pass --user=<login>.' ); return null; } $login = (string) $admins[0]->user_login; } $user = $repo->findByLogin( $login ); if ( null === $user ) { \WP_CLI::error( sprintf( 'User not found: %s', $login ) ); return null; } return $user; } /** * Parse a date the way the REST layer does, reporting rather than throwing. */ private function parse_date( string $value ): ?DateTimeImmutable { try { return new DateTimeImmutable( $value ); } catch ( \Exception $e ) { \WP_CLI::error( sprintf( 'Could not read "%s" as a date or time.', $value ) ); return null; } } /** * Map the CLI's words onto the stored single letters. * * The table stores P, C and R, which is not something to make a caller * type — and "R for private" is exactly the kind of detail a script gets * backwards. */ private function parse_access( string $value ): ?AccessLevel { switch ( strtolower( trim( $value ) ) ) { case 'public': case 'p': return AccessLevel::PUBLIC; case 'confidential': case 'c': return AccessLevel::CONFIDENTIAL; case 'private': case 'r': return AccessLevel::PRIVATE; default: \WP_CLI::error( sprintf( 'Unknown --access "%s". Expected public, confidential or private.', $value ) ); return null; } } /** * One event as a flat row, for every output format. * * @return array<string, string|int> */ private function to_row( Event $event ): array { return [ 'id' => $event->id()->value(), 'title' => $event->name(), 'start' => $event->start()->format( 'c' ), 'duration' => $event->duration(), 'all_day' => $event->isAllDay() ? 'yes' : 'no', 'access' => $this->access_word( $event->access() ), 'location' => $event->location(), 'created_by' => $event->createdBy(), 'uid' => $event->uid(), 'description' => $event->description(), ]; } /** * The stored letter as the word the CLI accepts, so output round-trips * back into --access without translation. */ private function access_word( AccessLevel $access ): string { switch ( $access ) { case AccessLevel::CONFIDENTIAL: return 'confidential'; case AccessLevel::PRIVATE: return 'private'; default: return 'public'; } } /** * Record the change in the audit log, matching what REST writes. * * Best effort, as it is over REST: losing the log entry must not fail the * operation that already succeeded. */ private function audit( int $id, string $login, ActivityLogType $type, string $text ): void { if ( $id <= 0 ) { return; } try { Container::get_instance()->get_activity_log_service()->log( $id, $login, null, $type, $text ); } catch ( \Throwable $e ) { \WP_CLI::debug( 'Audit log failed: ' . $e->getMessage(), 'webcal' ); } } }