prefix . 'selectika_catalog'; $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table (generation varchar(36) NOT NULL, row_id bigint unsigned NOT NULL, payload longtext NOT NULL, digest char(64) NOT NULL, PRIMARY KEY (generation,row_id)) $charset;" ); } /** * Invalidate in-progress catalog generations after product mutations. */ public static function changed() { update_option( 'selectika_catalog_revision', wp_generate_uuid4(), false ); } /** * Invalidate the catalog when a product or variation is removed. * * @param int $id Native object ID. */ public static function deleted( $id ) { if ( in_array( get_post_type( $id ), array( 'product', 'product_variation' ), true ) ) { self::changed(); } } /** * Report the most recent complete catalog and build state. */ public static function status() { $m = get_option( 'selectika_catalog_manifest', array() ); return array( 'generation' => $m['generation'] ?? null, 'completed_at' => $m['completed_at'] ?? null, 'rows' => $m['rows'] ?? 0, 'error' => get_option( 'selectika_catalog_error', '' ), 'building' => (bool) get_option( 'selectika_catalog_job', false ), ); } /** * Ensure a recurring catalog refresh is scheduled. */ public static function schedule() { if ( ! wp_next_scheduled( 'selectika_catalog_tick' ) ) { wp_schedule_event( time() + 5, 'hourly', 'selectika_catalog_tick' ); } } /** * Register authenticated read-only connection and catalog routes. */ public function routes() { register_rest_route( 'selectika/v1', '/connection', array( 'methods' => 'GET', 'callback' => array( $this, 'connection' ), 'permission_callback' => array( 'Selectika_Connection', 'authorize' ), ) ); register_rest_route( 'selectika/v1', '/catalog', array( 'methods' => 'GET', 'callback' => array( $this, 'page' ), 'permission_callback' => array( 'Selectika_Connection', 'authorize' ), ) ); } /** * Return private connection diagnostics. */ public function connection() { $response = new WP_REST_Response( Selectika_Connection::descriptor() ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } /** * Schedule a fresh build without deleting an active worker's state. */ public static function request_build() { self::changed(); if ( ! wp_next_scheduled( 'selectika_catalog_continue' ) ) { wp_schedule_single_event( time() + 1, 'selectika_catalog_continue' ); } } /** * Build one bounded product batch and publish only complete generations. * * @throws RuntimeException Internally caught to retain the previous completed generation. */ public function tick() { global $wpdb; if ( ! Selectika_Connection::active( 'catalog' ) || ! function_exists( 'wc_get_products' ) ) { return; } $now = time(); $lock = Selectika_Connection::acquire_lock( 'selectika_catalog_lock', 300 ); if ( ! $lock ) { return; } $job = get_option( 'selectika_catalog_job', false ); try { $c = Selectika_Connection::get(); if ( ! $job ) { $job = array( 'generation' => wp_generate_uuid4(), 'connection' => $c['generation'], 'page' => 1, 'rows' => 0, 'revision' => Selectika_Connection::fresh_option( 'selectika_catalog_revision', '' ), 'started' => $now, ); } if ( $c['generation'] !== $job['connection'] || Selectika_Connection::fresh_option( 'selectika_catalog_revision', '' ) !== $job['revision'] || $now - $job['started'] > 3600 ) { throw new RuntimeException( 'catalog_changed_retry' ); } $products = wc_get_products( array( 'status' => 'publish', 'limit' => self::BATCH_SIZE, 'page' => $job['page'], 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $products as $product ) { foreach ( self::rows( $product ) as $row ) { $json = wp_json_encode( $row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Append to a private immutable generation; no WordPress object/cache exists for these rows. if ( false === $json || false === $wpdb->insert( $wpdb->prefix . 'selectika_catalog', array( 'generation' => $job['generation'], 'row_id' => $job['rows'], 'payload' => $json, 'digest' => hash( 'sha256', $json ), ) ) ) { throw new RuntimeException( 'catalog_storage_failed' ); } ++$job['rows']; } } $current = Selectika_Connection::refresh(); if ( Selectika_Connection::fresh_option( 'selectika_catalog_revision', '' ) !== $job['revision'] || ! Selectika_Connection::active( 'catalog' ) || $current['generation'] !== $c['generation'] ) { throw new RuntimeException( 'catalog_changed_retry' ); } if ( count( $products ) < self::BATCH_SIZE ) { $manifest = array( 'generation' => $job['generation'], 'connection' => $c['generation'], 'rows' => $job['rows'], 'completed_at' => gmdate( 'c' ), 'expires' => $now + DAY_IN_SECONDS, ); update_option( 'selectika_catalog_manifest_' . $job['generation'], $manifest, false ); update_option( 'selectika_catalog_manifest', $manifest, false ); delete_option( 'selectika_catalog_job' ); delete_option( 'selectika_catalog_error' ); self::cleanup(); } else { ++$job['page']; update_option( 'selectika_catalog_job', $job, false ); wp_schedule_single_event( time() + 2, 'selectika_catalog_continue' ); } } catch ( Throwable $error ) { delete_option( 'selectika_catalog_job' ); $known = array( 'catalog_changed_retry', 'catalog_storage_failed', 'unsupported_product_type', 'too_many_variations', 'invalid_catalog_product' ); update_option( 'selectika_catalog_error', in_array( $error->getMessage(), $known, true ) ? $error->getMessage() : 'catalog_export_failed', false ); if ( 'catalog_changed_retry' === $error->getMessage() && ! wp_next_scheduled( 'selectika_catalog_continue' ) ) { wp_schedule_single_event( time() + 10, 'selectika_catalog_continue' ); } if ( is_array( $job ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete only a failed or expired private generation; rows are deliberately not cached. $wpdb->delete( $wpdb->prefix . 'selectika_catalog', array( 'generation' => $job['generation'] ) ); } } finally { Selectika_Connection::release_lock( 'selectika_catalog_lock', $lock ); } } /** * Yield stable variant rows using native WooCommerce CRUD objects. * * @param WC_Product $parent_product Native parent product. * @throws RuntimeException When a product cannot be represented without data loss. */ public static function rows( $parent_product ) { if ( ! $parent_product->is_type( array( 'simple', 'variable' ) ) ) { throw new RuntimeException( 'unsupported_product_type' ); } $children = $parent_product->is_type( 'variable' ) ? $parent_product->get_children() : array( $parent_product->get_id() ); if ( count( $children ) > 1000 ) { throw new RuntimeException( 'too_many_variations' ); } $categories = wp_get_post_terms( $parent_product->get_id(), 'product_cat', array( 'fields' => 'names' ) ); foreach ( $children as $id ) { $child = wc_get_product( $id ); if ( ! $child || 'publish' !== $child->get_status() ) { continue; } $price = $child->get_regular_price(); if ( '' === $price ) { $price = $child->get_price(); } if ( '' === $price || ! is_numeric( $price ) || '' === self::identity( $parent_product ) ) { throw new RuntimeException( 'invalid_catalog_product' ); } $attributes = $child->is_type( 'variation' ) ? $child->get_variation_attributes() : array(); $images = array_unique( array_filter( array_merge( array( $child->get_image_id(), $parent_product->get_image_id() ), $parent_product->get_gallery_image_ids() ) ) ); $image_urls = array_values( array_filter( array_map( 'wp_get_attachment_url', $images ) ) ); $variation_id = $child->is_type( 'variation' ) ? $child->get_id() : 0; yield array( 'schema_version' => 1, 'item_id' => self::identity( $parent_product ), 'product_id' => $parent_product->get_id(), 'parent_id' => $parent_product->get_id(), 'variation_id' => $variation_id, 'variant_id' => $parent_product->get_id() . '#' . $variation_id, 'slug' => $parent_product->get_slug(), 'parent_sku' => $parent_product->get_sku(), 'sku' => $child->get_sku(), 'title' => $parent_product->get_name(), 'description' => $parent_product->get_description(), 'short_description' => $parent_product->get_short_description(), 'link' => $child->get_permalink(), 'image_urls' => $image_urls, 'category_path' => is_wp_error( $categories ) ? '' : implode( ' > ', $categories ), 'price' => wc_format_decimal( wc_get_price_to_display( $child, array( 'price' => (float) $price ) ), wc_get_price_decimals() ), 'sale_price' => $child->is_on_sale() ? wc_format_decimal( wc_get_price_to_display( $child ), wc_get_price_decimals() ) : '', 'currency' => get_woocommerce_currency(), 'price_tax_display' => get_option( 'woocommerce_tax_display_shop' ), 'availability' => $child->is_in_stock() && $child->is_purchasable() ? 'in' : 'out', 'stock_status' => $child->get_stock_status(), 'backorders' => $child->get_backorders(), 'attributes' => $attributes, 'color' => 'parent-' . $parent_product->get_id(), 'variant_color' => self::attribute( $attributes, 'color' ), 'size' => self::attribute( $attributes, 'size' ), 'sale_from' => $child->get_date_on_sale_from() ? $child->get_date_on_sale_from()->date( DATE_ATOM ) : null, 'sale_to' => $child->get_date_on_sale_to() ? $child->get_date_on_sale_to()->date( DATE_ATOM ) : null, ); } } /** * Resolve parent identity without conflating native writeback IDs. * * @param WC_Product $parent_product Native parent product. */ private static function identity( $parent_product ) { return Selectika_Connection::item_id( $parent_product ); } /** * Read an attribute by its canonical suffix. * * @param array $attributes Configured attributes. * @param string $name Event or attribute name. */ private static function attribute( $attributes, $name ) { foreach ( $attributes as $key => $value ) { if ( preg_match( '/(?:^|_)' . preg_quote( $name, '/' ) . '$/i', $key ) ) { return $value; } } return ''; } /** * Read and integrity-check one page of an immutable generation. * * @param WP_REST_Request $request Authenticated REST request. */ public function page( $request ) { global $wpdb; if ( ! Selectika_Connection::active( 'catalog' ) ) { return new WP_Error( 'selectika_catalog_disabled', __( 'Catalog export is disabled.', 'selectika' ), array( 'status' => 403 ) ); } $generation = $request->get_param( 'snapshot' ); if ( null !== $generation && ( ! is_string( $generation ) || ! preg_match( '/^[a-f0-9-]{36}$/D', $generation ) ) ) { return new WP_Error( 'selectika_snapshot_invalid', __( 'Invalid catalog generation.', 'selectika' ), array( 'status' => 400 ) ); } $m = get_option( $generation ? 'selectika_catalog_manifest_' . $generation : 'selectika_catalog_manifest', false ); $c = Selectika_Connection::get(); if ( ! $m || $m['expires'] < time() || $m['connection'] !== $c['generation'] ) { return new WP_Error( 'selectika_snapshot_unavailable', __( 'A complete catalog generation is not available. Rebuild the feed.', 'selectika' ), array( 'status' => 503 ) ); } $page = $request->get_param( 'page' ); if ( null !== $page && ( ! is_scalar( $page ) || ! ctype_digit( (string) $page ) || (int) $page < 1 ) ) { return new WP_Error( 'selectika_page_invalid', __( 'Invalid catalog page.', 'selectika' ), array( 'status' => 400 ) ); } $page = max( 1, (int) $page ); $start = ( $page - 1 ) * self::PAGE_SIZE; $expected = min( self::PAGE_SIZE, max( 0, $m['rows'] - $start ) ); if ( $start >= $m['rows'] && ( $page > 1 || $m['rows'] > 0 ) ) { return new WP_Error( 'selectika_page_invalid', __( 'Catalog page is outside the generation.', 'selectika' ), array( 'status' => 400 ) ); } $table = $wpdb->prefix . 'selectika_catalog'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read private snapshot rows directly to validate count and checksums; all identifiers and values are prepared. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT row_id,payload,digest FROM %i WHERE generation=%s AND row_id >= %d AND row_id < %d ORDER BY row_id', $table, $m['generation'], $start, $start + self::PAGE_SIZE ), ARRAY_A ); $results = array(); foreach ( $rows as $index => $row ) { if ( (int) $row['row_id'] !== $start + $index || ! hash_equals( $row['digest'], hash( 'sha256', $row['payload'] ) ) ) { return new WP_Error( 'selectika_catalog_corrupt', __( 'Catalog integrity check failed.', 'selectika' ), array( 'status' => 503 ) ); } $decoded = json_decode( $row['payload'], true ); if ( ! is_array( $decoded ) ) { return new WP_Error( 'selectika_catalog_corrupt', __( 'Catalog integrity check failed.', 'selectika' ), array( 'status' => 503 ) ); } $results[] = $decoded; } if ( count( $results ) !== $expected ) { return new WP_Error( 'selectika_catalog_corrupt', __( 'Catalog integrity check failed.', 'selectika' ), array( 'status' => 503 ) ); } $next = $start + count( $results ) < $m['rows'] ? add_query_arg( array( 'snapshot' => $m['generation'], 'page' => $page + 1, ), rest_url( 'selectika/v1/catalog' ) ) : null; $response = new WP_REST_Response( array( 'schema_version' => 1, 'snapshot' => $m['generation'], 'total' => $m['rows'], 'results' => $results, 'next' => $next, ) ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } /** * Remove expired generations from private storage. */ private static function cleanup() { global $wpdb; $prefix = 'selectika_catalog_manifest_'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Enumerate plugin-owned manifests for expiry; WordPress has no option-prefix listing API. $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( $prefix ) . '%' ) ); foreach ( $names as $name ) { $m = get_option( $name ); if ( is_array( $m ) && $m['expires'] < time() ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Delete only a failed or expired private generation; rows are deliberately not cached. $wpdb->delete( $wpdb->prefix . 'selectika_catalog', array( 'generation' => $m['generation'] ) ); delete_option( $name ); } } } }