user_login : '' ); delete_transient( 'webcal_cats_' . md5( $user_login ) ); } /** * Register the routes. */ public function register_routes() : void { register_rest_route( $this->namespace, '/categories', [[ 'methods' => 'GET', 'callback' => [$this, 'get_categories'], 'permission_callback' => [$this, 'get_categories_permissions_check'], ], [ 'methods' => 'POST', 'callback' => [$this, 'create_category'], 'permission_callback' => [$this, 'manage_categories_permissions_check'], 'args' => [ 'name' => $this->rest_arg_string( 100, true ), 'color' => $this->rest_arg_hex_color(), ], ]] ); register_rest_route( $this->namespace, '/tags', [[ 'methods' => 'GET', 'callback' => [$this, 'get_tags'], 'permission_callback' => [$this, 'get_categories_permissions_check'], ]] ); $category_id_arg = [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $value ): bool => is_numeric( $value ) && (int) $value > 0, ], ]; register_rest_route( $this->namespace, '/categories/(?P\\d+)', [[ 'methods' => 'PUT', 'callback' => [$this, 'update_category'], 'permission_callback' => [$this, 'manage_categories_permissions_check'], 'args' => array_merge( $category_id_arg, [ 'owner' => $this->rest_arg_login(), 'name' => $this->rest_arg_string( 100, false ), 'color' => $this->rest_arg_hex_color(), ] ), ], [ 'methods' => 'DELETE', 'callback' => [$this, 'delete_category'], 'permission_callback' => [$this, 'manage_categories_permissions_check'], 'args' => array_merge( $category_id_arg, [ 'owner' => $this->rest_arg_login(), ] ), ]] ); } /** * Check permissions for managing categories. */ public function manage_categories_permissions_check( WP_REST_Request $request ) : 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_categories' ); } /** * Check permissions for reading categories and tags. * * Public read, matching GET /events: a shortcode or block on a public * page renders the category legend and the Filter Bar for logged-out * visitors, and both need these lists. Requiring a capability here meant * they 403'd for anonymous visitors, so the Filter Bar — whose controls * hide themselves when every list is empty — never appeared at all on a * public calendar, the case it exists for. * * Only names, colors and ids are exposed, all of which already travel * with the public events themselves. Writes remain capability-gated via * manage_categories_permissions_check(). */ public function get_categories_permissions_check( WP_REST_Request $request ) : bool|\WP_Error { $rate_limit = $this->check_rate_limit(); if ( is_wp_error( $rate_limit ) ) { return $rate_limit; } if ( !is_user_logged_in() ) { return true; } return $this->check_permission( 'webcal_can_view' ); } /** * List every tag, for the event editor's suggestions. * * Tags are global and flat — no owner scoping, no colors, no event * counts — so this is a much smaller payload than get_categories() and * needs no per-user cache. */ public function get_tags( WP_REST_Request $request ) : WP_REST_Response|WP_Error { try { $tags = $this->container->get_category_service()->getAllTags(); return new WP_REST_Response(array_values( array_map( static fn( $tag ): array => [ 'id' => $tag->id(), 'name' => $tag->name(), ], $tags ) ), 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in CategoriesController (get_tags): ' . esc_html( $e->getMessage() ) ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Get categories with colors and event counts. */ public function get_categories( WP_REST_Request $request ) : WP_REST_Response|WP_Error { try { // A logged-out request carries an empty WP_User, whose user_login // reads back as false rather than '' — WP_User::__get() falls // through to get_user_meta( 0, ... ). This endpoint is public, so // that false reached md5() and fatalled the whole request. $user_login = ( is_user_logged_in() ? (string) wp_get_current_user()->user_login : '' ); $transient_key = 'webcal_cats_' . md5( $user_login ); $cached = get_transient( $transient_key ); if ( false !== $cached ) { return new WP_REST_Response($cached, 200); } $category_service = $this->container->get_category_service(); $category_repo = $this->container->get_category_repository(); // A logged-out visitor has no personal categories, and asking for // the ones owned by '' would return the global set a second time, // since global rows are exactly the ones with an empty owner. $cats = ( is_user_logged_in() ? $category_service->getCategoriesForUser( $user_login ) : $category_repo->findAllGlobal() ); $categories = []; foreach ( $cats as $cat ) { $categories[] = [ 'id' => $cat->id(), 'owner' => $cat->owner() ?? '', 'name' => $cat->name(), 'color' => ( $cat->color() ?: '#0073aa' ), 'event_count' => $this->get_event_count_by_composite_key( $cat->id(), $cat->owner() ?? '' ), ]; } set_transient( $transient_key, $categories, self::CACHE_TTL ); return new WP_REST_Response($categories, 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in CategoriesController: ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Create a category. */ public function create_category( WP_REST_Request $request ) : WP_REST_Response|WP_Error { try { $category_service = $this->container->get_category_service(); $category_repo = $this->container->get_category_repository(); // Schema sanitizes via sanitize_text_field/sanitize_hex_color // (see register_routes(), WPORG-7.4); $this->str() stays as // PHPStan-Level-9-clean mixed→string coercion. $name = $this->str( $request->get_param( 'name' ) ?? '' ); $color = $this->str( $request->get_param( 'color' ) ?? '#0073aa' ); if ( empty( $name ) ) { return new WP_Error('webcal_invalid', __( 'Category name is required.', 'agenticdaisy-calendar' ), [ 'status' => 400, ]); } $id = $category_repo->nextId(); $category = new \WebCalendar\Core\Domain\Entity\Category( id: $id, owner: null, name: $name, color: $color ); $category_service->createCategory( $category, $this->category_manager() ); $this->invalidate_cache(); return new WP_REST_Response([ 'id' => $id, 'owner' => '', 'name' => $name, 'color' => $color, 'event_count' => 0, ], 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in CategoriesController: ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Update a category's name and/or color. */ public function update_category( WP_REST_Request $request ) : WP_REST_Response|WP_Error { try { $category_service = $this->container->get_category_service(); $category_repo = $this->container->get_category_repository(); // Schema has already sanitized via sanitize_text_field / // sanitize_user / sanitize_hex_color (see register_routes(), // WPORG-7.4); $this->str/int stay for PHPStan narrowing. $id = $this->int( $request->get_param( 'id' ) ); $owner = $this->str( $request->get_param( 'owner' ) ?? '' ); $existing = $this->find_category_by_composite_key( $id, $owner ); if ( !$existing ) { return new WP_Error('webcal_not_found', __( 'Category not found.', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } $name = $this->str( $request->get_param( 'name' ) ?? '' ); $color = $this->str( $request->get_param( 'color' ) ?? '' ); $category = new \WebCalendar\Core\Domain\Entity\Category( id: $id, owner: $existing->owner(), name: ( $name ?: $existing->name() ), color: ( $color ?: $existing->color() ), enabled: $existing->isEnabled() ); $category_service->updateCategory( $category, $this->category_manager() ); $this->invalidate_cache(); return new WP_REST_Response([ 'id' => $category->id(), 'owner' => $category->owner() ?? '', 'name' => $category->name(), 'color' => ( $category->color() ?: '#0073aa' ), 'event_count' => $this->get_event_count_by_composite_key( $id, $category->owner() ?? '' ), ], 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in CategoriesController: ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Delete a category. */ public function delete_category( WP_REST_Request $request ) : WP_REST_Response|WP_Error { try { // Schema sanitizes; $this->str/int stay for PHPStan narrowing. $id = $this->int( $request->get_param( 'id' ) ); $owner = $this->str( $request->get_param( 'owner' ) ?? '' ); $this->delete_category_by_composite_key( $id, $owner ); $this->invalidate_cache(); return new WP_REST_Response([ 'deleted' => true, ], 200); } catch ( \Exception $e ) { $this->get_logger()->error( 'REST Error in CategoriesController: ' . $e->getMessage() ); return new WP_Error('webcal_error', $e->getMessage(), [ 'status' => 500, ]); } } /** * Merge multiple categories into a target category. * * Expects JSON body: { "target_id": int, "source_ids": int[] } */ public function merge_categories( WP_REST_Request $request ) : WP_REST_Response|WP_Error { // Free build: the merge route is not registered (see register_routes), // so this is unreachable there. Generic guard with no licensing language. return new WP_Error('rest_no_route', __( 'No route was found matching the URL and request method.', 'agenticdaisy-calendar' ), [ 'status' => 404, ]); } /** * Find a category using the composite primary key (cat_id, cat_owner). * * Delegates to core's findByCompositeKey() — available since * webcalendar-core v4.3.0 (commit 215c2b5). * * @psalm-suppress UnusedMethod -- Invoked via $this-> within this file; Psalm misses some self-calls. */ private function find_category_by_composite_key( int $id, string $owner ) : ?\WebCalendar\Core\Domain\Entity\Category { return $this->container->get_category_repository()->findByCompositeKey( $id, $owner ); } /** * The actor for category writes. * * Categories here are global, and core lets only its admins change a * global category. In this plugin webcal_can_manage_categories is what * grants that (editors hold it without manage_options), so a holder acts * as a core admin for these writes. The route's permission callback has * already checked the capability; it is checked again here so the * elevation can never outlive it. * * @return User * * @psalm-suppress UnusedMethod -- Invoked via $this-> within this file; Psalm misses some self-calls. */ private function category_manager() : User { $actor = $this->get_current_actor(); if ( $actor->isAdmin() || !current_user_can( 'webcal_can_manage_categories' ) ) { return $actor; } return new User( login: $actor->login(), firstName: $actor->firstName(), lastName: $actor->lastName(), email: $actor->email(), isAdmin: true, isEnabled: $actor->isEnabled() ); } /** * Delete a category using the composite primary key (cat_id, cat_owner). * * Delegates to core's deleteByCompositeKey() — available since * webcalendar-core v4.3.0 (commit 215c2b5). * * @psalm-suppress UnusedMethod -- Invoked via $this-> within this file; Psalm misses some self-calls. */ private function delete_category_by_composite_key( int $id, string $owner ) : void { $this->container->get_category_repository()->deleteByCompositeKey( $id, $owner ); } /** * Count events for a (cat_id, cat_owner) pair. * * Delegates to core's getEventCountByOwner() — available since * webcalendar-core v4.3.0 (commit 215c2b5). * * @psalm-suppress UnusedMethod -- Invoked via $this-> within this file; Psalm misses some self-calls. */ private function get_event_count_by_composite_key( int $id, string $owner ) : int { return $this->container->get_category_repository()->getEventCountByOwner( $id, $owner ); } }