namespace, '/holidays', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_holidays' ], 'permission_callback' => [ $this, 'check_manage_permission' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_holiday' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'name' => $this->rest_arg_string( 200, true ), 'date' => $this->rest_arg_iso_date( true ), ], ], ] ); register_rest_route( $this->namespace, '/holidays/sources', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_sources' ], 'permission_callback' => [ $this, 'check_manage_permission' ], ], ] ); $allowed_sources = [ 'nager', 'google', 'thunderbird' ]; register_rest_route( $this->namespace, '/holidays/import', [ [ 'methods' => 'POST', 'callback' => [ $this, 'import_standard_holidays' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'source' => [ 'required' => true, 'type' => 'string', 'default' => 'nager', 'enum' => $allowed_sources, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static fn( $v ): bool => is_string( $v ) && in_array( $v, $allowed_sources, true ), ], 'country' => [ 'required' => false, 'type' => 'string', 'default' => 'US', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => static fn( $v ): bool => is_string( $v ) && preg_match( '/^[A-Za-z]{2,5}$/', $v ) === 1, ], 'year' => [ 'required' => false, 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $v ): bool { if ( ! is_numeric( $v ) ) { return false; } $year = (int) $v; // 0 is the "use current year" sentinel handled by the handler. return $year === 0 || ( $year >= 1900 && $year <= 2200 ); }, ], ], ], ] ); register_rest_route( $this->namespace, '/holidays/(?P\d+)', [ [ 'methods' => 'DELETE', 'callback' => [ $this, 'delete_holiday' ], 'permission_callback' => [ $this, 'check_manage_permission' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $v ): bool => is_numeric( $v ) && (int) $v > 0, ], ], ], ] ); } /** * Check if user can manage holidays. */ public function check_manage_permission(): bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } return $this->check_permission( 'webcal_can_manage_holidays' ); } /** * Get all global holidays. */ public function get_holidays( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $event_service = $this->container->get_event_service(); $transformer = $this->container->get_event_transformer(); $now = new DateTimeImmutable(); $range = new DateRange( $now->modify( '-2 years' ), $now->modify( '+5 years' ) ); // The holiday calendar, whole. Pinning to a creator is not an // access filter — an administrative scope still reads that // calendar's non-Public entries — which is what this holiday // management screen wants: a holiday saved as anything but Public // must still be listed here so it can be edited or removed. $events = $event_service->getEventsInDateRange( $range, EventScope::administrative()->limitedToUsers( [ self::GLOBAL_CREATOR ] ) ); $data = []; foreach ( $events as $event ) { $data[] = $transformer->transform_event( $event ); } return new WP_REST_Response( $data, 200 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in HolidaysController (get_holidays): ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Get available holiday sources and their countries. */ public function get_sources( WP_REST_Request $request ): WP_REST_Response|WP_Error { try { $registry = $this->container->get_holiday_provider_registry(); $providers = $registry->get_providers(); $data = []; foreach ( $providers as $provider ) { $countries = []; try { $raw = $provider->get_countries(); foreach ( $raw as $code => $name ) { $countries[] = [ 'code' => $code, 'name' => $name, ]; } } catch ( \RuntimeException $e ) { $this->get_logger()->warning( 'Failed to fetch countries for ' . $provider->get_id() . ': ' . $e->getMessage() ); } $data[] = [ 'id' => $provider->get_id(), 'label' => $provider->get_label(), 'countries' => $countries, ]; } return new WP_REST_Response( $data, 200 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in HolidaysController (get_sources): ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Create a new holiday. */ public function create_holiday( WP_REST_Request $request ): WP_REST_Response|WP_Error { $params = $this->safe_params( $request ); if ( empty( $params['name'] ) || empty( $params['date'] ) ) { return new WP_Error( 'missing_params', __( 'Name and date are required', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } try { $event_service = $this->container->get_event_service(); $action_svc = $this->container->get_event_action_service(); // Schema sanitizes via sanitize_text_field / ISO-date validator // (see register_routes()); $this->str() stays for the PHPStan // Level 9 mixed→string coercion. $event = new Event( id: new EventId( 0 ), uid: \wp_generate_uuid4(), name: $this->str( $params['name'] ), description: '', location: '', start: new DateTimeImmutable( $this->str( $params['date'] ) ), duration: 1440, // All day createdBy: self::GLOBAL_CREATOR, type: EventType::EVENT, access: \WebCalendar\Core\Domain\ValueObject\AccessLevel::PUBLIC, recurrence: new \WebCalendar\Core\Domain\ValueObject\Recurrence(), allDay: true ); // Holidays belong to the system user, not to whoever adds them. // Core refuses a non-admin creating an event owned by someone // else, which would fail a custom role granted // webcal_can_manage_holidays; the route has checked that already. $event_service->createEvent( $event, $this->holiday_system_user() ); // Find it back to get ID $event_repo = $this->container->get_event_repository(); $saved_event = $event_repo->findByUid( $event->uid() ); $event_id = $saved_event ? $saved_event->id()->value() : 0; // Assign to Holiday category (ID 3 by default from Activator). Owned by // the holidays creator so the feed (which reads categories by event // creator) returns it for every viewer. if ( $event_id ) { $action_svc->save_category( $event_id, 3, self::GLOBAL_CREATOR ); } return new WP_REST_Response( [ 'id' => $event_id ], 201 ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in HolidaysController (create_holiday): ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Import holidays from a provider. */ public function import_standard_holidays( WP_REST_Request $request ): WP_REST_Response|WP_Error { $params = $this->safe_params( $request ); $source = $this->str( $params['source'] ?? 'nager' ); $country = $this->str( $params['country'] ?? 'US' ); $year_param = $this->int( $params['year'] ?? 0 ); $current_year = (int) gmdate( 'Y' ); $year = $year_param <= 0 ? $current_year : $year_param; // Nager.Date returns single-year ICS; import 3 years when no year specified. $years_to_import = ( 'nager' === $source && $year_param <= 0 ) ? [ $year, $year + 1, $year + 2 ] : [ $year ]; try { $registry = $this->container->get_holiday_provider_registry(); $provider = $registry->get_provider( $source ); if ( $provider === null ) { return new WP_Error( 'webcal_unknown_source', // translators: %s is the holiday source name. sprintf( __( 'Unknown holiday source: %s', 'agenticdaisy-calendar' ), $source ), [ 'status' => 404 ] ); } // Fetch all ICS content before touching import services — a // RuntimeException here returns 502 without needing the services. $ics_list = []; foreach ( $years_to_import as $import_year ) { $ics_list[] = $provider->fetch_holidays( $country, $import_year ); } $import_svc = $this->container->get_import_service(); $action_svc = $this->container->get_event_action_service(); $event_repo = $this->container->get_event_repository(); $system_user = $this->holiday_system_user(); foreach ( $ics_list as $ics ) { $import_svc->importIcal( $ics, $system_user ); } // Post-process: ensure all imported events for this creator have the Holiday category $now = new DateTimeImmutable(); $range = new DateRange( $now->modify( '-2 years' ), $now->modify( '+5 years' ) ); $events = $event_repo->findByDateRange( $range, EventScope::administrative()->limitedToUsers( [ self::GLOBAL_CREATOR ] ) ); foreach ( $events as $event ) { $action_svc->save_category( $event->id()->value(), 3, self::GLOBAL_CREATOR ); } return new WP_REST_Response( [ 'success' => true, 'source' => $source, 'country' => $country, 'years' => $years_to_import, ], 200 ); } catch ( \RuntimeException $e ) { $this->get_logger()->error( 'Holiday import fetch failed: ' . $e->getMessage() ); return new WP_Error( 'webcal_fetch_error', $e->getMessage(), [ 'status' => 502 ] ); } catch ( \Exception $e ) { $this->get_logger()->error( 'Standard Holiday Import failed: ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } /** * Virtual system user that owns global holidays. * * @return \WebCalendar\Core\Domain\Entity\User * * @psalm-suppress UnusedMethod -- Invoked via $this-> within this file; Psalm misses some self-calls. */ private function holiday_system_user(): \WebCalendar\Core\Domain\Entity\User { return new \WebCalendar\Core\Domain\Entity\User( login: self::GLOBAL_CREATOR, firstName: 'System', lastName: 'Calendar', email: 'system@example.com', isAdmin: true, isEnabled: true ); } /** * Delete a holiday. */ public function delete_holiday( WP_REST_Request $request ): WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); try { $event_service = $this->container->get_event_service(); $event_service->deleteEvent( new EventId( $id ), $this->get_current_actor() ); \WebCalendar\WP\Database\EventSideData::purge( $id ); return new WP_REST_Response( null, 204 ); } catch ( \DomainException $e ) { return $this->delete_refused( $e ); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in HolidaysController (delete_holiday): ' . $e->getMessage() ); return new WP_Error( 'webcal_error', $e->getMessage(), [ 'status' => 500 ] ); } } }