[\d]+)/variants'; /** * Register REST API routes. * * @return void */ public function register_routes(): void { // GET /products/{id}/variations - List variations. // POST /products/{id}/variations - Create variation. register_rest_route( $this->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_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 ) ); }, ), ) ); // GET /products/{id}/variations/{vid} - Get single variation. // PUT /products/{id}/variations/{vid} - Update variation. // DELETE /products/{id}/variations/{vid} - Delete variation. 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 ) ); }, ), 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 ) ); }, ), 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 ) ); }, ), ) ); // POST /products/{id}/variations/batch - Batch operations. 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 ) ); }, ), ) ); } /** * Validate that the parent product exists and is a variable product. * * @param int $product_id The parent product ID. * @return WC_Product_Variable|WP_Error The product or error. */ protected function validate_parent_product( int $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product ) { return new WP_Error( 'gc_product_not_found', __( 'Parent product niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } if ( ! $product->is_type( 'variable' ) ) { return new WP_Error( 'gc_not_variable_product', __( 'Product is geen variable product. Variations kunnen alleen worden toegevoegd aan variable producten.', 'genius-commerce' ), array( 'status' => 400 ) ); } return $product; } /** * Get all variations for a product. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_items( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $this->log_request( 'GET /products/' . $product_id . '/variations', $request->get_params() ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $pagination = $this->get_pagination_params( $request ); $variation_ids = $product->get_children(); $total = count( $variation_ids ); // Apply pagination. $variation_ids = array_slice( $variation_ids, $pagination['offset'], $pagination['per_page'] ); $data = array(); foreach ( $variation_ids as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( $variation && $variation->is_type( 'variation' ) ) { $data[] = $this->prepare_variation_for_response( $variation ); } } return $this->create_paginated_response( $data, $pagination['page'], $pagination['per_page'], $total ); } /** * Get a single variation. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function get_item( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $variation_id = (int) $request->get_param( 'id' ); $this->log_request( 'GET /products/' . $product_id . '/variations/' . $variation_id, array() ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_type( 'variation' ) ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } // Verify the variation belongs to this product. if ( $variation->get_parent_id() !== $product_id ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation behoort niet tot dit product.', 'genius-commerce' ), array( 'status' => 404 ) ); } return new WP_REST_Response( array( 'data' => $this->prepare_variation_for_response( $variation ) ), 200 ); } /** * Create a variation. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function create_item( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $this->log_request( 'POST /products/' . $product_id . '/variations', array() ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $data = $request->get_json_params(); // Check for required attributes. if ( empty( $data['attributes'] ) ) { return new WP_Error( 'gc_missing_attributes', __( 'Attributes zijn verplicht voor een variation.', 'genius-commerce' ), array( 'status' => 400 ) ); } // Check if SKU already exists. if ( ! empty( $data['sku'] ) ) { $existing_id = wc_get_product_id_by_sku( sanitize_text_field( $data['sku'] ) ); if ( $existing_id ) { return new WP_Error( 'gc_sku_exists', __( 'Een product of variation met deze SKU bestaat al.', 'genius-commerce' ), array( 'status' => 400, 'existing_id' => $existing_id ) ); } } // Create the variation. $variation = new WC_Product_Variation(); $variation->set_parent_id( $product_id ); // Set variation data. $variation = $this->set_variation_data( $variation, $data ); if ( is_wp_error( $variation ) ) { return $variation; } // Save variation. $variation_id = $variation->save(); if ( ! $variation_id ) { return new WP_Error( 'gc_variation_create_failed', __( 'Variation aanmaken mislukt.', 'genius-commerce' ), array( 'status' => 500 ) ); } // Handle image. if ( ! empty( $data['image'] ) ) { $this->set_variation_image( $variation, $data['image'] ); $variation->save(); } // Clear parent product cache. wc_delete_product_transients( $product_id ); // Reload variation. $variation = wc_get_product( $variation_id ); return new WP_REST_Response( array( 'data' => $this->prepare_variation_for_response( $variation ), 'message' => __( 'Variation aangemaakt.', 'genius-commerce' ), ), 201 ); } /** * Update a variation. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function update_item( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $variation_id = (int) $request->get_param( 'id' ); $this->log_request( 'PUT /products/' . $product_id . '/variations/' . $variation_id, array() ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_type( 'variation' ) ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } // Verify the variation belongs to this product. if ( $variation->get_parent_id() !== $product_id ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation behoort niet tot dit product.', 'genius-commerce' ), array( 'status' => 404 ) ); } $data = $request->get_json_params(); // Set variation data. $variation = $this->set_variation_data( $variation, $data ); if ( is_wp_error( $variation ) ) { return $variation; } // Save variation. $variation->save(); // Handle image. if ( isset( $data['image'] ) ) { $this->set_variation_image( $variation, $data['image'] ); $variation->save(); } // Clear parent product cache. wc_delete_product_transients( $product_id ); // Reload variation. $variation = wc_get_product( $variation_id ); return new WP_REST_Response( array( 'data' => $this->prepare_variation_for_response( $variation ), 'message' => __( 'Variation bijgewerkt.', 'genius-commerce' ), ), 200 ); } /** * Delete a variation. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function delete_item( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $variation_id = (int) $request->get_param( 'id' ); $force = $request->get_param( 'force' ) === true; $this->log_request( 'DELETE /products/' . $product_id . '/variations/' . $variation_id, array( 'force' => $force ) ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_type( 'variation' ) ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation niet gevonden.', 'genius-commerce' ), array( 'status' => 404 ) ); } // Verify the variation belongs to this product. if ( $variation->get_parent_id() !== $product_id ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation behoort niet tot dit product.', 'genius-commerce' ), array( 'status' => 404 ) ); } // Prepare response before deletion. $response_data = $this->prepare_variation_for_response( $variation ); // Delete variation. $variation->delete( $force ); // Clear parent product cache. wc_delete_product_transients( $product_id ); return new WP_REST_Response( array( 'data' => $response_data, 'message' => $force ? __( 'Variation permanent verwijderd.', 'genius-commerce' ) : __( 'Variation naar prullenbak verplaatst.', 'genius-commerce' ), 'deleted' => true, ), 200 ); } /** * Batch operations on variations. * * @param WP_REST_Request $request The request object. * @return WP_REST_Response|WP_Error Response object or error. */ public function batch_items( $request ) { $product_id = (int) $request->get_param( 'product_id' ); $this->log_request( 'POST /products/' . $product_id . '/variations/batch', array() ); $product = $this->validate_parent_product( $product_id ); if ( is_wp_error( $product ) ) { return $product; } $data = $request->get_json_params(); $create = isset( $data['create'] ) ? (array) $data['create'] : array(); $update = isset( $data['update'] ) ? (array) $data['update'] : array(); $delete = isset( $data['delete'] ) ? (array) $data['delete'] : array(); // Enforce batch limit. $total = count( $create ) + count( $update ) + count( $delete ); if ( $total > 100 ) { return new WP_Error( 'gc_batch_limit_exceeded', __( 'Maximum 100 operaties per batch request.', 'genius-commerce' ), array( 'status' => 400 ) ); } $results = array( 'create' => array(), 'update' => array(), 'delete' => array(), ); // Process creates. foreach ( $create as $item ) { $result = $this->create_single_variation( $product_id, $item ); if ( is_wp_error( $result ) ) { $results['create'][] = array( 'error' => true, 'message' => $result->get_error_message(), 'sku' => $item['sku'] ?? null, ); } else { $results['create'][] = $this->prepare_variation_for_response( $result ); } } // Process updates. foreach ( $update as $item ) { if ( empty( $item['id'] ) ) { $results['update'][] = array( 'error' => true, 'message' => __( 'Variation ID is verplicht voor update.', 'genius-commerce' ), ); continue; } $result = $this->update_single_variation( $product_id, $item['id'], $item ); if ( is_wp_error( $result ) ) { $results['update'][] = array( 'id' => $item['id'], 'error' => true, 'message' => $result->get_error_message(), ); } else { $results['update'][] = $this->prepare_variation_for_response( $result ); } } // Process deletes. foreach ( $delete as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_type( 'variation' ) || $variation->get_parent_id() !== $product_id ) { $results['delete'][] = array( 'id' => $variation_id, 'error' => true, 'message' => __( 'Variation niet gevonden.', 'genius-commerce' ), ); continue; } $variation->delete( true ); $results['delete'][] = array( 'id' => $variation_id, 'deleted' => true, ); } // Clear parent product cache. wc_delete_product_transients( $product_id ); return new WP_REST_Response( array( 'data' => $results ), 200 ); } /** * Create a single variation (for batch operations). * * @param int $product_id The parent product ID. * @param array $data The variation data. * @return WC_Product_Variation|WP_Error The variation or error. */ protected function create_single_variation( int $product_id, array $data ) { // Check for required attributes. if ( empty( $data['attributes'] ) ) { return new WP_Error( 'gc_missing_attributes', __( 'Attributes zijn verplicht voor een variation.', 'genius-commerce' ) ); } // Check if SKU already exists. if ( ! empty( $data['sku'] ) ) { $existing_id = wc_get_product_id_by_sku( sanitize_text_field( $data['sku'] ) ); if ( $existing_id ) { // Return existing variation instead of error for idempotency. $existing = wc_get_product( $existing_id ); if ( $existing && $existing->is_type( 'variation' ) && $existing->get_parent_id() === $product_id ) { // Update existing variation instead. return $this->update_single_variation( $product_id, $existing_id, $data ); } return new WP_Error( 'gc_sku_exists', __( 'Een product of variation met deze SKU bestaat al.', 'genius-commerce' ) ); } } // Create the variation. $variation = new WC_Product_Variation(); $variation->set_parent_id( $product_id ); // Set variation data. $variation = $this->set_variation_data( $variation, $data ); if ( is_wp_error( $variation ) ) { return $variation; } // Save variation. $variation_id = $variation->save(); if ( ! $variation_id ) { return new WP_Error( 'gc_variation_create_failed', __( 'Variation aanmaken mislukt.', 'genius-commerce' ) ); } // Handle image. if ( ! empty( $data['image'] ) ) { $this->set_variation_image( $variation, $data['image'] ); $variation->save(); } return wc_get_product( $variation_id ); } /** * Update a single variation (for batch operations). * * @param int $product_id The parent product ID. * @param int $variation_id The variation ID. * @param array $data The variation data. * @return WC_Product_Variation|WP_Error The variation or error. */ protected function update_single_variation( int $product_id, int $variation_id, array $data ) { $variation = wc_get_product( $variation_id ); if ( ! $variation || ! $variation->is_type( 'variation' ) ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation niet gevonden.', 'genius-commerce' ) ); } // Verify the variation belongs to this product. if ( $variation->get_parent_id() !== $product_id ) { return new WP_Error( 'gc_variation_not_found', __( 'Variation behoort niet tot dit product.', 'genius-commerce' ) ); } // Set variation data. $variation = $this->set_variation_data( $variation, $data ); if ( is_wp_error( $variation ) ) { return $variation; } // Save variation. $variation->save(); // Handle image. if ( isset( $data['image'] ) ) { $this->set_variation_image( $variation, $data['image'] ); $variation->save(); } return wc_get_product( $variation_id ); } /** * Set variation data from request. * * @param WC_Product_Variation $variation The variation object. * @param array $data The variation data. * @return WC_Product_Variation|WP_Error Updated variation or error. */ protected function set_variation_data( WC_Product_Variation $variation, array $data ) { // SKU. if ( isset( $data['sku'] ) ) { $variation->set_sku( sanitize_text_field( $data['sku'] ) ); } // Status. if ( isset( $data['status'] ) ) { $variation->set_status( sanitize_text_field( $data['status'] ) ); } // Description. if ( isset( $data['description'] ) ) { $variation->set_description( wp_kses_post( $data['description'] ) ); } // Pricing. if ( isset( $data['regular_price'] ) ) { $variation->set_regular_price( sanitize_text_field( $data['regular_price'] ) ); } if ( array_key_exists( 'sale_price', $data ) ) { $variation->set_sale_price( $data['sale_price'] ? sanitize_text_field( $data['sale_price'] ) : '' ); } // Stock. if ( isset( $data['manage_stock'] ) ) { $variation->set_manage_stock( (bool) $data['manage_stock'] ); } if ( isset( $data['stock_quantity'] ) ) { $variation->set_stock_quantity( (int) $data['stock_quantity'] ); } if ( isset( $data['stock_status'] ) ) { $variation->set_stock_status( sanitize_text_field( $data['stock_status'] ) ); } if ( isset( $data['backorders'] ) ) { $variation->set_backorders( sanitize_text_field( $data['backorders'] ) ); } // Weight and dimensions. if ( isset( $data['weight'] ) ) { $variation->set_weight( sanitize_text_field( $data['weight'] ) ); } if ( isset( $data['dimensions'] ) ) { if ( isset( $data['dimensions']['length'] ) ) { $variation->set_length( sanitize_text_field( $data['dimensions']['length'] ) ); } if ( isset( $data['dimensions']['width'] ) ) { $variation->set_width( sanitize_text_field( $data['dimensions']['width'] ) ); } if ( isset( $data['dimensions']['height'] ) ) { $variation->set_height( sanitize_text_field( $data['dimensions']['height'] ) ); } } // Tax. if ( isset( $data['tax_status'] ) ) { $variation->set_tax_status( sanitize_text_field( $data['tax_status'] ) ); } if ( isset( $data['tax_class'] ) ) { $variation->set_tax_class( sanitize_text_field( $data['tax_class'] ) ); } // Attributes. if ( ! empty( $data['attributes'] ) ) { $attributes = array(); foreach ( $data['attributes'] as $attr ) { // Support both formats: // { "id": 1, "option": "Blue" } // { "name": "Color", "option": "Blue" } $attr_name = ''; $attr_id = isset( $attr['id'] ) ? (int) $attr['id'] : 0; if ( $attr_id > 0 ) { // Global attribute by ID - try standard lookup first. $attr_name = wc_attribute_taxonomy_name_by_id( $attr_id ); // If standard lookup failed, try direct database query. // This handles cases where WooCommerce's internal cache is stale. if ( empty( $attr_name ) ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $attr_slug = $wpdb->get_var( $wpdb->prepare( "SELECT attribute_name FROM {$wpdb->prefix}woocommerce_attribute_taxonomies WHERE attribute_id = %d", $attr_id ) ); if ( $attr_slug ) { $attr_name = 'pa_' . $attr_slug; } } } // Fallback to name if ID lookup failed or wasn't provided. if ( empty( $attr_name ) && isset( $attr['name'] ) ) { // Check if it's a taxonomy attribute or custom. $taxonomy = wc_attribute_taxonomy_name( sanitize_title( $attr['name'] ) ); if ( taxonomy_exists( $taxonomy ) ) { $attr_name = $taxonomy; } else { // Maybe the taxonomy isn't registered yet but exists in DB. // Check WooCommerce attribute table directly. global $wpdb; $attr_slug = sanitize_title( $attr['name'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Prepared read with no core API equivalent; sync correctness requires uncached data. $db_exists = $wpdb->get_var( $wpdb->prepare( "SELECT attribute_id FROM {$wpdb->prefix}woocommerce_attribute_taxonomies WHERE attribute_name = %s", $attr_slug ) ); if ( $db_exists ) { $attr_name = 'pa_' . $attr_slug; } else { // Custom/local attribute. $attr_name = sanitize_title( $attr['name'] ); } } } if ( $attr_name && isset( $attr['option'] ) ) { $option_value = sanitize_text_field( $attr['option'] ); // For taxonomy attributes, we need to use the term SLUG, not the name. // WooCommerce stores variation attributes as taxonomy => slug. if ( str_starts_with( $attr_name, 'pa_' ) && taxonomy_exists( $attr_name ) ) { // Look up term by name or slug $term = get_term_by( 'name', $option_value, $attr_name ); if ( ! $term ) { $term = get_term_by( 'slug', sanitize_title( $option_value ), $attr_name ); } if ( $term && ! is_wp_error( $term ) ) { $option_value = $term->slug; } else { // Term doesn't exist - use sanitized title as fallback $option_value = sanitize_title( $option_value ); } } $attributes[ $attr_name ] = $option_value; } } $variation->set_attributes( $attributes ); } // Meta data. if ( ! empty( $data['meta_data'] ) && is_array( $data['meta_data'] ) ) { foreach ( $data['meta_data'] as $meta ) { if ( isset( $meta['key'] ) && isset( $meta['value'] ) ) { $variation->update_meta_data( sanitize_key( $meta['key'] ), sanitize_text_field( $meta['value'] ) ); } } } return $variation; } /** * Set variation image. * * @param WC_Product_Variation $variation The variation object. * @param array $image The image data. * @return void */ protected function set_variation_image( WC_Product_Variation $variation, $image ): void { if ( empty( $image ) ) { $variation->set_image_id( 0 ); return; } $attachment_id = null; if ( isset( $image['id'] ) ) { // Existing attachment ID. $attachment_id = (int) $image['id']; } elseif ( isset( $image['src'] ) ) { // Download image from URL. $attachment_id = $this->upload_image_from_url( $image['src'], $variation->get_parent_id() ); } if ( $attachment_id ) { $variation->set_image_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 exact URL exists * 2. Then checking by normalized URL (without query params) * 3. Finally checking by filename alone * * @param string $url The image URL. * @param int $product_id The parent product ID. * @return int|null Attachment ID or null on failure. */ protected function upload_image_from_url( string $url, int $product_id ): ?int { // 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'; // Download file (schema/host allowlist enforced — story 10.5). $tmp = $this->download_remote_image( $url ); if ( is_wp_error( $tmp ) ) { return null; } // Get file info. $file_array = array( 'name' => $filename, 'tmp_name' => $tmp, ); // Upload file. $attachment_id = media_handle_sideload( $file_array, $product_id ); if ( is_wp_error( $attachment_id ) ) { wp_delete_file( $tmp ); return null; } // 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; // Query for attachment with matching _genius_commerce_source_url meta. // 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. * This is a fallback for images uploaded before the deduplication feature. * Also searches by filename without extension to handle format conversions (e.g., jpg -> webp). * * @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; } /** * Prepare a variation for the REST response. * * @param WC_Product_Variation $variation The variation object. * @return array Prepared variation data. */ protected function prepare_variation_for_response( WC_Product_Variation $variation ): array { // Get attributes. $attributes = array(); foreach ( $variation->get_attributes() as $attr_name => $attr_value ) { $attr_id = wc_attribute_taxonomy_id_by_name( $attr_name ); $attr_label = wc_attribute_label( $attr_name, $variation ); $attributes[] = array( 'id' => $attr_id ?: 0, 'name' => $attr_label, 'slug' => $attr_name, 'option' => $attr_value, ); } // Get image. $image = null; $image_id = $variation->get_image_id(); if ( $image_id ) { $image_url = wp_get_attachment_url( $image_id ); if ( $image_url ) { $image = array( 'id' => $image_id, 'src' => $image_url, 'name' => get_the_title( $image_id ), 'alt' => get_post_meta( $image_id, '_wp_attachment_image_alt', true ), ); } } return array( 'id' => $variation->get_id(), 'parent_id' => $variation->get_parent_id(), 'sku' => $variation->get_sku(), 'status' => $variation->get_status(), 'description' => $variation->get_description(), 'price' => (string) $variation->get_price(), 'regular_price' => (string) $variation->get_regular_price(), 'sale_price' => (string) $variation->get_sale_price(), 'manage_stock' => $variation->get_manage_stock(), 'stock_quantity' => $variation->get_stock_quantity(), 'stock_status' => $variation->get_stock_status(), 'backorders' => $variation->get_backorders(), 'weight' => $variation->get_weight(), 'dimensions' => array( 'length' => $variation->get_length(), 'width' => $variation->get_width(), 'height' => $variation->get_height(), ), 'tax_status' => $variation->get_tax_status(), 'tax_class' => $variation->get_tax_class(), 'attributes' => $attributes, 'image' => $image, 'date_created' => $variation->get_date_created() ? $variation->get_date_created()->format( 'c' ) : null, 'date_modified' => $variation->get_date_modified() ? $variation->get_date_modified()->format( 'c' ) : null, ); } }