*/ public static function abilities(): array { if (!SiteAbilities::woocommerceActive()) { return []; } $allowed = static fn (array $args): bool => current_user_can('manage_woocommerce'); $idSchema = ['minimum' => 1, 'type' => 'integer']; $settingsProperties = []; foreach (self::SETTINGS as $key) { $settingsProperties[$key] = ['maxLength' => 500, 'type' => 'string']; } return [ new Ability( 'sarcio/woo-settings-get', __('Look at the shop settings', 'sarcio'), Ability::READ, 'The WooCommerce store settings: address, currency and its format, whether prices include tax and taxes are calculated, whether coupons are enabled, and the shipping zones with their methods.', ['properties' => [], 'type' => 'object'], $allowed, static fn (array $args): array => self::settings(), ), new Ability( 'sarcio/woo-settings-update', __('Change the shop settings', 'sarcio'), Ability::WRITE, 'Change WooCommerce store settings. Keys are the woocommerce_* option names from woo-settings-get; yes/no settings take "yes" or "no". Only the keys given change.', ['properties' => $settingsProperties, 'required' => [], 'type' => 'object'], $allowed, static fn (array $args): array => self::updateSettings($args), null, static fn (array $before, array $after): array => self::writeSettings($before) + ['restored' => true], false, static fn (array $args, Overlay $overlay): array => self::stageSettings($args, $overlay), ), new Ability( 'sarcio/woo-products-search', __('Look for products', 'sarcio'), Ability::READ, 'Search products by name or SKU. Returns id, name, sku, price, sale price, stock status and url.', [ 'properties' => [ 'limit' => ['maximum' => 20, 'minimum' => 1, 'type' => 'integer'], 'query' => ['maxLength' => 200, 'type' => 'string'], 'status' => ['enum' => ['any', 'publish', 'draft', 'pending', 'private'], 'type' => 'string'], ], 'required' => [], 'type' => 'object', ], $allowed, static fn (array $args): array => self::searchProducts($args), ), new Ability( 'sarcio/woo-product-get', __('Read a product', 'sarcio'), Ability::READ, 'Read one product by id: name, sku, prices, sale dates, stock, status, descriptions, categories and url.', ['properties' => ['id' => $idSchema], 'required' => ['id'], 'type' => 'object'], $allowed, static fn (array $args): array => self::product((int) $args['id']), ), new Ability( 'sarcio/woo-product-update', __('Change a product', 'sarcio'), Ability::WRITE, 'Change a product’s regular price, sale price and sale dates (YYYY-MM-DD), stock status or quantity, descriptions or status. Only the fields given change. Prices are plain numbers as text, e.g. "24.50".', [ 'properties' => [ 'description' => ['type' => 'string'], 'id' => $idSchema, 'regularPrice' => ['maxLength' => 20, 'type' => 'string'], 'saleFrom' => ['maxLength' => 10, 'type' => 'string'], 'salePrice' => ['maxLength' => 20, 'type' => 'string'], 'saleTo' => ['maxLength' => 10, 'type' => 'string'], 'shortDescription' => ['type' => 'string'], 'status' => ['enum' => ['publish', 'draft', 'pending', 'private'], 'type' => 'string'], 'stockQuantity' => ['minimum' => 0, 'type' => 'integer'], 'stockStatus' => ['enum' => self::STOCK_STATUSES, 'type' => 'string'], ], 'required' => ['id'], 'type' => 'object', ], $allowed, static fn (array $args): array => self::updateProduct($args), null, static fn (array $before, array $after): array => self::writeProduct($before) + ['restored' => true], false, static fn (array $args, Overlay $overlay): array => self::stageProduct($args, $overlay), ), new Ability( 'sarcio/woo-coupons-list', __('Look at the coupons', 'sarcio'), Ability::READ, 'Every coupon: id, code, type, amount, expiry, usage limit and count, free shipping, minimum spend.', ['properties' => [], 'type' => 'object'], $allowed, static fn (array $args): array => self::coupons(), ), new Ability( 'sarcio/woo-coupon-save', __('Create or change a coupon', 'sarcio'), Ability::WRITE, 'Create a coupon, or change the one with the given code. type is percent, fixed_cart or fixed_product; amount is a number as text; expiry is YYYY-MM-DD or empty for none; usageLimit caps uses in total, usageLimitPerUser caps uses per customer (0 = no cap).', [ 'properties' => [ 'amount' => ['maxLength' => 20, 'type' => 'string'], 'code' => ['maxLength' => 100, 'minLength' => 1, 'type' => 'string'], 'description' => ['maxLength' => 1000, 'type' => 'string'], 'expiry' => ['maxLength' => 10, 'type' => 'string'], 'freeShipping' => ['type' => 'boolean'], 'minimumSpend' => ['maxLength' => 20, 'type' => 'string'], 'type' => ['enum' => self::COUPON_TYPES, 'type' => 'string'], 'usageLimit' => ['minimum' => 0, 'type' => 'integer'], 'usageLimitPerUser' => ['minimum' => 0, 'type' => 'integer'], ], 'required' => ['code'], 'type' => 'object', ], $allowed, static fn (array $args): array => self::saveCoupon($args), static fn (array $args): array => self::saveCouponPreview($args), static fn (array $before, array $after): array => self::undoCoupon($before, $after), ), ]; } private static function settings(): array { $out = []; foreach (self::SETTINGS as $key) { $out[$key] = (string) Stage::option($key, ''); } $zones = []; if (class_exists('WC_Shipping_Zones')) { foreach ((array) \WC_Shipping_Zones::get_zones() as $zone) { $methods = []; foreach ((array) ($zone['shipping_methods'] ?? []) as $method) { $methods[] = [ 'enabled' => (string) ($method->enabled ?? 'yes') === 'yes', 'title' => (string) $method->get_title(), 'type' => (string) $method->id, ]; } $zones[] = [ 'id' => (int) ($zone['id'] ?? $zone['zone_id'] ?? 0), 'methods' => $methods, 'name' => (string) ($zone['zone_name'] ?? ''), 'regions' => (string) ($zone['formatted_zone_location'] ?? ''), ]; } } return ['settings' => $out, 'shippingZones' => $zones]; } /** @param array $args */ private static function updateSettings(array $args): array { $before = []; $after = []; foreach (self::SETTINGS as $key) { if (array_key_exists($key, $args)) { $current = (string) get_option($key, ''); if ($current !== (string) $args[$key]) { $before[$key] = $current; $after[$key] = (string) $args[$key]; } } } if ($after === []) { return ['error' => __('Nothing would change.', 'sarcio')]; } self::writeSettings($after); return [ 'changed' => array_keys($after), 'journal' => [ 'after' => $after, 'before' => $before, 'summary' => sprintf( /* translators: %s: the settings changed. */ __('Changed the shop settings: %s', 'sarcio'), implode(', ', array_map(static fn (string $key): string => str_replace(['woocommerce_', '_'], ['', ' '], $key), array_keys($after))), ), 'target' => 'woocommerce_settings', ], ]; } /** @param array $args */ private static function stageSettings(array $args, Overlay $overlay): array { $before = []; $after = []; foreach (self::SETTINGS as $key) { if (array_key_exists($key, $args) && (string) $overlay->option($key, '') !== (string) $args[$key]) { $label = str_replace(['woocommerce_', '_'], ['', ' '], $key); $before[] = $label . ': ' . (string) $overlay->option($key, ''); $after[] = $label . ': ' . (string) $args[$key]; $overlay->options[$key] = (string) $args[$key]; } } if ($after === []) { return ['error' => __('Nothing would change.', 'sarcio')]; } return ['after' => implode("\n", $after), 'before' => implode("\n", $before), 'summary' => __('Change the shop settings', 'sarcio')]; } /** @param array $values */ private static function writeSettings(array $values): array { foreach ($values as $key => $value) { if (in_array($key, self::SETTINGS, true)) { update_option($key, (string) $value); } } return ['changed' => array_keys($values)]; } private static function summarizeProduct(object $product): array { return [ 'id' => (int) $product->get_id(), 'name' => (string) $product->get_name(), 'price' => (string) $product->get_price(), 'regularPrice' => (string) $product->get_regular_price(), 'salePrice' => (string) $product->get_sale_price(), 'sku' => (string) $product->get_sku(), 'status' => (string) $product->get_status(), 'stockStatus' => (string) $product->get_stock_status(), 'url' => (string) $product->get_permalink(), ]; } /** @param array $args */ private static function searchProducts(array $args): array { $query = (string) ($args['query'] ?? ''); $products = wc_get_products([ 'limit' => (int) ($args['limit'] ?? 10), 'orderby' => 'date', 's' => $query, 'status' => (string) ($args['status'] ?? 'any'), ]); $out = array_map([self::class, 'summarizeProduct'], is_array($products) ? $products : []); if ($query !== '' && function_exists('wc_get_product_id_by_sku')) { $bySku = (int) wc_get_product_id_by_sku($query); if ($bySku > 0 && !in_array($bySku, array_column($out, 'id'), true)) { $product = wc_get_product($bySku); if ($product) { array_unshift($out, self::summarizeProduct($product)); } } } return ['results' => $out]; } /** @return array */ private static function snapshotProduct(object $product): array { $from = $product->get_date_on_sale_from(); $to = $product->get_date_on_sale_to(); return [ 'description' => (string) $product->get_description(), 'id' => (int) $product->get_id(), 'regularPrice' => (string) $product->get_regular_price(), 'saleFrom' => $from ? $from->date('Y-m-d') : '', 'salePrice' => (string) $product->get_sale_price(), 'saleTo' => $to ? $to->date('Y-m-d') : '', 'shortDescription' => (string) $product->get_short_description(), 'status' => (string) $product->get_status(), 'stockQuantity' => $product->get_stock_quantity() === null ? null : (int) $product->get_stock_quantity(), 'stockStatus' => (string) $product->get_stock_status(), ]; } private static function product(int $id): array { $product = wc_get_product($id); if (!$product) { return ['error' => __('There is no product with that id.', 'sarcio')]; } $categories = []; foreach ((array) get_the_terms($id, 'product_cat') as $term) { if (is_object($term)) { $categories[] = (string) $term->name; } } $snapshot = self::snapshotProduct($product); if (Stage::active()) { $snapshot = Stage::overlay()->applyToProduct($id, $snapshot); } return $snapshot + [ 'categories' => $categories, 'name' => (string) $product->get_name(), 'sku' => (string) $product->get_sku(), 'url' => (string) $product->get_permalink(), ]; } /** * @param array $args * @return array{before:array,after:array,name:string}|array{error:string} */ private static function productChanges(array $args, ?Overlay $overlay = null): array { $product = wc_get_product((int) $args['id']); if (!$product) { return ['error' => __('There is no product with that id.', 'sarcio')]; } $before = self::snapshotProduct($product); if ($overlay !== null) { $before = $overlay->applyToProduct((int) $args['id'], $before); } $after = $before; foreach (['description', 'regularPrice', 'saleFrom', 'salePrice', 'saleTo', 'shortDescription', 'status', 'stockQuantity', 'stockStatus'] as $field) { if (array_key_exists($field, $args)) { $after[$field] = in_array($field, ['description', 'shortDescription'], true) ? wp_kses_post((string) $args[$field]) : $args[$field]; } } if ($after === $before) { return ['error' => __('Nothing would change.', 'sarcio')]; } return ['after' => $after, 'before' => $before, 'name' => (string) $product->get_name()]; } /** @param array $args */ private static function updateProduct(array $args): array { $changes = self::productChanges($args); if (isset($changes['error'])) { return $changes; } $written = self::writeProduct($changes['after']); if (isset($written['error'])) { return $written; } $changed = array_keys(array_filter($changes['after'], static fn ($value, $key) => $changes['before'][$key] !== $value, ARRAY_FILTER_USE_BOTH)); return [ 'changed' => $changed, 'id' => (int) $args['id'], 'journal' => [ 'after' => $changes['after'], 'before' => $changes['before'], 'summary' => sprintf( /* translators: 1: the fields changed, 2: the product name. */ __('Changed %1$s of “%2$s”', 'sarcio'), implode(', ', $changed), $changes['name'], ), 'target' => 'product:' . $args['id'], ], ]; } /** @param array $args */ private static function stageProduct(array $args, Overlay $overlay): array { $changes = self::productChanges($args, $overlay); if (isset($changes['error'])) { return $changes; } $before = []; $after = []; $staged = []; foreach ($changes['after'] as $field => $value) { if ($changes['before'][$field] !== $value) { $before[] = $field . ': ' . (string) $changes['before'][$field]; $after[] = $field . ': ' . (string) $value; $staged[$field] = $value; } } $overlay->setProduct((int) $args['id'], $staged); $postFields = array_filter([ 'post_content' => $staged['description'] ?? null, 'post_excerpt' => $staged['shortDescription'] ?? null, 'post_status' => $staged['status'] ?? null, ], static fn ($value): bool => $value !== null); if ($postFields !== []) { $overlay->setPost((int) $args['id'], $postFields); } return [ 'after' => implode("\n", $after), 'before' => implode("\n", $before), 'summary' => sprintf( /* translators: %s: the product name. */ __('Change “%s”', 'sarcio'), $changes['name'], ), ]; } /** @param array $fields */ private static function writeProduct(array $fields): array { $product = wc_get_product((int) $fields['id']); if (!$product) { return ['error' => __('There is no product with that id.', 'sarcio')]; } $product->set_regular_price((string) $fields['regularPrice']); $product->set_sale_price((string) $fields['salePrice']); $product->set_date_on_sale_from($fields['saleFrom'] !== '' ? (string) $fields['saleFrom'] : null); $product->set_date_on_sale_to($fields['saleTo'] !== '' ? (string) $fields['saleTo'] : null); $product->set_description((string) $fields['description']); $product->set_short_description((string) $fields['shortDescription']); $product->set_status((string) $fields['status']); if ($fields['stockQuantity'] !== null) { $product->set_manage_stock(true); $product->set_stock_quantity((int) $fields['stockQuantity']); } else { $product->set_stock_status((string) $fields['stockStatus']); } $product->save(); return ['id' => (int) $fields['id']]; } /** @return array */ private static function snapshotCoupon(object $coupon): array { $expiry = $coupon->get_date_expires(); return [ 'amount' => (string) $coupon->get_amount(), 'code' => (string) $coupon->get_code(), 'description' => (string) $coupon->get_description(), 'expiry' => $expiry ? $expiry->date('Y-m-d') : '', 'freeShipping' => (bool) $coupon->get_free_shipping(), 'id' => (int) $coupon->get_id(), 'minimumSpend' => (string) $coupon->get_minimum_amount(), 'type' => (string) $coupon->get_discount_type(), 'usageCount' => (int) $coupon->get_usage_count(), 'usageLimit' => (int) $coupon->get_usage_limit(), 'usageLimitPerUser' => (int) $coupon->get_usage_limit_per_user(), ]; } private static function coupons(): array { $posts = get_posts(['numberposts' => 100, 'post_status' => 'any', 'post_type' => 'shop_coupon']); $out = []; foreach (is_array($posts) ? $posts : [] as $post) { $out[] = self::snapshotCoupon(new \WC_Coupon((int) $post->ID)); } return ['coupons' => $out]; } private static function findCoupon(string $code): ?object { $id = function_exists('wc_get_coupon_id_by_code') ? (int) wc_get_coupon_id_by_code($code) : 0; return $id > 0 ? new \WC_Coupon($id) : null; } /** * @param array $args * @return array{before:array,after:array} */ private static function couponChanges(array $args, ?object $existing): array { $before = $existing !== null ? self::snapshotCoupon($existing) : []; $after = $existing !== null ? $before : [ 'amount' => '0', 'code' => (string) $args['code'], 'description' => '', 'expiry' => '', 'freeShipping' => false, 'id' => 0, 'minimumSpend' => '', 'type' => 'percent', 'usageCount' => 0, 'usageLimit' => 0, 'usageLimitPerUser' => 0, ]; foreach (['amount', 'description', 'expiry', 'freeShipping', 'minimumSpend', 'type', 'usageLimit', 'usageLimitPerUser'] as $field) { if (array_key_exists($field, $args)) { $after[$field] = $args[$field]; } } return ['after' => $after, 'before' => $before]; } /** @param array $args */ private static function saveCoupon(array $args): array { $existing = self::findCoupon((string) $args['code']); $changes = self::couponChanges($args, $existing); if ($existing !== null && $changes['after'] === $changes['before']) { return ['error' => __('Nothing would change.', 'sarcio')]; } $written = self::writeCoupon($changes['after']); if (isset($written['error'])) { return $written; } $changes['after']['id'] = $written['id']; return [ 'id' => $written['id'], 'journal' => [ 'after' => $changes['after'], 'before' => $changes['before'], 'summary' => sprintf( $existing !== null /* translators: %s: the coupon code. */ ? __('Changed the coupon %s', 'sarcio') /* translators: %s: the coupon code. */ : __('Created the coupon %s', 'sarcio'), (string) $args['code'], ), 'target' => 'coupon:' . $args['code'], ], ]; } /** @param array $args */ private static function saveCouponPreview(array $args): array { $existing = self::findCoupon((string) $args['code']); $changes = self::couponChanges($args, $existing); if ($existing !== null && $changes['after'] === $changes['before']) { return ['error' => __('Nothing would change.', 'sarcio')]; } return [ 'after' => self::describeCoupon($changes['after']), 'before' => $existing !== null ? self::describeCoupon($changes['before']) : '', 'summary' => sprintf( $existing !== null /* translators: %s: the coupon code. */ ? __('Change the coupon %s', 'sarcio') /* translators: %s: the coupon code. */ : __('Create the coupon %s', 'sarcio'), (string) $args['code'], ), ]; } /** @param array $fields */ private static function writeCoupon(array $fields): array { $coupon = new \WC_Coupon((int) ($fields['id'] ?? 0)); $coupon->set_code((string) $fields['code']); $coupon->set_discount_type((string) $fields['type']); $coupon->set_amount((string) $fields['amount']); $coupon->set_description(sanitize_textarea_field((string) $fields['description'])); $coupon->set_date_expires($fields['expiry'] !== '' ? (string) $fields['expiry'] : null); $coupon->set_free_shipping((bool) $fields['freeShipping']); $coupon->set_minimum_amount((string) $fields['minimumSpend']); $coupon->set_usage_limit((int) $fields['usageLimit']); $coupon->set_usage_limit_per_user((int) ($fields['usageLimitPerUser'] ?? 0)); $id = (int) $coupon->save(); if ($id <= 0) { return ['error' => __('WooCommerce could not save the coupon.', 'sarcio')]; } return ['id' => $id]; } /** * @param array $before * @param array $after */ private static function undoCoupon(array $before, array $after): array { if ($before === []) { $id = (int) ($after['id'] ?? 0); if ($id > 0 && get_post($id) !== null) { wp_trash_post($id); } return ['code' => (string) ($after['code'] ?? ''), 'trashed' => true]; } return self::writeCoupon($before) + ['restored' => true]; } /** @param array $coupon */ private static function describeCoupon(array $coupon): string { $amount = (string) $coupon['amount']; $lines = [ $coupon['type'] === 'percent' ? sprintf( /* translators: %s: the percentage. */ __('%s%% off', 'sarcio'), $amount, ) : sprintf( /* translators: %s: the amount. */ __('%s off', 'sarcio'), $amount, ), ]; if ((string) $coupon['expiry'] !== '') { $lines[] = sprintf( /* translators: %s: the expiry date. */ __('expires %s', 'sarcio'), (string) $coupon['expiry'], ); } if ((int) $coupon['usageLimit'] > 0) { $lines[] = sprintf( /* translators: %d: how many uses in total. */ _n('%d use in total', 'up to %d uses in total', (int) $coupon['usageLimit'], 'sarcio'), (int) $coupon['usageLimit'], ); } if ((int) ($coupon['usageLimitPerUser'] ?? 0) > 0) { $lines[] = sprintf( /* translators: %d: how many uses per customer. */ _n('%d use per customer', 'up to %d uses per customer', (int) $coupon['usageLimitPerUser'], 'sarcio'), (int) $coupon['usageLimitPerUser'], ); } if (!empty($coupon['freeShipping'])) { $lines[] = __('free shipping', 'sarcio'); } if ((string) $coupon['minimumSpend'] !== '') { $lines[] = sprintf( /* translators: %s: the minimum spend. */ __('minimum spend %s', 'sarcio'), (string) $coupon['minimumSpend'], ); } return implode(', ', $lines); } }