*/ public static function abilities(): array { if (!function_exists('wp_get_abilities')) { return []; } $out = []; foreach ((array) wp_get_abilities() as $wp) { if (!is_object($wp) || !method_exists($wp, 'get_name') || !method_exists($wp, 'execute')) { continue; } $name = (string) $wp->get_name(); [$namespace] = explode('/', $name, 2) + ['']; if (in_array($namespace, self::SKIP_NAMESPACES, true) || in_array($name, self::COVERED, true) || !preg_match('~^[a-z0-9-]+/[a-z0-9-]+$~', $name) || self::runsCode($name)) { continue; } $meta = method_exists($wp, 'get_meta') ? (array) $wp->get_meta() : []; $annotations = is_array($meta['annotations'] ?? null) ? $meta['annotations'] : []; $readonly = !empty($annotations['readonly']); $destructive = !empty($annotations['destructive']); $input = method_exists($wp, 'get_input_schema') ? $wp->get_input_schema() : null; $input = self::withoutForce($name, self::flatten($input)); $gated = self::gated($input); $out[] = new Ability( $name, method_exists($wp, 'get_label') ? (string) $wp->get_label() : $name, $readonly ? Ability::READ : Ability::WRITE, self::describe($wp, $namespace, $readonly, $destructive) . ($gated['dryRun'] ? ' ' . __('Shown as a dry run first; made for real when the person confirms.', 'sarcio') : ''), $input, static fn (array $args): bool => current_user_can('edit_posts') && self::permitted($wp, $args), static fn (array $args): array => self::refuse($name, $args) ?? self::run($wp, $name, $gated['confirm'] && !$readonly ? ['confirm' => true] + array_diff_key($args, ['dry_run' => true]) : $args), $gated['dryRun'] && !$readonly ? static fn (array $args): array => self::refuse($name, $args) ?? self::dryRun($wp, $name, $args) : null, null, $destructive || ($gated['confirm'] && !$readonly), ); } return $out; } /** * A tool's argument schema must be one plain object: a plugin that * describes its input as one of several shapes (WooCommerce's product * abilities) is offered the union of their properties, with nothing * required, and the plugin's own validation still decides each call. */ public static function flatten(mixed $schema): array { if (!is_array($schema)) { return ['type' => 'object']; } $branches = []; foreach (['oneOf', 'anyOf', 'allOf'] as $keyword) { foreach (is_array($schema[$keyword] ?? null) ? $schema[$keyword] : [] as $branch) { if (is_array($branch)) { $branches[] = self::flatten($branch); } } unset($schema[$keyword]); } $properties = is_array($schema['properties'] ?? null) ? $schema['properties'] : []; foreach ($branches as $branch) { foreach ($branch['properties'] ?? [] as $key => $property) { $properties[$key] = $properties[$key] ?? $property; } } if ($branches !== []) { unset($schema['required']); } $schema['properties'] = $properties; $schema['type'] = 'object'; return $schema; } /** The names registered on this site that are never offered, for the settings screen. */ public static function refused(): array { if (!function_exists('wp_get_abilities')) { return []; } $out = []; foreach ((array) wp_get_abilities() as $wp) { if (is_object($wp) && method_exists($wp, 'get_name') && self::runsCode((string) $wp->get_name())) { $out[] = (string) $wp->get_name(); } } return $out; } /** * The convention MainWP and others follow: a write that takes `confirm` * is made only with it set, and one that takes `dry_run` can be shown * first. The ticked box in Sarcio is the confirmation. * * @param array $input * @return array{confirm:bool,dryRun:bool} */ public static function gated(array $input): array { $properties = is_array($input['properties'] ?? null) ? $input['properties'] : []; return ['confirm' => isset($properties['confirm']), 'dryRun' => isset($properties['dry_run'])]; } /** @param array $args */ private static function dryRun(object $wp, string $name, array $args): array { try { $result = $wp->execute(['dry_run' => true] + array_diff_key($args, ['confirm' => true])); } catch (\Throwable $e) { return ['error' => sanitize_text_field($e->getMessage())]; } if (is_wp_error($result)) { return ['error' => sanitize_text_field($result->get_error_message())]; } $presented = SettingsAbilities::present($result); return [ 'after' => mb_substr((string) wp_json_encode($presented), 0, 2000), 'before' => '', 'summary' => sprintf( /* translators: %s: the ability's label. */ __('%s (dry run shown; made for real on Apply)', 'sarcio'), method_exists($wp, 'get_label') ? (string) $wp->get_label() : $name, ), ]; } /** * @param array $args * @return array{error:string}|null */ public static function refuse(string $name, array $args): ?array { if (self::deletes($name) && !empty($args['force']) && $args['force'] !== 'false') { return ['error' => __('Deleting something for good cannot be undone, so the assistant does not do it. It can move it to the trash instead.', 'sarcio')]; } if (str_contains($name, 'order')) { foreach ($args as $key => $value) { if (is_string($key) && str_ends_with(strtolower($key), 'status') && is_string($value) && in_array(strtolower(trim($value)), self::REFUNDED, true)) { return ['error' => __('The assistant never refunds an order or marks one refunded. Do that in WooCommerce yourself.', 'sarcio')]; } } } return null; } /** * @param array $input * @return array */ public static function withoutForce(string $name, array $input): array { if (!self::deletes($name) || !is_array($input['properties'] ?? null)) { return $input; } unset($input['properties']['force']); if (is_array($input['required'] ?? null)) { $input['required'] = array_values(array_diff($input['required'], ['force'])); } return $input; } private static function deletes(string $name): bool { return (bool) preg_match('~(delete|remove|trash|purge|destroy)~i', $name); } public static function runsCode(string $name): bool { return (bool) preg_match(self::CODE_PATTERN, $name); } private static function describe(object $wp, string $namespace, bool $readonly, bool $destructive): string { $text = method_exists($wp, 'get_description') ? trim((string) $wp->get_description()) : ''; $note = $readonly ? sprintf('An ability the %s plugin registered; it only looks.', $namespace) : sprintf('An ability the %s plugin registered. It is made at once when the person confirms, cannot be previewed on the site and cannot be undone from here%s.', $namespace, $destructive ? '; the plugin marks it destructive, so the person must tick a box first' : ''); return trim($text . ' ' . $note); } private static function permitted(object $wp, array $args): bool { foreach (['has_permission', 'check_permission', 'check_permissions'] as $method) { if (method_exists($wp, $method)) { try { $allowed = $wp->{$method}($args); } catch (\Throwable) { return false; } return $allowed === true; } } return true; } /** @param array $args */ private static function run(object $wp, string $name, array $args): array { $result = $wp->execute($args); if (function_exists('is_wp_error') && is_wp_error($result)) { return ['error' => sanitize_text_field((string) $result->get_error_message())]; } $output = ['result' => SettingsAbilities::present($result)]; $meta = method_exists($wp, 'get_meta') ? (array) $wp->get_meta() : []; if (empty($meta['annotations']['readonly'])) { $output['journal'] = [ 'after' => ['ability' => $name, 'args' => $args], 'before' => ['ability' => $name], 'summary' => sprintf( /* translators: %s: what the ability is called. */ __('Ran ā€œ%sā€ (cannot be undone from here)', 'sarcio'), method_exists($wp, 'get_label') ? (string) $wp->get_label() : $name, ), 'target' => 'ability:' . $name, ]; } return $output; } }