namespace, '/' . $this->rest_base, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_items' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => $this->get_categories_collection_params(), ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'create_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => $this->get_category_create_params(), ), ) ); // GET /categories/{id} - Get single category. // PUT /categories/{id} - Update category. // DELETE /categories/{id} - Delete category. register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P[\d]+)', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'get_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => array( 'id' => array( 'description' => __( 'Category ID.', 'genius-commerce' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), ), ), array( 'methods' => WP_REST_Server::EDITABLE, 'callback' => array( $this, 'update_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => $this->get_category_update_params(), ), array( 'methods' => WP_REST_Server::DELETABLE, 'callback' => array( $this, 'delete_item' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => array( 'id' => array( 'description' => __( 'Category ID.', 'genius-commerce' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'force' => array( 'description' => __( 'Verwijder permanent.', 'genius-commerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ), ), ), ) ); // POST /categories/batch - Batch create/update/delete. register_rest_route( $this->namespace, '/' . $this->rest_base . '/batch', array( array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'batch_items' ), 'permission_callback' => function ( \WP_REST_Request $r ) { return $this->check_scope( $r, \GeniusCommerce\GC_Scope_Map::for_request( $r ) ); }, 'args' => array( 'create' => array( 'description' => __( 'Categories om aan te maken.', 'genius-commerce' ), 'type' => 'array', 'items' => array( 'type' => 'object' ), ), 'update' => array( 'description' => __( 'Categories om bij te werken.', 'genius-commerce' ), 'type' => 'array', 'items' => array( 'type' => 'object' ), ), 'delete' => array( 'description' => __( 'Category IDs om te verwijderen.', 'genius-commerce' ), 'type' => 'array', 'items' => array( 'type' => 'integer' ), ), ), ), ) ); } /** * Get categories collection parameters. * * @return array Collection parameters. */ protected function get_categories_collection_params(): array { $params = $this->get_collection_params(); // Remove modified_after as it doesn't apply to terms. unset( $params['modified_after'] ); $params['parent'] = array( 'description' => __( 'Filter op parent ID (0 voor root categorieën).', 'genius-commerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ); $params['include_empty'] = array( 'description' => __( 'Inclusief categorieën zonder producten.', 'genius-commerce' ), 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', ); return $params; } /** * Get category create parameters. * * @return array Create parameters. */ protected function get_category_create_params(): array { return array( 'name' => array( 'description' => __( 'Naam (verplicht).', 'genius-commerce' ), 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', ), 'slug' => array( 'description' => __( 'Slug voor URL.', 'genius-commerce' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_title', ), 'parent' => array( 'description' => __( 'Parent category ID.', 'genius-commerce' ), 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), 'description' => array( 'description' => __( 'Beschrijving.', 'genius-commerce' ), 'type' => 'string', 'sanitize_callback' => 'wp_kses_post', ), 'image' => array( 'description' => __( 'Category afbeelding (object met id of src).', 'genius-commerce' ), 'type' => 'object', ), 'display' => array( 'description' => __( 'Display type (default, products, subcategories, both).', 'genius-commerce' ), 'type' => 'string', 'default' => 'default', 'sanitize_callback' => 'sanitize_text_field', ), 'menu_order' => array( 'description' => __( 'Menu volgorde.', 'genius-commerce' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ); } /** * Get category update parameters. * * @return array Update parameters. */ protected function get_category_update_params(): array { $params = $this->get_category_create_params(); // ID is required for update. $params['id'] = array( 'description' => __( 'Category ID.', 'genius-commerce' ), 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ); // Name not required for update. $params['name']['required'] = false; return $params; } /** * Get categories. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_items( $request ) { $this->log_request( 'GET /categories', $request->get_params() ); $pagination = $this->get_pagination_params( $request ); // Build query arguments for get_terms(). $args = array( 'taxonomy' => 'product_cat', 'number' => $pagination['per_page'], 'offset' => $pagination['offset'], 'hide_empty' => ! $request->get_param( 'include_empty' ), 'orderby' => 'name', 'order' => 'ASC', ); // Parent filter. $parent = $request->get_param( 'parent' ); if ( $parent !== null ) { $args['parent'] = (int) $parent; } // Get terms. $terms = get_terms( $args ); if ( is_wp_error( $terms ) ) { return new WP_Error( 'gc_categories_error', $terms->get_error_message(), array( 'status' => 500 ) ); } // Get total count. $count_args = $args; $count_args['number'] = 0; $count_args['offset'] = 0; $count_args['count'] = true; $total = (int) get_terms( $count_args ); // Prepare response data. $data = array(); foreach ( $terms as $term ) { $data[] = $this->prepare_category_for_response( $term ); } return $this->create_paginated_response( $data, $pagination['page'], $pagination['per_page'], $total ); } /** * Prepare a category for the REST response. * * @param WP_Term $term The term object. * @return array Prepared category data. */ protected function prepare_category_for_response( WP_Term $term ): array { $data = array( 'id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, 'parent' => $term->parent, 'description' => $term->description, 'count' => $term->count, 'display' => get_term_meta( $term->term_id, 'display_type', true ) ?: 'default', 'menu_order' => (int) get_term_meta( $term->term_id, 'order', true ), ); // Include thumbnail image. $thumbnail_id = get_term_meta( $term->term_id, 'thumbnail_id', true ); if ( $thumbnail_id ) { $data['image'] = array( 'id' => (int) $thumbnail_id, 'src' => wp_get_attachment_url( $thumbnail_id ), 'alt' => get_post_meta( $thumbnail_id, '_wp_attachment_image_alt', true ), ); } else { $data['image'] = null; } return $data; } /** * Create a category. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function create_item( $request ) { $this->log_request( 'POST /categories', $request->get_params() ); $fields = $this->sanitize_term_fields( $request->get_param( 'name' ), $request->get_param( 'description' ) ); $name = $fields['name']; if ( empty( $name ) ) { return new WP_Error( 'gc_missing_name', __( 'Naam is verplicht.', 'genius-commerce' ), array( 'status' => 400 ) ); } $args = array( 'description' => $fields['description'] ?? '', 'parent' => $request->get_param( 'parent' ) ?: 0, ); $slug = $request->get_param( 'slug' ); if ( ! empty( $slug ) ) { $args['slug'] = $slug; } $result = wp_insert_term( $name, 'product_cat', $args ); if ( is_wp_error( $result ) ) { return new WP_Error( 'gc_category_create_failed', $result->get_error_message(), array( 'status' => 500 ) ); } $term_id = $result['term_id']; // Set display type. $display = $request->get_param( 'display' ); if ( $display ) { update_term_meta( $term_id, 'display_type', $display ); } // Set menu order. $menu_order = $request->get_param( 'menu_order' ); if ( $menu_order !== null ) { update_term_meta( $term_id, 'order', $menu_order ); } // Set image. $image = $request->get_param( 'image' ); if ( is_array( $image ) ) { $this->set_category_image( $term_id, $image ); } $term = get_term( $term_id, 'product_cat' ); return new WP_REST_Response( $this->prepare_category_for_response( $term ), 201 ); } /** * Get a single category. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_item( $request ) { $category_id = $request->get_param( 'id' ); $this->log_request( "GET /categories/{$category_id}", $request->get_params() ); $term = get_term( $category_id, 'product_cat' ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'gc_category_not_found', __( 'Category niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } return new WP_REST_Response( $this->prepare_category_for_response( $term ), 200 ); } /** * Update a category. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function update_item( $request ) { $category_id = $request->get_param( 'id' ); $this->log_request( "PUT /categories/{$category_id}", $request->get_params() ); $term = get_term( $category_id, 'product_cat' ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'gc_category_not_found', __( 'Category niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } $args = array(); $fields = $this->sanitize_term_fields( $request->get_param( 'name' ), $request->get_param( 'description' ) ); $name = $fields['name']; if ( $name !== null ) { $args['name'] = $name; } $slug = $request->get_param( 'slug' ); if ( $slug !== null ) { $args['slug'] = $slug; } $description = $fields['description']; if ( $description !== null ) { $args['description'] = $description; } $parent = $request->get_param( 'parent' ); if ( $parent !== null ) { $args['parent'] = $parent; } if ( ! empty( $args ) ) { $result = wp_update_term( $category_id, 'product_cat', $args ); if ( is_wp_error( $result ) ) { return new WP_Error( 'gc_category_update_failed', $result->get_error_message(), array( 'status' => 500 ) ); } } // Update display type. $display = $request->get_param( 'display' ); if ( $display !== null ) { update_term_meta( $category_id, 'display_type', $display ); } // Update menu order. $menu_order = $request->get_param( 'menu_order' ); if ( $menu_order !== null ) { update_term_meta( $category_id, 'order', $menu_order ); } // Update image. $image = $request->get_param( 'image' ); if ( $image !== null ) { if ( is_array( $image ) ) { $this->set_category_image( $category_id, $image ); } else { // Remove image if null/empty. delete_term_meta( $category_id, 'thumbnail_id' ); } } // Refresh term. $term = get_term( $category_id, 'product_cat' ); return new WP_REST_Response( $this->prepare_category_for_response( $term ), 200 ); } /** * Delete a category. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function delete_item( $request ) { $category_id = $request->get_param( 'id' ); $force = $request->get_param( 'force' ); $this->log_request( "DELETE /categories/{$category_id}", $request->get_params() ); $term = get_term( $category_id, 'product_cat' ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'gc_category_not_found', __( 'Category niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } // Get category data before deletion. $category_data = $this->prepare_category_for_response( $term ); // Delete the term. $result = wp_delete_term( $category_id, 'product_cat' ); if ( is_wp_error( $result ) ) { return new WP_Error( 'gc_category_delete_failed', $result->get_error_message(), array( 'status' => 500 ) ); } if ( $result === false ) { return new WP_Error( 'gc_category_delete_failed', __( 'Category verwijderen mislukt.', 'genius-commerce' ), array( 'status' => 500 ) ); } $category_data['deleted'] = true; return new WP_REST_Response( $category_data, 200 ); } /** * Batch create/update/delete categories. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response Response object. */ public function batch_items( $request ) { $this->log_request( 'POST /categories/batch', $request->get_params() ); $results = array( 'create' => array(), 'update' => array(), 'delete' => array(), ); // Process creates. $create = $request->get_param( 'create' ); if ( is_array( $create ) ) { foreach ( $create as $item ) { $sub_request = new WP_REST_Request( 'POST', '/genius-commerce/v1/categories' ); $sub_request->set_body_params( $item ); $response = $this->create_item( $sub_request ); if ( is_wp_error( $response ) ) { $results['create'][] = array( 'error' => array( 'code' => $response->get_error_code(), 'message' => $response->get_error_message(), ), ); } else { $results['create'][] = $response->get_data(); } } } // Process updates. $update = $request->get_param( 'update' ); if ( is_array( $update ) ) { foreach ( $update as $item ) { if ( empty( $item['id'] ) ) { $results['update'][] = array( 'error' => array( 'code' => 'gc_missing_id', 'message' => __( 'ID is verplicht voor update.', 'genius-commerce' ), ), ); continue; } $sub_request = new WP_REST_Request( 'PUT', '/genius-commerce/v1/categories/' . $item['id'] ); $sub_request->set_body_params( $item ); $sub_request->set_url_params( array( 'id' => $item['id'] ) ); $response = $this->update_item( $sub_request ); if ( is_wp_error( $response ) ) { $results['update'][] = array( 'id' => $item['id'], 'error' => array( 'code' => $response->get_error_code(), 'message' => $response->get_error_message(), ), ); } else { $results['update'][] = $response->get_data(); } } } // Process deletes. $delete = $request->get_param( 'delete' ); if ( is_array( $delete ) ) { foreach ( $delete as $category_id ) { $sub_request = new WP_REST_Request( 'DELETE', '/genius-commerce/v1/categories/' . $category_id ); $sub_request->set_url_params( array( 'id' => $category_id ) ); $sub_request->set_param( 'force', true ); $response = $this->delete_item( $sub_request ); if ( is_wp_error( $response ) ) { $results['delete'][] = array( 'id' => $category_id, 'error' => array( 'code' => $response->get_error_code(), 'message' => $response->get_error_message(), ), ); } else { $results['delete'][] = $response->get_data(); } } } return new WP_REST_Response( $results, 200 ); } /** * Set category image. * * @param int $term_id The term ID. * @param array $image The image data (id or src). * @return void */ protected function set_category_image( int $term_id, array $image ): void { // If ID is provided, use it directly. if ( ! empty( $image['id'] ) ) { update_term_meta( $term_id, 'thumbnail_id', (int) $image['id'] ); return; } // If src is provided, upload the image. if ( ! empty( $image['src'] ) ) { $attachment_id = $this->upload_image_from_url( $image['src'] ); if ( $attachment_id ) { update_term_meta( $term_id, 'thumbnail_id', $attachment_id ); } } } /** * Upload an image from URL, or return existing attachment if already uploaded. * * This function prevents duplicate images by: * 1. First checking if an attachment with the normalized URL exists * 2. Then checking by filename alone as fallback * * @param string $url The image URL. * @return int|false Attachment ID or false on failure. */ protected function upload_image_from_url( string $url ) { // Normalize the URL for storage and comparison. $normalized_url = $this->normalize_image_url( $url ); $filename = basename( wp_parse_url( $url, PHP_URL_PATH ) ); // First, check if we already have an attachment with this source URL (normalized). $existing_id = $this->find_attachment_by_source_url( $normalized_url ); if ( $existing_id ) { return $existing_id; } // Also try finding by filename as fallback (for images uploaded before deduplication). $existing_id = $this->find_attachment_by_filename( $filename ); if ( $existing_id ) { // Store the normalized URL for future lookups. update_post_meta( $existing_id, '_genius_commerce_source_url', $normalized_url ); return $existing_id; } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; // Schema/host allowlist enforced before download — story 10.5. $temp_file = $this->download_remote_image( $url ); if ( is_wp_error( $temp_file ) ) { return false; } $file_array = array( 'name' => $filename, 'tmp_name' => $temp_file, ); $attachment_id = media_handle_sideload( $file_array, 0 ); if ( is_wp_error( $attachment_id ) ) { wp_delete_file( $temp_file ); return false; } // Store the normalized source URL as meta for future deduplication. update_post_meta( $attachment_id, '_genius_commerce_source_url', $normalized_url ); return $attachment_id; } /** * Normalize an image URL for consistent comparison. * Removes query parameters and fragments. * * @param string $url The URL to normalize. * @return string The normalized URL. */ protected function normalize_image_url( string $url ): string { $parsed = wp_parse_url( $url ); $normalized = ''; if ( isset( $parsed['scheme'] ) ) { $normalized .= $parsed['scheme'] . '://'; } if ( isset( $parsed['host'] ) ) { $normalized .= $parsed['host']; } if ( isset( $parsed['port'] ) ) { $normalized .= ':' . $parsed['port']; } if ( isset( $parsed['path'] ) ) { $normalized .= $parsed['path']; } return $normalized; } /** * Find an existing attachment by its source URL. * * @param string $url The source URL to search for. * @return int|null Attachment ID or null if not found. */ protected function find_attachment_by_source_url( string $url ): ?int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $attachment_id = $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_genius_commerce_source_url' AND meta_value = %s LIMIT 1", $url ) ); if ( $attachment_id ) { // Verify the attachment still exists. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $exists = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID = %d AND post_type = 'attachment'", $attachment_id ) ); if ( $exists ) { return (int) $attachment_id; } } return null; } /** * Find an existing attachment by filename. * * @param string $filename The filename to search for. * @return int|null Attachment ID or null if not found. */ protected function find_attachment_by_filename( string $filename ): ?int { global $wpdb; // First try exact filename match // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $attachment_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' AND guid LIKE %s ORDER BY ID DESC LIMIT 1", '%/' . $wpdb->esc_like( $filename ) ) ); if ( $attachment_id ) { return (int) $attachment_id; } // Try without extension (handles WordPress image format conversions like jpg -> webp) $basename = pathinfo( $filename, PATHINFO_FILENAME ); if ( $basename ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $attachment_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'attachment' AND guid LIKE %s ORDER BY ID DESC LIMIT 1", '%/' . $wpdb->esc_like( $basename ) . '.%' ) ); } return $attachment_id ? (int) $attachment_id : null; } }