namespace, '/layers', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_items' ], 'permission_callback' => [ $this, 'check_permission_layers' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'create_item' ], 'permission_callback' => [ $this, 'check_permission_layers' ], 'args' => [ 'layerUser' => array_merge( $this->rest_arg_login(), [ 'required' => true ] ), 'color' => $this->rest_arg_hex_color(), 'showDuplicates' => $this->rest_arg_bool( false ), ], ], ] ); register_rest_route( $this->namespace, '/layers/(?P\d+)', [ [ 'methods' => 'DELETE', 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'check_permission_layers' ], 'args' => [ 'id' => [ 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => static fn( $v ): bool => is_numeric( $v ) && (int) $v > 0, ], ], ], ] ); register_rest_route( $this->namespace, '/layers/available', [ [ 'methods' => 'GET', 'callback' => [ $this, 'get_available' ], 'permission_callback' => [ $this, 'check_permission_layers' ], ], ] ); } /** * Check if user can manage layers. */ public function check_permission_layers(): 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_layers' ); } /** * Get current user's layer subscriptions. */ public function get_items( $request ): WP_REST_Response { $login = wp_get_current_user()->user_login; $layer_service = $this->container->get_layer_service(); $layers = $layer_service->getLayersForUser( $login ); $data = array_map( fn( Layer $layer ) => $this->prepare_layer( $layer ), $layers ); return new WP_REST_Response( $data, 200 ); } /** * Create a new layer subscription. */ public function create_item( $request ): WP_REST_Response|WP_Error { // Schema (see register_routes(), WPORG-7.7) has already run // sanitize_user on layerUser and sanitize_hex_color on color; // $this->str stays for PHPStan Level 9 mixed→string coercion. $login = wp_get_current_user()->user_login; $layer_user = $this->str( $request->get_param( 'layerUser' ) ); $color = $this->str( $request->get_param( 'color' ) ); $show_dupes = ! empty( $request->get_param( 'showDuplicates' ) ); if ( empty( $layer_user ) ) { return new WP_Error( 'missing_params', __( 'layerUser is required.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } if ( empty( $color ) ) { $color = '#0073aa'; } // Prevent subscribing to own calendar if ( $layer_user === $login ) { return new WP_Error( 'invalid_subscription', __( 'You cannot subscribe to your own calendar.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } // A non-public shared calendar is only offered to the people entitled // to it (get_available), so refuse a subscription built by hand. $shared = $this->container->get_resource_repository()->findByLogin( $layer_user ); if ( $shared !== null && $shared->url() === null && ! EventVisibility::entitled_to_shared_calendar( $shared ) ) { return new WP_Error( 'rest_forbidden', __( 'You do not have permission to subscribe to this calendar.', 'agenticdaisy-calendar' ), [ 'status' => 403 ] ); } // Duplicate? $layer_service = $this->container->get_layer_service(); $existing = $layer_service->getLayersForUser( $login ); foreach ( $existing as $layer ) { if ( $layer->layerUser() === $layer_user ) { return new WP_Error( 'duplicate_subscription', __( 'You are already subscribed to this calendar.', 'agenticdaisy-calendar' ), [ 'status' => 400 ] ); } } $layer = new Layer( id: 0, owner: $login, layerUser: $layer_user, color: $color, showDuplicates: $show_dupes, ); $layer_service->addLayer( $layer ); // Re-fetch to get the assigned ID $layers = $layer_service->getLayersForUser( $login ); $saved = null; foreach ( $layers as $l ) { if ( $l->layerUser() === $layer_user ) { $saved = $l; break; } } return new WP_REST_Response( $saved !== null ? $this->prepare_layer( $saved ) : $this->prepare_layer( $layer ), 201 ); } /** * Delete a layer subscription (verify ownership). */ public function delete_item( $request ): WP_REST_Response|WP_Error { $id = $this->int( $request->get_param( 'id' ) ); $login = wp_get_current_user()->user_login; // Verify the layer belongs to the current user $layer_service = $this->container->get_layer_service(); $layers = $layer_service->getLayersForUser( $login ); $found = false; foreach ( $layers as $layer ) { if ( $layer->id() === $id ) { $found = true; break; } } if ( ! $found ) { return new WP_Error( 'not_found', __( 'Subscription not found.', 'agenticdaisy-calendar' ), [ 'status' => 404 ] ); } $layer_service->deleteLayer( $id ); return new WP_REST_Response( [ 'deleted' => true ], 200 ); } /** * Get available calendars the user can subscribe to. * * Returns other WP users + public shared calendars (Resources with NULL url). */ public function get_available( WP_REST_Request $request ): WP_REST_Response { $login = wp_get_current_user()->user_login; $layer_service = $this->container->get_layer_service(); $current_layers = $layer_service->getLayersForUser( $login ); $subscribed = []; foreach ( $current_layers as $layer ) { $subscribed[] = $layer->layerUser(); } $available = []; // 1. Other WP users with webcal_can_view $wp_users = get_users( [ 'number' => 100, 'orderby' => 'display_name', ] ); foreach ( $wp_users as $wp_user ) { if ( $wp_user->user_login === $login ) { continue; } if ( ! user_can( $wp_user, 'webcal_can_view' ) ) { continue; } $available[] = [ 'login' => $wp_user->user_login, 'name' => esc_html( $wp_user->display_name ), 'type' => 'user', 'subscribed' => in_array( $wp_user->user_login, $subscribed, true ), ]; } // 2. Public shared calendars (Resources with NULL url) $resource_repo = $this->container->get_resource_repository(); $resources = $resource_repo->findAll(); foreach ( $resources as $resource ) { if ( $resource->url() !== null ) { continue; } if ( ! EventVisibility::entitled_to_shared_calendar( $resource ) ) { continue; } $available[] = [ 'login' => $resource->login(), 'name' => esc_html( $resource->name() ), 'type' => 'shared', 'subscribed' => in_array( $resource->login(), $subscribed, true ), ]; } // 3. Remote calendars (Resources with a URL) foreach ( $resources as $resource ) { if ( $resource->url() === null ) { continue; } if ( ! $resource->isPublic() && $resource->admin() !== $login && ! current_user_can( 'webcal_can_import' ) ) { continue; } $available[] = [ 'login' => $resource->login(), 'name' => esc_html( $resource->name() ), 'type' => 'remote', 'subscribed' => in_array( $resource->login(), $subscribed, true ), ]; } return new WP_REST_Response( $available, 200 ); } /** * Prepare layer for JSON response. * * @param Layer $layer The layer entity. * @return array */ private function prepare_layer( Layer $layer ): array { // Resolve display name for the layer user $name = $layer->layerUser(); $wp_user = get_user_by( 'login', $layer->layerUser() ); if ( $wp_user ) { $name = $wp_user->display_name; } else { // Might be a shared calendar (Resource) $resource_repo = $this->container->get_resource_repository(); $resource = $resource_repo->findByLogin( $layer->layerUser() ); if ( $resource !== null ) { $name = $resource->name(); } } return [ 'id' => $layer->id(), 'layerUser' => $layer->layerUser(), 'name' => esc_html( $name ), 'color' => $layer->color(), 'showDuplicates' => $layer->showDuplicates(), ]; } }