['description' => 'rank_math_description', 'title' => 'rank_math_title'], self::YOAST => ['description' => '_yoast_wpseo_metadesc', 'title' => '_yoast_wpseo_title'], ]; /** @return array */ public static function abilities(): array { $idSchema = ['minimum' => 1, 'type' => 'integer']; return [ new Ability( 'sarcio/seo-get', __('Look at a page’s search listing', 'sarcio'), Ability::READ, 'The search engine title and description of a page, post or product, as set in the site’s SEO plugin (Yoast SEO or Rank Math), and which plugin that is. Empty values mean the plugin’s default is used.', ['properties' => ['id' => $idSchema], 'required' => ['id'], 'type' => 'object'], static fn (array $args): bool => isset($args['id']) ? current_user_can('edit_post', (int) $args['id']) : current_user_can('edit_posts'), static fn (array $args): array => self::get((int) $args['id']), ), new Ability( 'sarcio/seo-update', __('Change a page’s search listing', 'sarcio'), Ability::WRITE, 'Set the search engine title and/or description of a page, post or product in the site’s SEO plugin. Only the fields given change; an empty string goes back to the plugin’s default. Keep titles under 60 characters and descriptions under 160.', [ 'properties' => [ 'description' => ['maxLength' => 320, 'type' => 'string'], 'id' => $idSchema, 'title' => ['maxLength' => 200, 'type' => 'string'], ], 'required' => ['id'], 'type' => 'object', ], static fn (array $args): bool => isset($args['id']) ? current_user_can('edit_post', (int) $args['id']) : current_user_can('edit_posts'), static fn (array $args): array => self::update($args), static fn (array $args): array => self::updatePreview($args), static fn (array $before, array $after): array => self::write((int) $before['id'], $before) + ['restored' => true], ), ]; } /** Which SEO plugin holds the listing, or null. */ public static function plugin(): ?string { if (defined('WPSEO_VERSION') || class_exists('WPSEO_Options')) { return self::YOAST; } if (class_exists('RankMath') || defined('RANK_MATH_VERSION')) { return self::RANK_MATH; } return null; } /** The post meta key holding the search description, for the plugin in use. */ public static function descriptionKey(): ?string { $plugin = self::plugin(); return $plugin === null ? null : self::KEYS[$plugin]['description']; } /** @return array{id:int,plugin:string,title:string,description:string} */ public static function snapshot(int $id, string $plugin): array { return [ 'description' => (string) get_post_meta($id, self::KEYS[$plugin]['description'], true), 'id' => $id, 'plugin' => $plugin, 'title' => (string) get_post_meta($id, self::KEYS[$plugin]['title'], true), ]; } private static function get(int $id): array { $post = get_post($id); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } $plugin = self::plugin(); if ($plugin === null) { return ['error' => __('This site has no SEO plugin the assistant can change (Yoast SEO or Rank Math).', 'sarcio')]; } return self::snapshot($id, $plugin) + ['postTitle' => (string) $post->post_title, 'url' => (string) get_permalink($post)]; } /** * @param array $args * @return array{before:array,after:array,changed:array,postTitle:string}|array{error:string} */ private static function changes(array $args): array { $post = get_post((int) $args['id']); if ($post === null) { return ['error' => __('There is no page or post with that id.', 'sarcio')]; } $plugin = self::plugin(); if ($plugin === null) { return ['error' => __('This site has no SEO plugin the assistant can change (Yoast SEO or Rank Math).', 'sarcio')]; } $before = self::snapshot((int) $post->ID, $plugin); $after = $before; foreach (['title', 'description'] as $field) { if (array_key_exists($field, $args)) { $after[$field] = sanitize_text_field((string) $args[$field]); } } $changed = array_values(array_filter(['title', 'description'], static fn (string $field): bool => $before[$field] !== $after[$field])); if ($changed === []) { return ['error' => __('Nothing would change.', 'sarcio')]; } return ['after' => $after, 'before' => $before, 'changed' => $changed, 'postTitle' => (string) $post->post_title]; } /** @param array $args */ private static function update(array $args): array { $changes = self::changes($args); if (isset($changes['error'])) { return $changes; } $written = self::write((int) $args['id'], $changes['after']); if (isset($written['error'])) { return $written; } return [ 'changed' => $changes['changed'], 'id' => (int) $args['id'], 'journal' => [ 'after' => $changes['after'], 'before' => $changes['before'], 'summary' => sprintf( /* translators: 1: title and/or description, 2: the page title. */ __('Changed the search %1$s of “%2$s”', 'sarcio'), implode(' and ', $changes['changed']), $changes['postTitle'], ), 'target' => 'post:' . $args['id'], ], ]; } /** @param array $args */ private static function updatePreview(array $args): array { $changes = self::changes($args); if (isset($changes['error'])) { return $changes; } $line = static fn (array $fields, string $field): string => $field . ': ' . ($fields[$field] !== '' ? $fields[$field] : __('(the plugin’s default)', 'sarcio')); return [ 'after' => implode("\n", array_map(static fn (string $field): string => $line($changes['after'], $field), $changes['changed'])), 'before' => implode("\n", array_map(static fn (string $field): string => $line($changes['before'], $field), $changes['changed'])), 'summary' => sprintf( /* translators: %s: the page title. */ __('Change the search listing of “%s”', 'sarcio'), $changes['postTitle'], ), ]; } /** @param array $fields the snapshot shape */ private static function write(int $id, array $fields): array { $plugin = (string) ($fields['plugin'] ?? self::plugin()); if (!isset(self::KEYS[$plugin]) || get_post($id) === null) { return ['error' => __('That page no longer exists.', 'sarcio')]; } foreach (['title', 'description'] as $field) { $value = (string) ($fields[$field] ?? ''); if ($value === '') { delete_post_meta($id, self::KEYS[$plugin][$field]); } else { update_post_meta($id, self::KEYS[$plugin][$field], $value); } } if ($plugin === self::YOAST) { // Yoast reads its own table at render time and rebuilds it when // the post is saved, so touch the post as the editor would. wp_update_post(['ID' => $id]); } return ['id' => $id]; } }