>> */ private array $attributeTermNames = []; /** @var array */ private array $categoryTranslations = []; public function __construct( private BridgeService $bridge, private PublicationService $publication, private ?ProductQuickEditService $quickEdits = null ) { $this->repository = new Repository(); $this->apiKeys = new ApiKeyManager(); $this->stagedAssets = new StagedAssetService(); $this->quickEdits ??= new ProductQuickEditService(); } public const REST_NAMESPACE = 'crocoai-data-bridge/v1'; /** Previous namespace used by installed Croco clients. Kept as a compatibility alias. */ public const REST_NAMESPACE_LEGACY = 'wpi/v1'; public function register(): void { add_action('rest_api_init', function (): void { foreach ([self::REST_NAMESPACE, self::REST_NAMESPACE_LEGACY] as $namespace) { $this->registerRoutes($namespace); } }); } private function registerRoutes(string $namespace): void { $permission = [$this, 'permission']; $bridgePermission = [$this, 'bridgePermission']; $adminPermission = [$this, 'adminPermission']; register_rest_route($namespace, '/auth/key', [ ['methods' => 'GET', 'callback' => [$this, 'apiKeyStatus'], 'permission_callback' => $adminPermission], ['methods' => 'POST', 'callback' => [$this, 'createApiKey'], 'permission_callback' => $adminPermission], ]); register_rest_route($namespace, '/auth/key/rotate', [ 'methods' => 'POST', 'callback' => [$this, 'rotateApiKey'], 'permission_callback' => $adminPermission, ]); register_rest_route($namespace, '/settings', [ [ 'methods' => 'GET', 'callback' => [$this, 'settings'], 'permission_callback' => $adminPermission, ], [ 'methods' => 'POST', 'callback' => [$this, 'saveSettings'], 'permission_callback' => $adminPermission, 'args' => [ 'remove_data_on_uninstall' => [ 'type' => 'boolean', 'required' => true, ], ], ], ]); register_rest_route($namespace, '/jobs', [ 'methods' => 'GET', 'callback' => [$this, 'jobs'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'job'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)/pause', [ 'methods' => 'POST', 'callback' => [$this, 'pauseJob'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)/resume', [ 'methods' => 'POST', 'callback' => [$this, 'resumeJob'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)/cancel', [ 'methods' => 'POST', 'callback' => [$this, 'cancelJob'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)/wake', [ 'methods' => 'POST', 'callback' => [$this, 'wakeJob'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/jobs/(?P\d+)/media-ready', [ 'methods' => 'POST', 'callback' => [$this, 'mediaReady'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/diagnostics', [ 'methods' => 'GET', 'callback' => [$this, 'diagnostics'], 'permission_callback' => $permission, ]); register_rest_route($namespace, '/capabilities', [ [ 'methods' => 'GET', 'callback' => [$this, 'capabilities'], 'permission_callback' => $bridgePermission, ], 'schema' => [$this, 'capabilitiesSchema'], ]); register_rest_route($namespace, '/catalog', [ 'methods' => 'GET', 'callback' => [$this, 'catalog'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/ids', [ 'methods' => 'GET', 'callback' => [$this, 'catalogIds'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/manifest', [ 'methods' => 'GET', 'callback' => [$this, 'catalogManifest'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/products/(?P\d+)', [ 'args' => $this->quickEditArgs(), [ 'methods' => 'PATCH', 'callback' => [$this, 'quickEditProduct'], 'permission_callback' => $bridgePermission, ], 'schema' => [$this, 'quickEditResponseSchema'], ]); register_rest_route($namespace, '/catalog/provision', [ 'methods' => 'POST', 'callback' => [$this, 'provisionCatalog'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/attributes', [ 'methods' => 'GET', 'callback' => [$this, 'attributes'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/attributes/plan', [ 'methods' => 'POST', 'callback' => [$this, 'planAttributes'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/catalog/attributes/apply', [ 'methods' => 'POST', 'callback' => [$this, 'applyAttributes'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/assets', [ 'methods' => 'POST', 'callback' => [$this, 'uploadStagedAssets'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/assets/v2', [ [ 'methods' => 'POST', 'callback' => [$this, 'uploadPreparedAssets'], 'permission_callback' => $bridgePermission, 'args' => $this->stagedAssetV2Args(), ], 'schema' => [$this, 'stagedAssetV2ResponseSchema'], ]); register_rest_route($namespace, '/staged/releases', [ 'methods' => 'POST', 'callback' => [$this, 'createStagedRelease'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/releases/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'stagedRelease'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/releases/(?P\d+)/items', [ 'methods' => 'POST', 'callback' => [$this, 'addStagedItems'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/releases/(?P\d+)/results', [ 'methods' => 'GET', 'callback' => [$this, 'stagedReleaseResults'], 'permission_callback' => $bridgePermission, ]); register_rest_route($namespace, '/staged/releases/(?P\d+)/finalize', [ 'methods' => 'POST', 'callback' => [$this, 'finalizeStagedRelease'], 'permission_callback' => $bridgePermission, 'args' => $this->finalizeStagedReleaseArgs(), ]); } public function permission(WP_REST_Request $request): bool|WP_Error { if ($this->adminPermission($request) === true) { return true; } return $this->bridgePermission($request); } public function bridgePermission(WP_REST_Request $request): bool|WP_Error { if ($this->apiKeys->verify(trim((string) $request->get_header(ApiKeyManager::HEADER)))) { return true; } return $this->restError( $request, 'authentication_required', 'A valid Croco API key is required.', 401 ); } public function adminPermission(WP_REST_Request $request): bool|WP_Error { $nonce = (string) $request->get_header('X-WP-Nonce'); if ($nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') && current_user_can('manage_woocommerce')) { return true; } return $this->restError( $request, 'administrator_nonce_required', 'A WooCommerce administrator and valid REST nonce are required.', 403 ); } public function apiKeyStatus(): WP_REST_Response { return rest_ensure_response($this->apiKeys->status()); } public function createApiKey(): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->apiKeys->create()); } public function rotateApiKey(): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->apiKeys->rotate()); } public function settings(): WP_REST_Response { return rest_ensure_response([ 'remove_data_on_uninstall' => (bool) get_option('crocoai_dbridge_remove_data_on_uninstall', false), ]); } public function saveSettings(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(function () use ($request): array { $params = $request->get_json_params() ?: []; if ( !array_key_exists('remove_data_on_uninstall', $params) || !is_bool($params['remove_data_on_uninstall']) ) { throw new \RuntimeException('remove_data_on_uninstall must be a Boolean.'); } update_option( 'crocoai_dbridge_remove_data_on_uninstall', $params['remove_data_on_uninstall'], false ); return [ 'remove_data_on_uninstall' => (bool) get_option('crocoai_dbridge_remove_data_on_uninstall', false), ]; }); } public function jobs(): WP_REST_Response { return rest_ensure_response($this->repository->jobs()); } public function job(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->repository->job((int) $request['id'])); } public function pauseJob(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->publication->pause((int) $request['id'])); } public function resumeJob(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->publication->resume((int) $request['id'])); } public function cancelJob(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->publication->cancel((int) $request['id'])); } public function wakeJob(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(function () use ($request): array { $jobId = (int) $request['id']; if (function_exists('as_unschedule_all_actions')) { as_unschedule_all_actions( 'crocoai_dbridge_process_publication_job', [$jobId], 'crocoai-data-bridge' ); as_unschedule_all_actions( 'wpi_process_publication_job', [$jobId], 'woo-product-importer' ); } $this->publication->processPublication($jobId, false); return $this->repository->job($jobId); }); } public function mediaReady(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->publication->releaseMediaGate((int) $request['id'])); } public function diagnostics(): WP_REST_Response { return rest_ensure_response($this->bridge->diagnostics()); } public function capabilities(): WP_REST_Response { return rest_ensure_response([ 'schema_version' => 1, 'plugin' => [ 'name' => 'CrocoAI Data Bridge', 'version' => CROCOAI_DBRIDGE_VERSION, 'rest_namespace' => self::REST_NAMESPACE, 'legacy_rest_namespace' => self::REST_NAMESPACE_LEGACY, 'bridge_protocol' => BridgeService::BRIDGE_PROTOCOL, 'selected_release_protocol' => BridgeService::SELECTED_RELEASE_PROTOCOL, ], 'authentication' => [ 'type' => 'api_key', 'header' => ApiKeyManager::HEADER, ], 'discovery' => [ 'rest_index' => rest_url('crocoai-data-bridge/v1'), 'capabilities' => rest_url('crocoai-data-bridge/v1/capabilities'), ], 'operations' => [ 'diagnostics' => [ 'method' => 'GET', 'path' => '/wp-json/crocoai-data-bridge/v1/diagnostics', 'description' => 'Read bridge, runtime, integration, database, and worker health.', ], 'catalog_export' => [ 'method' => 'GET', 'path' => '/wp-json/crocoai-data-bridge/v1/catalog', 'description' => 'Read paginated WooCommerce catalog data for Croco.', 'parameters' => [ 'cursor' => 'Return products with an ID above this value.', 'before' => 'Return products with an ID below this value. Zero means no upper bound.', 'limit' => 'Return at most this many products, from 1 to 100.', ], 'product_fields' => [ 'brands' => [ 'type' => 'array', 'taxonomy' => 'product_brand', 'items' => [ 'id' => 'integer', 'name' => 'string', 'slug' => 'string', ], 'empty_when_unassigned' => true, ], ], ], 'catalog_ids' => [ 'method' => 'GET', 'path' => '/wp-json/crocoai-data-bridge/v1/catalog/ids', 'description' => 'Read the ascending ID of every product and variation that the catalog export returns.', ], 'catalog_manifest' => [ 'method' => 'GET', 'path' => '/wp-json/crocoai-data-bridge/v1/catalog/manifest', 'description' => 'Read the parent, modification time, and stock of every exported product and variation, with a signature of the attribute and brand terms, so Croco can fetch only changed products with the catalog include parameter.', ], 'catalog_provision' => [ 'method' => 'POST', 'path' => '/wp-json/crocoai-data-bridge/v1/catalog/provision', 'description' => 'Provision validated catalog dependencies.', ], 'attribute_schema' => [ 'methods' => ['GET', 'POST'], 'paths' => [ '/wp-json/crocoai-data-bridge/v1/catalog/attributes', '/wp-json/crocoai-data-bridge/v1/catalog/attributes/plan', '/wp-json/crocoai-data-bridge/v1/catalog/attributes/apply', ], 'description' => 'Inventory, plan, and apply reviewed attribute-schema operations.', ], 'quick_edit' => [ 'method' => 'PATCH', 'path' => '/wp-json/crocoai-data-bridge/v1/catalog/products/{woocommerce_id}', 'identifier' => 'woocommerce_id', 'fields' => $this->quickEditFieldSchemas(), 'semantics' => [ 'partial_update' => true, 'idempotent' => true, 'temporary' => true, 'last_writer_wins' => true, 'croco_release_remains_authoritative' => true, 'product_language_scoped' => true, ], 'restrictions' => [ 'The request must contain at least one allowlisted field; unknown fields are rejected.', 'Images replace the complete featured and gallery assignment in order; IDs must reference existing image attachments.', 'A variation supports only one image.', 'Attribute edits use exact keys; global pa_ attributes take existing term IDs, and product-specific attributes take strings.', 'Variation values must be enabled on the parent and must not duplicate a sibling combination; local variation keys use stored slugs.', 'Categories replace the complete assignment and must reference existing product_cat IDs.', 'Categories cannot be edited on variations.', 'On multilingual stores, the complete edit runs in the target product language and fails before saving if that language cannot be determined.', 'Variable-parent prices cannot be edited; edit the relevant variations.', 'No term, translation, Croco ownership, canonical snapshot, or staged-release record is created or changed.', ], 'example_request' => [ 'name' => 'Example product', 'short_description' => '

Short summary.

', 'description' => '

Full description.

', 'images' => [101, 102], 'attributes' => [ ['key' => 'pa_color', 'values' => [56]], ['key' => 'Material', 'values' => ['Cotton']], ], 'status' => 'publish', 'category_ids' => [12, 34], 'regular_price' => '129.00', 'sale_price' => '', 'manage_stock' => true, 'stock_quantity' => 8, 'stock_status' => 'instock', 'backorders' => 'no', ], 'response' => [ 'product' => 'Final persisted values for every quick-edit field.', 'changed_fields' => 'Before/after values for fields changed by the save.', ], ], 'staged_publication' => [ 'methods' => ['GET', 'POST'], 'path_prefix' => '/wp-json/crocoai-data-bridge/v1/staged', 'description' => 'Upload assets and create, stage, finalize, and inspect Croco releases.', 'product_brands' => [ 'field' => 'brands', 'type' => 'array', 'items' => ['type' => 'string', 'minLength' => 1], 'taxonomy' => 'product_brand', 'supported_product_types' => ['simple', 'variable'], 'ownership_field' => 'brands', 'minimum_selected_release_protocol' => 6, 'semantics' => [ 'Values are exact native WooCommerce product-brand term names.', 'Missing terms are created idempotently and assignments contain no duplicates.', 'Omitting brands preserves the existing assignment; an empty array clears it.', 'Merchant-owned brands on existing products are preserved.', 'Brands are never assigned to variations.', ], ], 'product_metadata' => [ 'fields' => ['meta_title', 'meta_description', 'meta_data'], 'seo_keys' => \CrocoAI\DataBridge\Domain\ProductMetadata::SEO_KEYS, 'semantics' => [ 'SEO fields are plain-text strings written to both Yoast and Rank Math metadata keys, including when an SEO plugin is inactive.', 'Omitted fields are preserved; an empty string clears the supplied field.', 'Generic SEO fields take precedence over matching keys in meta_data.', 'SEO-only and metadata-only changes are detected; identical retries are unchanged.', 'Field ownership falls back to meta_data ownership; merchant-owned metadata on existing products is preserved.', 'Translate-mode entries can supply independent SEO fields; duplicates inherit the source fields.', ], ], 'prepared_assets' => $this->stagedAssets->capabilities(), 'media_gate' => [ 'protocol' => 1, 'finalize_request_field' => 'media_gate', 'ready_method' => 'POST', 'ready_path' => '/wp-json/crocoai-data-bridge/v1/jobs/{id}/media-ready', 'default_timeout_seconds' => 1800, 'maximum_timeout_seconds' => 3600, 'semantics' => [ 'Products can be processed while Croco prepares and uploads images.', 'Media processing waits until Croco signals readiness.', 'The timeout opens the gate and keeps source-URL fallback available.', 'The readiness signal is idempotent.', ], ], ], 'publication_jobs' => [ 'methods' => ['GET', 'POST'], 'path_prefix' => '/wp-json/crocoai-data-bridge/v1/jobs', 'description' => 'Inspect and control publication jobs.', ], ], ]); } public function catalog(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(function () use ($request): array { $cursor = max(0, (int) ($request['cursor'] ?: 0)); $before = max(0, (int) ($request['before'] ?: 0)); $limit = min(100, max(1, (int) ($request['limit'] ?: 25))); $include = $this->catalogIncludeIds((string) ($request['include'] ?? '')); if ($include !== null) { $ids = $this->catalogIncludedIds($include); $hasMore = false; } else { $ids = $this->catalogProductIds($cursor, $before, $limit + 1); $hasMore = count($ids) > $limit; if ($hasMore) { array_pop($ids); } } $this->categoryTranslations = []; $this->primeCatalogPosts($ids); $multilingual = MultilingualAdapterFactory::make(); $publisher = new WooPublisher($multilingual); $languages = []; foreach ($ids as $id) { $languages[$id] = $multilingual->productLanguage($id); } $translationMaps = $publisher->capturedTranslationMaps($ids); $items = []; foreach ($this->idsByLanguage($languages) as $itemLanguage => $languageIds) { $previousLanguage = $multilingual->switchLanguage((string) $itemLanguage); try { $this->primeCatalogTerms($languageIds); foreach ($languageIds as $id) { $item = $this->catalogItem( $id, (string) $itemLanguage, $multilingual, $translationMaps[$id] ?? [], $languageIds ); if ($item !== null) { $items[] = $item; } } } finally { $multilingual->restoreLanguage($previousLanguage); } } usort($items, static fn (array $a, array $b): int => $a['id'] <=> $b['id']); // The identifier alone does not say what the merchant image is, so // Croco needs the URL as well. Resolve every image of the page after // the loop: one primed cache costs two queries, while a lookup for // each image costs two queries each. $allImageIds = []; foreach ($items as $item) { foreach ($item['image_ids'] as $imageId) { $allImageIds[] = (int) $imageId; } } if ($allImageIds !== []) { _prime_post_caches(array_values(array_unique($allImageIds)), false, true); } foreach ($items as $index => $item) { $imageUrls = []; foreach ($item['image_ids'] as $imageId) { $imageUrl = wp_get_attachment_url((int) $imageId); if (is_string($imageUrl) && $imageUrl !== '') { $imageUrls[] = $imageUrl; } } $items[$index]['image_urls'] = $imageUrls; } $categories = []; $withCategories = $include === null ? $cursor === 0 : (bool) ($request['categories'] ?? false); if ($withCategories) { $adapter = MultilingualAdapterFactory::make(); $terms = get_terms([ 'taxonomy' => 'product_cat', 'hide_empty' => false, 'suppress_filters' => true, 'lang' => 'all', ]); if (is_wp_error($terms)) { throw new \RuntimeException($terms->get_error_message()); } // A multilingual or taxonomy query can omit an empty parent even // when it returns a child. Export the full path for every term. $termsById = []; $pending = $terms; while ($pending !== []) { $term = array_pop($pending); $id = (int) $term->term_id; if (isset($termsById[$id])) { continue; } $termsById[$id] = $term; $parentId = (int) $term->parent; if ($parentId > 0 && !isset($termsById[$parentId])) { $parent = get_term($parentId, 'product_cat'); if ($parent && !is_wp_error($parent)) { $pending[] = $parent; } } } foreach ($termsById as $term) { $id = (int) $term->term_id; $categories[] = [ 'id' => $id, 'parent' => (int) $term->parent, 'name' => (string) $term->name, 'slug' => (string) $term->slug, 'language' => $adapter->termLanguage($id, 'product_cat'), 'translations' => $adapter->termTranslationMap($id, 'product_cat'), ]; } } return [ 'items' => $items, 'categories' => $categories, 'next_cursor' => $ids ? (int) end($ids) : $cursor, 'has_more' => $hasMore, ]; }); } public function quickEditProduct(WP_REST_Request $request): WP_REST_Response|WP_Error { $params = (array) $request->get_json_params(); return $this->guard(fn (): array => $this->quickEdits->edit( (int) $request['woocommerce_id'], $params )); } /** @return array> */ private function quickEditArgs(): array { $fields = $this->quickEditFieldSchemas(); $validators = [ 'name' => static fn (mixed $value): bool => is_string($value) && trim($value) !== '', 'short_description' => static fn (mixed $value): bool => is_string($value), 'description' => static fn (mixed $value): bool => is_string($value), 'images' => static fn (mixed $value): bool => is_array($value) && array_is_list($value) && array_reduce( $value, static fn (bool $valid, mixed $id): bool => $valid && is_int($id) && $id > 0, true ), 'attributes' => static fn (mixed $value): bool => is_array($value) && array_is_list($value) && $value !== [] && count($value) <= 100 && array_reduce( $value, static fn (bool $valid, mixed $row): bool => $valid && is_array($row) && count($row) === 2 && array_key_exists('key', $row) && array_key_exists('values', $row) && is_string($row['key']) && trim($row['key']) === $row['key'] && $row['key'] !== '' && is_array($row['values']) && array_is_list($row['values']) && array_reduce( $row['values'], static fn (bool $validValue, mixed $item): bool => $validValue && (is_string($item) || (is_int($item) && $item > 0)), true ), true ), 'status' => static fn (mixed $value): bool => is_string($value) && in_array($value, ['draft', 'pending', 'private', 'publish'], true), 'category_ids' => static fn (mixed $value): bool => is_array($value) && array_is_list($value) && array_reduce( $value, static fn (bool $valid, mixed $id): bool => $valid && is_int($id) && $id > 0, true ), 'regular_price' => static fn (mixed $value): bool => is_string($value) && ($value === '' || preg_match('/^[0-9]+(?:\.[0-9]+)?$/D', $value) === 1), 'sale_price' => static fn (mixed $value): bool => is_string($value) && ($value === '' || preg_match('/^[0-9]+(?:\.[0-9]+)?$/D', $value) === 1), 'manage_stock' => static fn (mixed $value): bool => is_bool($value), 'stock_quantity' => static fn (mixed $value): bool => $value === null || ((is_int($value) || is_float($value)) && $value >= 0), 'stock_status' => static fn (mixed $value): bool => is_string($value) && in_array($value, ['instock', 'outofstock', 'onbackorder'], true), 'backorders' => static fn (mixed $value): bool => is_string($value) && in_array($value, ['no', 'notify', 'yes'], true), ]; foreach ($validators as $field => $validator) { $fields[$field]['validate_callback'] = $validator; $fields[$field]['sanitize_callback'] = static fn (mixed $value): mixed => $value; } return [ 'woocommerce_id' => [ 'description' => 'Exact WooCommerce product or variation ID.', 'type' => 'integer', 'minimum' => 1, 'required' => true, ], ] + $fields; } /** @return array> */ private function quickEditFieldSchemas(): array { return [ 'name' => [ 'description' => 'Product name; must not be empty.', 'type' => 'string', 'minLength' => 1, ], 'short_description' => [ 'description' => 'Product short description; HTML is filtered with wp_kses_post. Empty string clears it.', 'type' => 'string', ], 'description' => [ 'description' => 'Product description; HTML is filtered with wp_kses_post. Empty string clears it.', 'type' => 'string', ], 'images' => [ 'description' => 'Ordered image attachment IDs; first is featured and the rest form the gallery. Empty array clears all images.', 'type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 1], 'uniqueItems' => true, ], 'attributes' => [ 'description' => 'Partial edits by attribute key. Global pa_ attributes use existing term IDs; product-specific attributes use plain strings. An empty values list clears that attribute. Local variation keys use stored slugs and values must match a parent axis.', 'type' => 'array', 'minItems' => 1, 'maxItems' => 100, 'items' => [ 'type' => 'object', 'required' => ['key', 'values'], 'additionalProperties' => false, 'properties' => [ 'key' => ['type' => 'string', 'minLength' => 1], 'values' => ['type' => 'array', 'items' => ['type' => ['integer', 'string']]], ], ], ], 'status' => [ 'description' => 'WordPress product status.', 'type' => 'string', 'enum' => ['draft', 'pending', 'private', 'publish'], ], 'category_ids' => [ 'description' => 'Complete replacement category assignment using existing product_cat term IDs.', 'type' => 'array', 'items' => ['type' => 'integer', 'minimum' => 1], ], 'regular_price' => [ 'description' => 'Non-negative decimal string; an empty string clears the price.', 'type' => 'string', 'pattern' => '^(?:|[0-9]+(?:\.[0-9]+)?)$', ], 'sale_price' => [ 'description' => 'Non-negative decimal string no greater than regular_price; an empty string clears it.', 'type' => 'string', 'pattern' => '^(?:|[0-9]+(?:\.[0-9]+)?)$', ], 'manage_stock' => [ 'description' => 'Whether WooCommerce manages stock quantities for this product.', 'type' => 'boolean', ], 'stock_quantity' => [ 'description' => 'Non-negative stock quantity or null; a non-null value requires manage_stock.', 'type' => ['number', 'null'], 'minimum' => 0, ], 'stock_status' => [ 'description' => 'WooCommerce stock status.', 'type' => 'string', 'enum' => ['instock', 'outofstock', 'onbackorder'], ], 'backorders' => [ 'description' => 'Backorder behavior; notify or yes requires manage_stock.', 'type' => 'string', 'enum' => ['no', 'notify', 'yes'], ], ]; } /** @return array */ public function quickEditResponseSchema(): array { $productProperties = [ 'id' => ['type' => 'integer'], 'type' => ['type' => 'string'], ] + $this->quickEditFieldSchemas() + [ 'date_modified_gmt' => [ 'description' => 'UTC modification timestamp in YYYY-MM-DDTHH:MM:SS format.', 'type' => ['string', 'null'], ], ]; unset($productProperties['attributes']['minItems']); return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'crocoai_dbridge_quick_edit_response', 'type' => 'object', 'properties' => [ 'product' => ['type' => 'object', 'properties' => $productProperties], 'changed_fields' => [ 'type' => 'object', 'additionalProperties' => [ 'type' => 'object', 'properties' => [ 'before' => ['type' => ['string', 'number', 'integer', 'boolean', 'array', 'null']], 'after' => ['type' => ['string', 'number', 'integer', 'boolean', 'array', 'null']], ], ], ], ], ]; } /** @return array */ /** * @param array $translationMap * @param list $groupIds * @return array|null */ private function catalogItem( int $id, string $itemLanguage, MultilingualAdapter $multilingual, array $translationMap, array $groupIds ): ?array { $product = wc_get_product($id); if (!$product) { return null; } $dimensions = [ 'length' => (string) $product->get_length('edit'), 'width' => (string) $product->get_width('edit'), 'height' => (string) $product->get_height('edit'), ]; // A variation shows one image. A parent shows its main image and // then its gallery, in that order. $imageIds = $product instanceof \WC_Product_Variation ? array_values(array_filter([(int) $product->get_image_id('edit')])) : array_values(array_filter(array_merge( [(int) $product->get_image_id('edit')], $product->get_gallery_image_ids('edit') ))); // An attribute is reported by the identity the store keeps it // under, with the label it currently shows over it. A reader // that compared labels alone saw a difference on every run // after a merchant or Croco renamed one, and could never // settle it, because the store was not wrong. $localizedLabels = (array) \CrocoAI\DataBridge\LegacyKeys::productMeta($product, 'attribute_labels'); $attributes = []; $variationAttributes = []; if ($product instanceof \WC_Product_Variation) { foreach ($product->get_attributes('edit') as $name => $value) { $isTaxonomy = taxonomy_exists((string) $name); $key = AttributeIdentity::fromProductAttribute((string) $name); $label = isset($localizedLabels[$key]) ? (string) $localizedLabels[$key] : ($isTaxonomy ? wc_attribute_label((string) $name) : (string) $name); $term = $isTaxonomy ? get_term_by('slug', (string) $value, (string) $name) : false; $attributes[] = [ 'key' => $key, 'name' => $label, 'values' => [(string) ($term && !is_wp_error($term) ? $term->name : $value)], ]; $variationAttributes[] = $key; } } else { foreach ($product->get_attributes('edit') as $attribute) { if (!$attribute instanceof \WC_Product_Attribute) { continue; } $isTaxonomy = $attribute->is_taxonomy(); $key = AttributeIdentity::fromProductAttribute((string) $attribute->get_name()); $attributeName = isset($localizedLabels[$key]) ? (string) $localizedLabels[$key] : ($isTaxonomy ? wc_attribute_label($attribute->get_name()) : (string) $attribute->get_name()); $attributes[] = [ 'key' => $key, 'name' => $attributeName, 'values' => array_values(array_map( 'strval', $isTaxonomy ? $this->attributeTermNames($id, $attribute->get_name(), $groupIds) : $attribute->get_options() )), ]; if ($attribute->get_variation()) { $variationAttributes[] = $key; } } } $language = $multilingual->available() ? $itemLanguage : ''; if ($language === '' && $product instanceof \WC_Product_Variation) { $language = $multilingual->productLanguage((int) $product->get_parent_id('edit')); } $originalId = $multilingual->available() ? $multilingual->originalProductId($id) : $id; $translationOf = ($originalId > 0 && $originalId !== $id) ? $originalId : 0; $categoryIds = $product instanceof \WC_Product_Variation ? [] : $this->categoryIdsInLanguage( $product->get_category_ids('edit'), $language, $multilingual ); return [ 'id' => $id, 'parent_id' => (int) $product->get_parent_id('edit'), 'type' => $product->get_type(), 'slug' => (string) $product->get_slug('edit'), 'permalink' => (string) get_permalink($id), 'sku' => (string) $product->get_sku('edit'), 'global_unique_id' => is_callable([$product, 'get_global_unique_id']) ? (string) $product->get_global_unique_id('edit') : '', 'cogs_value' => $this->costOfGoodsSoldEnabled() && is_callable([$product, 'get_cogs_value']) ? $product->get_cogs_value() : null, 'name' => (string) $product->get_name('edit'), 'description' => (string) $product->get_description('edit'), 'short_description' => (string) $product->get_short_description('edit'), 'regular_price' => (string) $product->get_regular_price('edit'), 'sale_price' => (string) $product->get_sale_price('edit'), 'stock_quantity' => $product->get_stock_quantity('edit'), 'manage_stock' => (bool) $product->get_manage_stock('edit'), 'backorders' => (string) $product->get_backorders('edit'), 'stock_status' => (string) $product->get_stock_status('edit'), 'weight' => (string) $product->get_weight('edit'), 'dimensions' => $dimensions, 'status' => (string) $product->get_status('edit'), 'category_ids' => $categoryIds, 'brands' => CatalogBrands::forProduct($id), 'attributes' => $attributes, 'variation_attributes' => array_values($variationAttributes), 'image_ids' => $imageIds, 'date_modified_gmt' => $product->get_date_modified('edit')?->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s'), 'language' => $language, 'translation_of' => $translationOf, 'translations' => $translationMap, ]; } /** * @return list */ private function catalogProductIds(int $cursor, int $before, int $limit): array { global $wpdb; $upperBound = $before > 0 ? $before : PHP_INT_MAX; return array_map('intval', $wpdb->get_col($wpdb->prepare( "SELECT ID FROM %i WHERE ID>%d AND ID<%d AND post_type IN ('product','product_variation')" . " AND post_status NOT IN ('auto-draft','trash') ORDER BY ID ASC LIMIT %d", $wpdb->posts, $cursor, $upperBound, $limit )) ?: []); } /** * @return list|null */ private function catalogIncludeIds(string $include): ?array { if (trim($include) === '') { return null; } $ids = array_values(array_unique(array_filter( array_map('intval', explode(',', $include)), static fn (int $id): bool => $id > 0 ))); if ($ids === [] || count($ids) > 100) { throw new \InvalidArgumentException('Send between 1 and 100 product IDs in include.'); } sort($ids); return $ids; } /** * @param list $ids * @return list */ private function catalogIncludedIds(array $ids): array { global $wpdb; $placeholders = implode(',', array_fill(0, count($ids), '%d')); return array_map('intval', $wpdb->get_col($wpdb->prepare( "SELECT ID FROM %i WHERE ID IN ($placeholders) AND post_type IN ('product','product_variation')" . " AND post_status NOT IN ('auto-draft','trash') ORDER BY ID ASC", $wpdb->posts, ...$ids )) ?: []); } /** * The manifest reads stock from meta because WooCommerce changes stock * with direct SQL when an order is placed, which leaves post_modified_gmt * unchanged. The term signature covers attribute and brand renames, which * change the exported names of every product that uses the term. */ public function catalogManifest(): WP_REST_Response|WP_Error { return $this->guard(function (): array { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare( "SELECT p.ID, p.post_parent, p.post_modified_gmt," . " (SELECT m.meta_value FROM %i m WHERE m.post_id=p.ID AND m.meta_key='_stock' ORDER BY m.meta_id LIMIT 1) AS stock," . " (SELECT m.meta_value FROM %i m WHERE m.post_id=p.ID AND m.meta_key='_stock_status' ORDER BY m.meta_id LIMIT 1) AS stock_status" . " FROM %i p WHERE p.post_type IN ('product','product_variation')" . " AND p.post_status NOT IN ('auto-draft','trash') ORDER BY p.ID ASC", $wpdb->postmeta, $wpdb->postmeta, $wpdb->posts ), ARRAY_N) ?: []; $items = []; foreach ($rows as [$id, $parent, $modified, $stock, $stockStatus]) { $items[] = [ 'id' => (int) $id, 'parent_id' => (int) $parent, 'date_modified_gmt' => $modified === null || str_starts_with((string) $modified, '0000') ? null : str_replace(' ', 'T', (string) $modified), 'stock_quantity' => $stock === null || $stock === '' ? null : wc_stock_amount($stock), 'stock_status' => (string) $stockStatus, ]; } return [ 'plugin_version' => CROCOAI_DBRIDGE_VERSION, 'term_signature' => $this->catalogTermSignature(), 'items' => $items, ]; }); } private function catalogTermSignature(): string { global $wpdb; $terms = $wpdb->get_results($wpdb->prepare( "SELECT t.term_id, tt.taxonomy, t.name, t.slug FROM %i t JOIN %i tt ON tt.term_id=t.term_id" . " WHERE tt.taxonomy='product_brand' OR tt.taxonomy LIKE %s ORDER BY t.term_id, tt.taxonomy", $wpdb->terms, $wpdb->term_taxonomy, $wpdb->esc_like('pa_') . '%' ), ARRAY_N) ?: []; $attributes = $wpdb->get_results($wpdb->prepare( "SELECT attribute_id, attribute_name, attribute_label FROM %i ORDER BY attribute_id", $wpdb->prefix . 'woocommerce_attribute_taxonomies' ), ARRAY_N) ?: []; return md5((string) wp_json_encode([$terms, $attributes])); } public function catalogIds(): WP_REST_Response|WP_Error { return $this->guard(function (): array { global $wpdb; return ['ids' => array_map('intval', $wpdb->get_col($wpdb->prepare( "SELECT ID FROM %i WHERE post_type IN ('product','product_variation')" . " AND post_status NOT IN ('auto-draft','trash') ORDER BY ID ASC", $wpdb->posts )) ?: [])]; }); } /** * Group a page by product language in ID order, so the request switches * the multilingual context once per language instead of once per product. * * @param array $languages * @return array> */ private function idsByLanguage(array $languages): array { $groups = []; foreach ($languages as $id => $language) { $groups[$language][] = (int) $id; } return $groups; } /** * Load the posts and meta of a page, and of the parents its variations * read, in a few queries instead of several for each product. * * @param list $ids */ private function primeCatalogPosts(array $ids): void { _prime_post_caches($ids, false, true); $parentIds = $this->variationParentIds($ids); if ($parentIds !== []) { _prime_post_caches($parentIds, false, true); } } /** * Load the terms of one language group while the request is in that * language, because a multilingual plugin can adjust a term query to the * current language. * * @param list $ids */ private function primeCatalogTerms(array $ids): void { $this->attributeTermNames = []; $posts = array_values(array_filter( array_map(static fn (int $id) => get_post($id), array_merge($ids, $this->variationParentIds($ids))), static fn ($post): bool => $post instanceof \WP_Post )); update_post_caches($posts, 'any', true, false); } /** * @param list $ids * @return list */ private function variationParentIds(array $ids): array { $parentIds = []; foreach ($ids as $id) { $post = get_post($id); if ($post instanceof \WP_Post && $post->post_type === 'product_variation' && $post->post_parent > 0) { $parentIds[] = (int) $post->post_parent; } } return array_values(array_unique($parentIds)); } /** * Read the term names of one taxonomy attribute, as wc_get_product_terms() * returns them, with one term query for the language group instead of one * for each product. The WooCommerce filter still runs for each product, * because a multilingual plugin translates the names there. * * @param list $groupIds * @return list */ private function attributeTermNames(int $productId, string $taxonomy, array $groupIds): array { $args = ['fields' => 'names', 'suppress_filters' => true]; if (!isset($this->attributeTermNames[$taxonomy])) { $terms = wp_get_object_terms($groupIds, $taxonomy, [ 'fields' => 'all_with_object_id', 'suppress_filters' => true, ]); if (is_wp_error($terms)) { throw new \RuntimeException($terms->get_error_message()); } $names = array_fill_keys($groupIds, []); foreach ($terms as $term) { $names[(int) $term->object_id][] = (string) $term->name; } $this->attributeTermNames[$taxonomy] = $names; } return (array) apply_filters( 'woocommerce_get_product_terms', $this->attributeTermNames[$taxonomy][$productId] ?? [], $productId, $taxonomy, $args ); } public function capabilitiesSchema(): array { return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'crocoai_dbridge_capabilities', 'type' => 'object', 'properties' => [ 'schema_version' => ['type' => 'integer'], 'plugin' => ['type' => 'object'], 'authentication' => ['type' => 'object'], 'discovery' => ['type' => 'object'], 'operations' => ['type' => 'object'], ], ]; } /** * Resolve category relationships into the exported product's language. * * The catalog endpoint deliberately reads every product language in one * request. WPML can therefore leave WooCommerce's category property in the * request's current/default language even when the product post is a * translation. Resolve each term explicitly so every translated product * points at the category IDs from its own language. * * @param array $categoryIds * @return list */ private function categoryIdsInLanguage( array $categoryIds, string $language, MultilingualAdapter $multilingual ): array { $resolved = []; foreach ($categoryIds as $categoryId) { $categoryId = (int) $categoryId; if ($categoryId <= 0) { continue; } $memoKey = $categoryId . ':' . $language; if (!array_key_exists($memoKey, $this->categoryTranslations)) { $this->categoryTranslations[$memoKey] = $language !== '' ? $multilingual->translateTerm($categoryId, 'product_cat', $language) : null; } $resolved[] = $this->categoryTranslations[$memoKey] ?: $categoryId; } return array_values(array_unique($resolved)); } public function provisionCatalog(WP_REST_Request $request): WP_REST_Response|WP_Error { $params = $request->get_json_params() ?: []; return $this->guard(fn (): array => (new WooPublisher())->provision($params)); } public function attributes(): WP_REST_Response|WP_Error { return $this->guard(static fn (): array => ['attributes' => (new AttributeSchema())->inventory()]); } public function planAttributes(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(static function () use ($request): array { $params = $request->get_json_params() ?: []; $operations = AttributeSchema::normalizeOperations((array) ($params['operations'] ?? [])); return (new AttributeSchema())->plan($operations); }); } public function applyAttributes(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(static function () use ($request): array { $params = $request->get_json_params() ?: []; $operations = AttributeSchema::normalizeOperations((array) ($params['operations'] ?? [])); if (($params['confirm'] ?? null) !== true) { throw new \RuntimeException('Send "confirm": true to apply. Use /catalog/attributes/plan for a dry run.'); } return (new AttributeSchema())->apply($operations); }); } public function uploadStagedAssets(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(function () use ($request): array { $params = $request->get_json_params() ?: []; $items = (array) ($params['items'] ?? []); if (!$items || count($items) > 10) { throw new \RuntimeException('An asset batch must contain between 1 and 10 images.'); } $uploaded = []; foreach ($items as $item) { if (!is_array($item)) { throw new \RuntimeException('Invalid staged asset payload.'); } $key = sanitize_key((string) ($item['key'] ?? '')); $contentHash = sanitize_text_field((string) ($item['content_hash'] ?? '')); $binary = base64_decode((string) ($item['content'] ?? ''), true); if ($key === '' || !preg_match('/^[a-f0-9]{64}$/', $contentHash) || $binary === false) { throw new \RuntimeException('Staged asset identity or content is invalid.'); } if (strlen($binary) < 32 || strlen($binary) > 5 * MB_IN_BYTES || hash('sha256', $binary) !== $contentHash) { throw new \RuntimeException('Staged asset failed size or checksum validation.'); } global $wpdb; $assetTable = Database::table('media_assets'); $existing = (int) $wpdb->get_var($wpdb->prepare( 'SELECT attachment_id FROM %i WHERE url_hash=%s', $assetTable, $key )); if ($existing > 0 && get_post_type($existing) === 'attachment') { $uploaded[$key] = $existing; continue; } $filename = 'crocoai-dbridge-' . $contentHash . '.jpg'; $upload = wp_upload_bits($filename, null, $binary); if (!empty($upload['error'])) { throw new \RuntimeException(sanitize_text_field((string) $upload['error'])); } $mime = function_exists('wp_get_image_mime') ? wp_get_image_mime($upload['file']) : false; $size = @getimagesize($upload['file']); if ($mime !== 'image/jpeg' || !$size) { wp_delete_file($upload['file']); throw new \RuntimeException('Staged asset is not a valid JPEG image.'); } $attachmentId = wp_insert_attachment([ 'post_mime_type' => 'image/jpeg', 'post_title' => sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)), 'post_status' => 'inherit', 'guid' => $upload['url'], ], $upload['file']); if (is_wp_error($attachmentId)) { wp_delete_file($upload['file']); throw new \RuntimeException(sanitize_text_field($attachmentId->get_error_message())); } update_attached_file((int) $attachmentId, $upload['file']); wp_update_attachment_metadata((int) $attachmentId, [ 'width' => (int) $size[0], 'height' => (int) $size[1], 'file' => _wp_relative_upload_path($upload['file']), 'sizes' => [], 'image_meta' => [], ]); update_post_meta((int) $attachmentId, '_crocoai_dbridge_asset_hash', $contentHash); $wpdb->replace($assetTable, [ 'url_hash' => $key, 'source_url' => 'staged:' . $contentHash, 'attachment_id' => (int) $attachmentId, 'created_at' => current_time('mysql', true), ]); $uploaded[$key] = (int) $attachmentId; } return ['attachments' => $uploaded]; }); } public function uploadPreparedAssets(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => $this->stagedAssets->upload($request->get_json_params() ?: [])); } /** @return array> */ private function stagedAssetV2Args(): array { return [ 'image_profile_hash' => [ 'description' => 'Exact image profile hash advertised by GET /capabilities.', 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$', 'required' => true, 'validate_callback' => static fn (mixed $value): bool => is_string($value) && preg_match('/^[a-f0-9]{64}$/D', $value) === 1, 'sanitize_callback' => static fn (mixed $value): mixed => $value, ], 'items' => [ 'description' => 'One to ten JPEG originals with their complete prepared rendition sets.', 'type' => 'array', 'minItems' => 1, 'maxItems' => 10, 'items' => ['type' => 'object'], 'required' => true, 'validate_callback' => static fn (mixed $value): bool => is_array($value) && array_is_list($value) && count($value) >= 1 && count($value) <= 10, 'sanitize_callback' => static fn (mixed $value): mixed => $value, ], ]; } /** @return array */ public function stagedAssetV2ResponseSchema(): array { return [ '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'crocoai_dbridge_prepared_asset_response', 'type' => 'object', 'properties' => [ 'attachments' => [ 'type' => 'object', 'additionalProperties' => ['type' => 'integer', 'minimum' => 1], ], 'image_profile_hash' => [ 'type' => 'string', 'pattern' => '^[a-f0-9]{64}$', ], ], ]; } public function createStagedRelease(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => (new StagedReleaseService($this->publication))->create($request->get_json_params() ?: [])); } public function stagedRelease(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => (new StagedReleaseService($this->publication))->get((int) $request['id'])); } public function addStagedItems(WP_REST_Request $request): WP_REST_Response|WP_Error { $params = $request->get_json_params() ?: []; return $this->guard(fn (): array => (new StagedReleaseService($this->publication))->addItems((int) $request['id'], (array) ($params['items'] ?? []))); } public function finalizeStagedRelease(WP_REST_Request $request): WP_REST_Response|WP_Error { $params = $request->get_json_params() ?: []; return $this->guard(fn (): array => (new StagedReleaseService($this->publication))->finalize( (int) $request['id'], !empty($params['publish']), (array) ($params['media_gate'] ?? []) )); } /** @return array> */ private function finalizeStagedReleaseArgs(): array { return [ 'publish' => [ 'type' => 'boolean', 'required' => true, ], 'media_gate' => [ 'description' => 'Hold bridge media processing while Croco prepares and uploads images.', 'type' => 'object', 'required' => false, 'properties' => [ 'hold' => ['type' => 'boolean', 'required' => true], 'timeout_seconds' => [ 'type' => 'integer', 'minimum' => 60, 'maximum' => 3600, 'required' => false, ], ], 'additionalProperties' => false, 'sanitize_callback' => static fn (mixed $value): mixed => $value, ], ]; } public function stagedReleaseResults(WP_REST_Request $request): WP_REST_Response|WP_Error { return $this->guard(fn (): array => (new StagedReleaseService($this->publication))->results((int) $request['id'])); } private function costOfGoodsSoldEnabled(): bool { $controllerClass = \Automattic\WooCommerce\Internal\Features\FeaturesController::class; if (!function_exists('wc_get_container') || !class_exists($controllerClass)) { return false; } $controller = wc_get_container()->get($controllerClass); return is_callable([$controller, 'feature_is_enabled']) && $controller->feature_is_enabled('cost_of_goods_sold'); } private function guard(callable $callback): WP_REST_Response|WP_Error { try { return rest_ensure_response($callback()); } catch (Throwable $error) { $route = isset($GLOBALS['wp']) && is_object($GLOBALS['wp']) ? (string) ($GLOBALS['wp']->query_vars['rest_route'] ?? '') : ''; $code = str_starts_with($route, '/wpi/') ? 'wpi_error' : 'crocoai_dbridge_error'; return new WP_Error($code, $error->getMessage(), ['status' => 400]); } } /** * Legacy `/wpi/v1` clients still expect the previous error codes. */ private function restError(WP_REST_Request $request, string $suffix, string $message, int $status): WP_Error { $code = str_starts_with((string) $request->get_route(), '/wpi/') ? 'wpi_' . $suffix : 'crocoai_dbridge_' . $suffix; return new WP_Error($code, $message, ['status' => $status]); } }