Everything so far in this course translates one post at a time. A blog steadily accumulates a few
dozen posts, but a WooCommerce catalog can hold hundreds of products, and a product is a mix of
free-text content, a name and description, worth translating, and structured data, a price, a SKU,
stock levels, taxonomy-based attributes, that should never be handed to a language model at all.
This lesson builds a batch ability around wc_get_products() that translates the free-text
fields and copies the structured ones across untouched.
WooCommerce active, with products assigned to at least one category for batching. Lesson 2’s WPML ability or Lesson 3’s Polylang ability, this lesson calls whichever linking function you already built rather than reintroducing it.
Step 1: Separate what’s free text from what’s structured data
Before writing any code, it’s worth being explicit about which fields this ability actually touches, since sending the wrong field to a translation prompt is the single most common mistake in catalog translation.
| Field | Treatment | Why |
|---|---|---|
| Name, short description, description | Sent to the AI Client SDK for translation | Free text, meaning depends on the reader’s language. |
| Regular price, sale price | Copied unchanged | A number has no language, currency conversion and localized formatting are a separate, non-AI concern for a multicurrency plugin, not this ability. |
| SKU, stock quantity, stock status | Copied unchanged | Identifiers and inventory state, translating either would corrupt them. |
| Attribute terms (color, size, and similar) | Left to your i18n plugin’s own taxonomy translation | Attribute terms are shared taxonomy data tied to variations across the whole catalog, rewriting them per product breaks that shared relationship. WPML and Polylang both have dedicated taxonomy translation features for exactly this, use those, not a per-product AI rewrite. |
Step 2: Register the batch ability
// File: catalog-translation/catalog-translation.php
add_action( 'wp_abilities_api_init', function () {
wp_register_ability(
'catalog-translation/translate-category',
array(
'label' => __( 'Batch-translate a product category', 'catalog-translation' ),
'description' => __( 'Generates translated drafts for every published product in a category, translating only name and description fields. Price, SKU, and stock are copied unchanged. Always creates drafts, never publishes.', 'catalog-translation' ),
'category' => 'store-inventory',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'category_slug' => array( 'type' => 'string' ),
'target_language' => array( 'type' => 'string' ),
),
'required' => array( 'category_slug', 'target_language' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'translated' => array( 'type' => 'integer' ),
'skipped' => array( 'type' => 'integer' ),
),
),
'permission_callback' => function () {
return current_user_can( 'edit_products' );
},
'execute_callback' => 'catalog_translation_translate_category',
'meta' => array(
'annotations' => array( 'destructive' => false, 'idempotent' => false ),
'mcp' => array( 'public' => true ),
),
)
);
} );
Step 3: Fetch the batch, translate only the free-text fields
// File: catalog-translation/catalog-translation.php
function catalog_translation_translate_category( $input ) {
$target_language = sanitize_text_field( $input['target_language'] );
$products = wc_get_products( array(
'category' => array( sanitize_text_field( $input['category_slug'] ) ),
'status' => 'publish',
'limit' => -1,
) );
$translated = 0;
$skipped = 0;
foreach ( $products as $product ) {
$result = catalog_translation_translate_one_product( $product, $target_language );
if ( $result ) {
$translated++;
} else {
$skipped++;
}
}
return array( 'translated' => $translated, 'skipped' => $skipped );
}
function catalog_translation_translate_one_product( $product, $target_language ) {
$prompt = <<<PROMPT
Translate only the product name and description below into the language identified by the code
"{$target_language}". Do not translate or alter any numbers, currency symbols, or SKU-like codes
you see, none appear in this input, but treat any that do as literal text to leave unchanged.
Name: {$product->get_name()}
Short description: {$product->get_short_description()}
Description: {$product->get_description()}
Return three lines prefixed exactly: "Name: ", "Short: ", "Description: ".
PROMPT;
$response = wp_ai_client_prompt( $prompt )->generate_text();
if ( empty( $response ) ) {
return false;
}
$fields = catalog_translation_parse_fields( $response );
if ( ! $fields ) {
return false;
}
$new_product = new WC_Product_Simple();
$new_product->set_name( $fields['name'] );
$new_product->set_short_description( $fields['short'] );
$new_product->set_description( $fields['description'] );
// Structured fields copied unchanged, never sent to the model.
$new_product->set_regular_price( $product->get_regular_price() );
$new_product->set_sale_price( $product->get_sale_price() );
$new_product->set_sku( $product->get_sku() . '-' . $target_language );
$new_product->set_manage_stock( $product->get_manage_stock() );
$new_product->set_stock_quantity( $product->get_stock_quantity() );
$new_product->set_status( 'draft' ); // Never auto-publish a translated product.
$new_product_id = $new_product->save();
if ( ! $new_product_id ) {
return false;
}
// Link into WPML or Polylang using whichever ability's linking function you built in
// Lesson 2 or Lesson 3, for example:
// wpml_translation_pipeline_link( $product->get_id(), $new_product_id, $target_language );
return true;
}
function catalog_translation_parse_fields( $response ) {
if ( preg_match( '/^Name:\s*(.+)$/mi', $response, $name )
&& preg_match( '/^Short:\s*(.+)$/mi', $response, $short )
&& preg_match( '/^Description:\s*(.+)$/mi', $response, $desc ) ) {
return array(
'name' => trim( $name[1] ),
'short' => trim( $short[1] ),
'description' => trim( $desc[1] ),
);
}
return null;
}
Test it
wp-env run cli wp eval '
$result = wp_get_ability( "catalog-translation/translate-category" )->execute( array(
"category_slug" => "outdoor-gear",
"target_language" => "es",
) );
print_r( $result );
'
Spot-check a handful of the resulting draft products in wp-admin. Confirm the name and description read naturally in the target language, and separately confirm the price, SKU suffix, and stock quantity match the source product exactly, those fields should never look “translated” in any way.
wc_get_products() with limit => -1 on a large catalog can mean hundreds of sequential AI
calls in one request, the same execution-time risk Course 6’s Bulk-Editing Assistant project
warned about for regular posts. For a real store, batch by category and run smaller batches, or
move this into a scheduled background job, rather than one enormous synchronous call across an
entire catalog.
Recap
wc_get_products() fetches a category’s worth of products in one call, and each product is split
into two clearly separate treatments: free-text fields (name, short description, description) go
through wp_ai_client_prompt() for translation, while price, SKU, and stock are copied directly
into the new product object without ever reaching the model. Attribute terms are deliberately left
out entirely, since they’re shared taxonomy data your i18n plugin’s own translation feature already
handles correctly. Every translated product is created as a draft, same rule as every other
ability in this course.
Resources & further reading
- wc_get_products() function reference, woocommerce.github.io
- WC_Product class reference, woocommerce.github.io
- Project: Product Description Generator, this track, Course 7