|null */ private static ?array $abilities = null; /** @return array */ public static function all(): array { if (self::$abilities === null) { $list = self::own(); // Other plugins' abilities exist only once WordPress has collected // them, which happens on the first wp_get_abilities() call of the // request; ours are registered during that collection, when the // catalog is Sarcio's own and is not kept. $collecting = function_exists('doing_action') && doing_action('wp_abilities_api_init'); if (function_exists('wp_get_abilities') && function_exists('did_action') && did_action('wp_abilities_api_init') === 0 && !$collecting) { wp_get_abilities(); } $bridged = function_exists('wp_get_abilities') && function_exists('did_action') && did_action('wp_abilities_api_init') > 0 && !$collecting; if ($bridged) { $list = array_merge($list, AbilitiesBridge::abilities()); } $built = []; foreach ($list as $ability) { $built[$ability->name] ??= $ability; } if (!$bridged && function_exists('wp_get_abilities')) { return $built; } self::$abilities = $built; } return self::$abilities; } /** Sarcio's own abilities, the ones registered with WordPress. @return array */ private static function own(): array { $own = array_merge( SiteAbilities::abilities(), ContentAbilities::abilities(), MenuAbilities::abilities(), StyleAbilities::abilities(), StyleRuleAbilities::abilities(), ThemeSettingAbilities::abilities(), ThemeAbilities::abilities(), SeoAbilities::abilities(), MediaAbilities::abilities(), PluginAbilities::abilities(), WooAbilities::abilities(), OrderAbilities::abilities(), CommentAbilities::abilities(), TermAbilities::abilities(), RevisionAbilities::abilities(), RedirectAbilities::abilities(), FormAbilities::abilities(), UpdateAbilities::abilities(), CacheAbilities::abilities(), UserAbilities::abilities(), HealthAbilities::abilities(), WidgetAbilities::abilities(), ElementorAbilities::abilities(), ActivityAbilities::abilities(), Review::abilities(), SettingsAbilities::abilities(), JournalAbilities::abilities(), ); $own = array_merge($own, Registry::abilities()); $extra = self::deprecatedExtras(); foreach ($extra as $ability) { if ($ability instanceof Ability && !str_starts_with($ability->name, self::NAMESPACE . '/')) { $own[] = $ability; } } return $own; } /** * Abilities handed over by the filter that came before `sarcio_loaded`. * Still honoured; WordPress flags it as deprecated to whoever uses it. * * @return array */ private static function deprecatedExtras(): array { if (function_exists('apply_filters_deprecated')) { $extra = apply_filters_deprecated('sarcio_assistant_abilities', [[]], '0.17.0', 'sarcio_loaded'); } elseif (function_exists('apply_filters')) { $extra = apply_filters('sarcio_assistant_abilities', []); } else { $extra = []; } return is_array($extra) ? $extra : []; } public static function reset(): void { self::$abilities = null; } public static function get(string $name): ?Ability { $all = self::all(); if (isset($all[$name])) { return $all[$name]; } $current = Registry::aliasFor($name); return $current === null ? null : ($all[$current] ?? null); } /** * Why a journaled change cannot be undone from here, when the reason is * that whatever made it is not active now; null otherwise. */ public static function inactiveReason(string $name): ?string { if (self::get($name) !== null || str_starts_with($name, self::NAMESPACE . '/')) { return null; } return __('This change was made by an add-on that is not active now, so it cannot be undone from here. Turn that add-on back on to undo it.', 'sarcio'); } /** @return array the abilities an administrator switched off in the plugin's settings */ public static function disabled(): array { $raw = (string) \Sarcio\WordPress\Options::get('assistant_disabled'); return $raw === '' ? [] : array_values(array_filter(array_map('trim', explode(',', $raw)))); } public static function isDisabled(string $name): bool { return in_array($name, self::disabled(), true); } /** The abilities the current user may run, as Sarcio is told about them. */ public static function forCurrentUser(): array { $out = []; $disabled = self::disabled(); foreach (self::all() as $ability) { if ($ability->allowed([]) && !in_array($ability->name, $disabled, true)) { $out[] = $ability->describe(); } } return $out; } /** @return array ability name => plain-words label */ public static function labels(): array { $out = []; foreach (self::all() as $ability) { $out[$ability->name] = $ability->label; } return $out; } /** @return array ability name => read|write */ public static function kinds(): array { $out = []; foreach (self::all() as $ability) { $out[$ability->name] = $ability->kind; } return $out; } /** @return array names of abilities that run only with the person's explicit go-ahead */ public static function consents(): array { $out = []; foreach (self::all() as $ability) { if ($ability->consent) { $out[] = $ability->name; } } return $out; } public static function label(string $name): string { return self::get($name)?->label ?? __('A change', 'sarcio'); } /** * @param array $args * @return array{ok:bool,output?:array,error?:string,changeId?:int} */ public static function run(string $name, array $args, bool $consent = false): array { $ability = self::get($name); if ($ability === null) { return ['error' => __('That is not something the assistant can do on this site.', 'sarcio'), 'ok' => false]; } if (self::isDisabled($name)) { return ['error' => __('An administrator turned that off in the Sarcio settings.', 'sarcio'), 'ok' => false]; } $invalid = $ability->validate($args); if ($invalid !== null) { return ['error' => $invalid, 'ok' => false]; } if (!$ability->allowed($args)) { return ['error' => __('You do not have permission to do that on this site.', 'sarcio'), 'ok' => false]; } if ($ability->consent && !$consent) { return ['error' => __('This needs your explicit go-ahead first.', 'sarcio'), 'ok' => false]; } try { $output = ($ability->run)($args); } catch (\Throwable $error) { return ['error' => sanitize_text_field($error->getMessage()), 'ok' => false]; } if (isset($output['error']) && is_string($output['error'])) { return ['error' => $output['error'], 'ok' => false]; } $result = ['ok' => true]; if ($ability->kind === Ability::WRITE && $name !== 'sarcio/cache-purge') { \Sarcio\WordPress\Cache::purgeAfterChange(); } if ($ability->kind === Ability::WRITE && isset($output['journal']) && is_array($output['journal'])) { $journal = $output['journal']; unset($output['journal']); $result['changeId'] = Journal::record( $name, (string) ($journal['summary'] ?? $ability->label), (string) ($journal['target'] ?? ''), is_array($journal['before'] ?? null) ? $journal['before'] : [], is_array($journal['after'] ?? null) ? $journal['after'] : [], ); $output['changeId'] = $result['changeId']; } if (!isset($result['changeId']) && is_int($output['changeId'] ?? null)) { $result['changeId'] = $output['changeId']; } $result['output'] = $output; return $result; } /** * @param array $args * @return array{ok:bool,summary?:string,before?:string,after?:string,plugin?:array,consent?:bool,error?:string} */ public static function preview(string $name, array $args): array { $ability = self::get($name); if ($ability === null) { return ['error' => __('That is not something the assistant can do on this site.', 'sarcio'), 'ok' => false]; } if (self::isDisabled($name)) { return ['error' => __('An administrator turned that off in the Sarcio settings.', 'sarcio'), 'ok' => false]; } $invalid = $ability->validate($args); if ($invalid !== null) { return ['error' => $invalid, 'ok' => false]; } if (!$ability->allowed($args)) { return ['error' => __('You do not have permission to do that on this site.', 'sarcio'), 'ok' => false]; } if ($ability->stage !== null) { $overlay = Stage::active() ? clone Stage::overlay() : new Overlay(); $staged = self::stage($name, $args, $overlay); return $staged['ok'] ? ['consent' => $ability->consent, 'stageable' => true] + $staged : $staged; } if ($ability->preview === null) { return ['after' => '', 'before' => '', 'ok' => true, 'summary' => $ability->label]; } try { $preview = ($ability->preview)($args); } catch (\Throwable $error) { return ['error' => sanitize_text_field($error->getMessage()), 'ok' => false]; } if (isset($preview['error']) && is_string($preview['error'])) { return ['error' => $preview['error'], 'ok' => false]; } return ['consent' => $ability->consent, 'ok' => true] + $preview; } /** * Lays one write over the preview without making it: the same checks as a * run, then the ability's own stage step. * * @param array $args * @return array{ok:bool,summary?:string,before?:string,after?:string,error?:string} */ public static function stage(string $name, array $args, Overlay $overlay): array { $ability = self::get($name); if ($ability === null) { return ['error' => __('That is not something the assistant can do on this site.', 'sarcio'), 'ok' => false]; } if (self::isDisabled($name)) { return ['error' => __('An administrator turned that off in the Sarcio settings.', 'sarcio'), 'ok' => false]; } if ($ability->stage === null) { return ['error' => __('That change cannot be previewed; it is made when you apply it.', 'sarcio'), 'ok' => false]; } $invalid = $ability->validate($args); if ($invalid !== null) { return ['error' => $invalid, 'ok' => false]; } if (!$ability->allowed($args)) { return ['error' => __('You do not have permission to do that on this site.', 'sarcio'), 'ok' => false]; } try { $staged = ($ability->stage)($args, $overlay); } catch (\Throwable $error) { return ['error' => sanitize_text_field($error->getMessage()), 'ok' => false]; } if (isset($staged['error']) && is_string($staged['error'])) { return ['error' => $staged['error'], 'ok' => false]; } return ['ok' => true] + $staged; } public static function stageable(string $name): bool { return self::get($name)?->stage !== null; } /** @return array ability name => whether it can be laid over the preview */ public static function stageables(): array { $out = []; foreach (self::all() as $ability) { $out[$ability->name] = $ability->stage !== null; } return $out; } /** @return array{ok:bool,output?:array,error?:string} */ public static function undo(int $changeId): array { $entry = Journal::get($changeId); if ($entry === null) { return ['error' => __('That change is no longer in the list.', 'sarcio'), 'ok' => false]; } if ($entry['undoneAt'] !== null) { return ['error' => __('That change was already undone.', 'sarcio'), 'ok' => false]; } $ability = self::get($entry['ability']); if ($ability === null || $ability->undo === null) { return ['error' => self::inactiveReason($entry['ability']) ?? __('That change cannot be undone from here.', 'sarcio'), 'ok' => false]; } if (!$ability->allowed($entry['before'] + $entry['after'])) { return ['error' => __('You do not have permission to undo that.', 'sarcio'), 'ok' => false]; } try { $output = ($ability->undo)($entry['before'], $entry['after']); } catch (\Throwable $error) { return ['error' => sanitize_text_field($error->getMessage()), 'ok' => false]; } if (isset($output['error']) && is_string($output['error'])) { return ['error' => $output['error'], 'ok' => false]; } Journal::markUndone($changeId); \Sarcio\WordPress\Cache::purgeAfterChange(); $output['undoneChangeId'] = $changeId; Journal::record( 'sarcio/changes-undo', sprintf( /* translators: %s: what the change did. */ __('Undid: %s', 'sarcio'), $entry['summary'], ), $entry['target'], $entry['after'], $entry['before'], ); return ['ok' => true, 'output' => $output]; } /** WordPress 6.9+ takes categories on their own hook, before any ability. */ public static function registerCategory(): void { if (!function_exists('wp_register_ability_category')) { return; } wp_register_ability_category(self::CATEGORY, [ 'description' => __('Changes to this site made through Sarcio.', 'sarcio'), 'label' => __('Sarcio', 'sarcio'), ]); } /** Hands every ability to WordPress 6.9+'s Abilities API, so the MCP Adapter lists them too. */ public static function registerWithWordPress(): void { if (!function_exists('wp_register_ability')) { return; } foreach (self::own() as $ability) { wp_register_ability($ability->name, [ 'category' => self::CATEGORY, 'description' => $ability->description, 'execute_callback' => static function (mixed $input) use ($ability): mixed { $result = self::run($ability->name, is_array($input) ? $input : []); if (!$result['ok']) { return new \WP_Error('sarcio_ability_failed', (string) ($result['error'] ?? '')); } return $result['output'] ?? []; }, 'input_schema' => $ability->input, 'label' => $ability->label, 'meta' => ['annotations' => ['readonly' => $ability->kind === Ability::READ, 'destructive' => false]], 'output_schema' => ['type' => 'object'], 'permission_callback' => static fn (mixed $input): bool => $ability->allowed(is_array($input) ? $input : []), ]); } } }