attributeEdits = $attributeEdits ?? new ProductQuickEditAttributes(); $this->multilingual = $multilingual ?? MultilingualAdapterFactory::make(); } /** @return array */ public function edit(int $productId, array $patch): array { if ($productId <= 0) { throw new RuntimeException('woocommerce_id must be a positive integer.'); } if ($patch === []) { throw new RuntimeException('At least one quick-edit field is required.'); } $unknown = array_values(array_diff(array_keys($patch), self::FIELDS)); if ($unknown !== []) { throw new RuntimeException('Unknown quick-edit fields: ' . implode(', ', $unknown) . '.'); } if (!in_array(get_post_type($productId), ['product', 'product_variation'], true)) { throw new RuntimeException('WooCommerce product ' . $productId . ' does not exist.'); } $language = sanitize_key($this->multilingual->productLanguage($productId)); if ($this->multilingual->name() !== 'none' && $language === '') { throw new RuntimeException( 'Quick Edit could not determine the product language in ' . $this->multilingual->name() . '; no changes were saved.' ); } if ($language === '') { return $this->editInCurrentLanguage($productId, $patch); } $previousLanguage = $this->multilingual->switchLanguage($language); try { return $this->editInCurrentLanguage($productId, $patch); } finally { $this->multilingual->restoreLanguage($previousLanguage); } } /** @return array */ private function editInCurrentLanguage(int $productId, array $patch): array { // Load, validate, save, and reload inside the product's own language. // WCML observes WooCommerce CRUD saves and must never see a translated // product saved while WPML is still in another language. $product = wc_get_product($productId); if (!$product instanceof \WC_Product || (int) $product->get_id() !== $productId) { throw new RuntimeException('WooCommerce product ' . $productId . ' does not exist.'); } $normalized = $this->normalize($product, $patch); $before = $this->editableState($product); foreach ($normalized as $field => $value) { if ($field === 'attributes') { $product->set_attributes($value); continue; } if ($field === 'images') { $product->set_image_id((int) ($value[0] ?? 0)); if (!$product instanceof \WC_Product_Variation) { $product->set_gallery_image_ids(array_slice($value, 1)); } continue; } if ($field === 'category_ids') { $product->set_category_ids($value); continue; } $setter = 'set_' . $field; $product->{$setter}($value); } $product->save(); clean_post_cache($productId); wc_delete_product_transients($productId); $saved = wc_get_product($productId); if (!$saved instanceof \WC_Product) { throw new RuntimeException('WooCommerce could not reload product ' . $productId . ' after saving it.'); } $after = $this->editableState($saved); $changed = []; foreach (self::FIELDS as $field) { if ($before[$field] !== $after[$field]) { $changed[$field] = [ 'before' => $before[$field], 'after' => $after[$field], ]; } } return [ 'product' => $this->present($saved, $after), 'changed_fields' => $changed === [] ? (object) [] : $changed, ]; } /** @return array */ private function normalize(\WC_Product $product, array $patch): array { $normalized = []; if (array_key_exists('name', $patch)) { if (!is_string($patch['name']) || trim($patch['name']) === '') { throw new RuntimeException('name must be a non-empty string.'); } $normalized['name'] = sanitize_text_field($patch['name']); if ($normalized['name'] === '') { throw new RuntimeException('name must be a non-empty string.'); } } foreach (['short_description', 'description'] as $field) { if (array_key_exists($field, $patch)) { if (!is_string($patch[$field])) { throw new RuntimeException($field . ' must be a string.'); } $normalized[$field] = wp_kses_post($patch[$field]); } } if (array_key_exists('images', $patch)) { if (!is_array($patch['images']) || !array_is_list($patch['images'])) { throw new RuntimeException('images must be an ordered list of existing image attachment IDs.'); } if ($product instanceof \WC_Product_Variation && count($patch['images']) > 1) { throw new RuntimeException('A variation supports only one image.'); } $imageIds = []; foreach ($patch['images'] as $imageId) { if (!is_int($imageId) || $imageId <= 0 || !wp_attachment_is_image($imageId)) { throw new RuntimeException('Each images value must be an existing image attachment ID.'); } if (isset($imageIds[$imageId])) { throw new RuntimeException('images must not contain duplicate attachment IDs.'); } $imageIds[$imageId] = $imageId; } $normalized['images'] = array_values($imageIds); } if (array_key_exists('attributes', $patch)) { $normalized['attributes'] = $this->attributeEdits->prepare($product, $patch['attributes']); } if (array_key_exists('status', $patch)) { if (!is_string($patch['status']) || !in_array($patch['status'], self::STATUSES, true)) { throw new RuntimeException('status must be draft, pending, private, or publish.'); } $normalized['status'] = $patch['status']; } if (array_key_exists('category_ids', $patch)) { if ($product instanceof \WC_Product_Variation) { throw new RuntimeException('Categories cannot be assigned directly to a variation.'); } if (!is_array($patch['category_ids']) || !array_is_list($patch['category_ids'])) { throw new RuntimeException('category_ids must be a list of positive WooCommerce term IDs.'); } $categoryIds = []; foreach ($patch['category_ids'] as $categoryId) { if (!is_int($categoryId) || $categoryId <= 0) { throw new RuntimeException('Each category_ids value must be a positive WooCommerce term ID.'); } $term = get_term($categoryId, 'product_cat'); if (!$term instanceof \WP_Term) { throw new RuntimeException('Product category ' . $categoryId . ' does not exist.'); } $categoryIds[$categoryId] = $categoryId; } $normalized['category_ids'] = array_values($categoryIds); sort($normalized['category_ids']); } $priceRequested = array_key_exists('regular_price', $patch) || array_key_exists('sale_price', $patch); if ($priceRequested && $product instanceof \WC_Product_Variable) { throw new RuntimeException('Prices must be edited on the variable product variations.'); } foreach (['regular_price', 'sale_price'] as $field) { if (!array_key_exists($field, $patch)) { continue; } $normalized[$field] = $this->price($patch[$field], $field); } $regularPrice = $normalized['regular_price'] ?? $this->storedPrice($product->get_regular_price('edit')); $salePrice = $normalized['sale_price'] ?? $this->storedPrice($product->get_sale_price('edit')); if ($salePrice !== '') { if ($regularPrice === '') { throw new RuntimeException('sale_price requires a non-empty regular_price.'); } if ((float) $salePrice > (float) $regularPrice) { throw new RuntimeException('sale_price cannot be greater than regular_price.'); } } if (array_key_exists('manage_stock', $patch)) { if (!is_bool($patch['manage_stock'])) { throw new RuntimeException('manage_stock must be a Boolean.'); } $normalized['manage_stock'] = $patch['manage_stock']; } if (array_key_exists('stock_quantity', $patch)) { $quantity = $patch['stock_quantity']; if ($quantity !== null && (!is_int($quantity) && !is_float($quantity))) { throw new RuntimeException('stock_quantity must be a non-negative number or null.'); } if ($quantity !== null && $quantity < 0) { throw new RuntimeException('stock_quantity must be a non-negative number or null.'); } $normalized['stock_quantity'] = $quantity === null ? null : wc_stock_amount($quantity); } if (array_key_exists('stock_status', $patch)) { if (!is_string($patch['stock_status']) || !in_array($patch['stock_status'], self::STOCK_STATUSES, true)) { throw new RuntimeException('stock_status must be instock, outofstock, or onbackorder.'); } $normalized['stock_status'] = $patch['stock_status']; } if (array_key_exists('backorders', $patch)) { if (!is_string($patch['backorders']) || !in_array($patch['backorders'], self::BACKORDERS, true)) { throw new RuntimeException('backorders must be no, notify, or yes.'); } $normalized['backorders'] = $patch['backorders']; } $manageStock = $normalized['manage_stock'] ?? (bool) $product->get_manage_stock('edit'); $stockQuantity = array_key_exists('stock_quantity', $normalized) ? $normalized['stock_quantity'] : $product->get_stock_quantity('edit'); $stockStatus = $normalized['stock_status'] ?? (string) $product->get_stock_status('edit'); $backorders = $normalized['backorders'] ?? (string) $product->get_backorders('edit'); if (!$manageStock && $stockQuantity !== null && array_key_exists('stock_quantity', $normalized)) { throw new RuntimeException('A non-null stock_quantity requires manage_stock to be true.'); } if (!$manageStock && $backorders !== 'no') { throw new RuntimeException('Backorders require manage_stock to be true.'); } if ($stockStatus === 'onbackorder' && $backorders === 'no') { throw new RuntimeException('stock_status onbackorder requires backorders to be notify or yes.'); } return $normalized; } private function price(mixed $value, string $field): string { if (!is_string($value) || ($value !== '' && preg_match('/^[0-9]+(?:\.[0-9]+)?$/D', $value) !== 1)) { throw new RuntimeException($field . ' must be a non-negative decimal string or an empty string.'); } return $value === '' ? '' : wc_format_decimal($value); } private function storedPrice(mixed $value): string { $value = (string) $value; return $value === '' ? '' : wc_format_decimal($value); } /** @return array */ private function editableState(\WC_Product $product): array { $categoryIds = array_values(array_unique(array_map('intval', $product->get_category_ids('edit')))); sort($categoryIds); return [ 'name' => (string) $product->get_name('edit'), 'short_description' => (string) $product->get_short_description('edit'), 'description' => (string) $product->get_description('edit'), 'images' => $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')], array_map('intval', $product->get_gallery_image_ids('edit')) ))), 'attributes' => $this->attributeEdits->read($product), 'status' => (string) $product->get_status('edit'), 'category_ids' => $categoryIds, 'regular_price' => $this->storedPrice($product->get_regular_price('edit')), 'sale_price' => $this->storedPrice($product->get_sale_price('edit')), 'manage_stock' => (bool) $product->get_manage_stock('edit'), 'stock_quantity' => $product->get_stock_quantity('edit'), 'stock_status' => (string) $product->get_stock_status('edit'), 'backorders' => (string) $product->get_backorders('edit'), ]; } /** @param array $state * @return array */ private function present(\WC_Product $product, array $state): array { $modified = $product->get_date_modified('edit'); return [ 'id' => (int) $product->get_id(), 'type' => (string) $product->get_type(), ] + $state + [ 'date_modified_gmt' => $modified ? $modified->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d\TH:i:s') : null, ]; } }