'string', 'enum' => AOEDIA_TAXONOMIES, 'description' => __( 'category or post_tag.', 'aoedia' ), ); $term_output_schema = array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'taxonomy' => array( 'type' => 'string' ), 'name' => array( 'type' => 'string' ), 'slug' => array( 'type' => 'string' ), 'description' => array( 'type' => 'string' ), 'parent' => array( 'type' => 'integer' ), 'count' => array( 'type' => 'integer' ), ), ); $annotations = static function ( bool $destructive, bool $idempotent ): array { return array( 'annotations' => array( 'readonly' => false, 'destructive' => $destructive, 'idempotent' => $idempotent, ), 'show_in_rest' => true, ); }; wp_register_ability( 'aoedia/create-term', array( 'label' => __( 'Create Category or Tag', 'aoedia' ), 'description' => __( 'Creates a new category or tag. It fails if one with the same name or slug already exists; check with list-taxonomies first.', 'aoedia' ), 'category' => 'aoedia-content', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => $taxonomy_property, 'name' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'The name.', 'aoedia' ), ), 'slug' => array( 'type' => 'string', 'description' => __( 'Optional: The URL slug. Default made from the name.', 'aoedia' ), ), 'description' => array( 'type' => 'string', 'description' => __( 'Optional: A short description.', 'aoedia' ), ), 'parent' => array( 'type' => 'integer', 'minimum' => 0, 'description' => __( 'Optional, categories only: The parent category ID.', 'aoedia' ), ), ), 'required' => array( 'taxonomy', 'name' ), 'additionalProperties' => false, ), 'output_schema' => $term_output_schema, 'execute_callback' => 'aoedia_ability_create_term', 'permission_callback' => static function ( $input = array() ): bool { return aoedia_can_manage_terms( $input, 'edit_terms' ); }, 'meta' => $annotations( false, false ), ) ); wp_register_ability( 'aoedia/update-term', array( 'label' => __( 'Update Category or Tag', 'aoedia' ), 'description' => __( 'Renames a category or tag, or changes its slug, description or parent (categories only). Pass only the fields to change. A new slug changes the archive URL, and old links stop working.', 'aoedia' ), 'category' => 'aoedia-content', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => $taxonomy_property, 'id' => array( 'type' => 'integer', 'description' => __( 'The term ID.', 'aoedia' ), ), 'name' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'Optional: The new name.', 'aoedia' ), ), 'slug' => array( 'type' => 'string', 'minLength' => 1, 'description' => __( 'Optional: The new URL slug.', 'aoedia' ), ), 'description' => array( 'type' => 'string', 'description' => __( 'Optional: The new description.', 'aoedia' ), ), 'parent' => array( 'type' => 'integer', 'minimum' => 0, 'description' => __( 'Optional, categories only: The new parent category ID, or 0 for a top-level category.', 'aoedia' ), ), ), 'required' => array( 'taxonomy', 'id' ), // The taxonomy and ID plus at least one field to change. 'minProperties' => 3, 'additionalProperties' => false, ), 'output_schema' => $term_output_schema, 'execute_callback' => 'aoedia_ability_update_term', 'permission_callback' => static function ( $input = array() ): bool { return aoedia_can_manage_terms( $input, 'edit_terms' ); }, 'meta' => $annotations( false, true ), ) ); wp_register_ability( 'aoedia/delete-term', array( 'label' => __( 'Delete Category or Tag', 'aoedia' ), 'description' => __( 'Permanently deletes a category or tag. Posts keep their other terms; posts left without a category get the default category, which cannot be deleted. Propose this only when the user clearly asks for it.', 'aoedia' ), 'category' => 'aoedia-content', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => $taxonomy_property, 'id' => array( 'type' => 'integer', 'description' => __( 'The term ID.', 'aoedia' ), ), ), 'required' => array( 'taxonomy', 'id' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'deleted' => array( 'type' => 'string' ), 'affected' => array( 'type' => 'integer' ), ), ), 'execute_callback' => 'aoedia_ability_delete_term', 'permission_callback' => static function ( $input = array() ): bool { return aoedia_can_manage_terms( $input, 'delete_terms' ); }, 'meta' => $annotations( true, true ), ) ); wp_register_ability( 'aoedia/merge-terms', array( 'label' => __( 'Merge Categories or Tags', 'aoedia' ), 'description' => __( 'Moves every post from one category or tag to another of the same kind, then deletes the first one. Subcategories of a merged category move up to its parent. Propose this only when the user clearly asks for it.', 'aoedia' ), 'category' => 'aoedia-content', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => $taxonomy_property, 'from' => array( 'type' => 'integer', 'description' => __( 'The ID of the term to merge and delete.', 'aoedia' ), ), 'to' => array( 'type' => 'integer', 'description' => __( 'The ID of the term that is kept.', 'aoedia' ), ), ), 'required' => array( 'taxonomy', 'from', 'to' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'merged' => array( 'type' => 'string' ), 'into' => array( 'type' => 'string' ), 'moved' => array( 'type' => 'integer' ), ), ), 'execute_callback' => 'aoedia_ability_merge_terms', 'permission_callback' => static function ( $input = array() ): bool { return aoedia_can_manage_terms( $input, 'edit_terms' ) && aoedia_can_manage_terms( $input, 'delete_terms' ); }, 'meta' => $annotations( true, true ), ) ); } /** * Returns whether the current user has a term capability for the input's taxonomy. * * @param mixed $input The ability input. * @param string $cap The taxonomy capability: edit_terms or delete_terms. * @return bool Whether the user has it. */ function aoedia_can_manage_terms( $input, string $cap ): bool { $taxonomy = is_array( $input ) && isset( $input['taxonomy'] ) ? (string) $input['taxonomy'] : ''; if ( ! in_array( $taxonomy, AOEDIA_TAXONOMIES, true ) ) { return false; } return current_user_can( get_taxonomy( $taxonomy )->cap->$cap ); } /** * Returns a term of the given taxonomy, or an error if there is none. * * @param string $taxonomy The taxonomy. * @param int $term_id The term ID. * @return WP_Term|WP_Error The term, or an error. */ function aoedia_get_term( string $taxonomy, int $term_id ) { $term = $term_id > 0 ? get_term( $term_id, $taxonomy ) : null; if ( ! $term instanceof WP_Term ) { return new WP_Error( 'aoedia_term_not_found', 'category' === $taxonomy ? __( 'Category not found.', 'aoedia' ) : __( 'Tag not found.', 'aoedia' ) ); } return $term; } /** * Checks a term change before it is proposed and again before it runs. * * @param string $ability The ability name. * @param array $input The validated input. * @return true|WP_Error True, or the problem. */ function aoedia_validate_term_change( string $ability, array $input ) { $taxonomy = (string) $input['taxonomy']; if ( 'aoedia/delete-term' === $ability || 'aoedia/merge-terms' === $ability ) { $id = (int) ( $input['id'] ?? $input['from'] ); $term = aoedia_get_term( $taxonomy, $id ); if ( is_wp_error( $term ) ) { return $term; } if ( 'category' === $taxonomy && (int) get_option( 'default_category' ) === $id ) { return new WP_Error( 'aoedia_default_category', __( 'The default category cannot be deleted or merged. Choose another default category first, under Settings → Writing.', 'aoedia' ) ); } if ( 'aoedia/merge-terms' === $ability ) { $target = aoedia_get_term( $taxonomy, (int) $input['to'] ); if ( is_wp_error( $target ) ) { return $target; } if ( $target->term_id === $id ) { return new WP_Error( 'aoedia_merge_same', __( 'A term cannot be merged into itself.', 'aoedia' ) ); } } return true; } $term_id = (int) ( $input['id'] ?? 0 ); if ( $term_id ) { $term = aoedia_get_term( $taxonomy, $term_id ); if ( is_wp_error( $term ) ) { return $term; } } if ( isset( $input['parent'] ) && (int) $input['parent'] > 0 ) { if ( 'category' !== $taxonomy ) { return new WP_Error( 'aoedia_invalid_parent', __( 'Tags cannot have a parent.', 'aoedia' ) ); } $parent = aoedia_get_term( 'category', (int) $input['parent'] ); if ( is_wp_error( $parent ) ) { return new WP_Error( 'aoedia_invalid_parent', __( 'The parent category does not exist.', 'aoedia' ) ); } if ( $term_id && ( $parent->term_id === $term_id || in_array( $term_id, get_ancestors( $parent->term_id, 'category', 'taxonomy' ), true ) ) ) { return new WP_Error( 'aoedia_invalid_parent', __( 'A category cannot be placed under itself or one of its subcategories.', 'aoedia' ) ); } } if ( isset( $input['slug'] ) && '' !== $input['slug'] ) { $existing = get_term_by( 'slug', sanitize_title( $input['slug'] ), $taxonomy ); if ( $existing && $existing->term_id !== $term_id ) { /* translators: %s: slug. */ return new WP_Error( 'aoedia_term_exists', sprintf( __( 'The slug "%s" is already used.', 'aoedia' ), sanitize_title( $input['slug'] ) ) ); } } if ( ! $term_id && term_exists( sanitize_text_field( $input['name'] ), $taxonomy, (int) ( $input['parent'] ?? 0 ) ) ) { /* translators: %s: term name. */ return new WP_Error( 'aoedia_term_exists', sprintf( __( '"%s" already exists.', 'aoedia' ), sanitize_text_field( $input['name'] ) ) ); } return true; } /** * Formats a term for ability output and approval cards. * * @param WP_Term $term The term. * @return array The term data. */ function aoedia_term_summary( WP_Term $term ): array { return array( 'id' => $term->term_id, 'taxonomy' => $term->taxonomy, 'name' => aoedia_plain_text( $term->name ), 'slug' => $term->slug, 'description' => $term->description, 'parent' => (int) $term->parent, 'count' => (int) $term->count, ); } /** * Returns a term's data with its parent's name, for showing it next to a proposed change. * * @param string $taxonomy The taxonomy. * @param int $term_id The term ID. * @return array|null The snapshot, or null if there is no such term. */ function aoedia_term_snapshot( string $taxonomy, int $term_id ): ?array { $term = aoedia_get_term( $taxonomy, $term_id ); if ( is_wp_error( $term ) ) { return null; } return array_merge( aoedia_term_summary( $term ), array( 'parent_name' => aoedia_term_parent_name( $taxonomy, (int) $term->parent ) ) ); } /** * Returns the name of a category's parent. * * @param string $taxonomy The taxonomy. * @param int $parent_id The parent term ID. * @return string The name, or an empty string for none. */ function aoedia_term_parent_name( string $taxonomy, int $parent_id ): string { $parent = $parent_id ? get_term( $parent_id, $taxonomy ) : null; return $parent instanceof WP_Term ? aoedia_plain_text( $parent->name ) : ''; } /** * Executes aoedia/create-term. * * @param array $input The ability input. * @return array|WP_Error The created term, or an error. */ function aoedia_ability_create_term( $input = array() ) { $check = aoedia_validate_term_change( 'aoedia/create-term', $input ); if ( is_wp_error( $check ) ) { return $check; } $args = array( 'description' => sanitize_textarea_field( $input['description'] ?? '' ) ); if ( ! empty( $input['slug'] ) ) { $args['slug'] = sanitize_title( $input['slug'] ); } if ( 'category' === $input['taxonomy'] ) { $args['parent'] = (int) ( $input['parent'] ?? 0 ); } $created = wp_insert_term( sanitize_text_field( $input['name'] ), $input['taxonomy'], $args ); if ( is_wp_error( $created ) ) { return $created; } return aoedia_term_summary( get_term( $created['term_id'], $input['taxonomy'] ) ); } /** * Executes aoedia/update-term. * * @param array $input The ability input. * @return array|WP_Error The updated term, or an error. */ function aoedia_ability_update_term( $input = array() ) { $check = aoedia_validate_term_change( 'aoedia/update-term', $input ); if ( is_wp_error( $check ) ) { return $check; } $args = array(); if ( isset( $input['name'] ) ) { $args['name'] = sanitize_text_field( $input['name'] ); } if ( isset( $input['slug'] ) ) { $args['slug'] = sanitize_title( $input['slug'] ); } if ( isset( $input['description'] ) ) { $args['description'] = sanitize_textarea_field( $input['description'] ); } if ( isset( $input['parent'] ) && 'category' === $input['taxonomy'] ) { $args['parent'] = (int) $input['parent']; } $updated = wp_update_term( (int) $input['id'], $input['taxonomy'], $args ); if ( is_wp_error( $updated ) ) { return $updated; } return aoedia_term_summary( get_term( $updated['term_id'], $input['taxonomy'] ) ); } /** * Executes aoedia/delete-term. * * @param array $input The ability input. * @return array|WP_Error What was deleted, or an error. */ function aoedia_ability_delete_term( $input = array() ) { $check = aoedia_validate_term_change( 'aoedia/delete-term', $input ); if ( is_wp_error( $check ) ) { return $check; } $term = get_term( (int) $input['id'], $input['taxonomy'] ); $deleted = wp_delete_term( $term->term_id, $term->taxonomy ); if ( true !== $deleted ) { return is_wp_error( $deleted ) ? $deleted : new WP_Error( 'aoedia_delete_failed', __( 'The term could not be deleted.', 'aoedia' ) ); } return array( 'deleted' => aoedia_plain_text( $term->name ), 'affected' => (int) $term->count, ); } /** * Executes aoedia/merge-terms. * * @param array $input The ability input. * @return array|WP_Error What was merged, or an error. */ function aoedia_ability_merge_terms( $input = array() ) { $check = aoedia_validate_term_change( 'aoedia/merge-terms', $input ); if ( is_wp_error( $check ) ) { return $check; } $from = get_term( (int) $input['from'], $input['taxonomy'] ); $to = get_term( (int) $input['to'], $input['taxonomy'] ); // With force_default, every post that had the deleted term gets the kept one. $deleted = wp_delete_term( $from->term_id, $from->taxonomy, array( 'default' => $to->term_id, 'force_default' => true, ) ); if ( true !== $deleted ) { return is_wp_error( $deleted ) ? $deleted : new WP_Error( 'aoedia_delete_failed', __( 'The term could not be deleted.', 'aoedia' ) ); } return array( 'merged' => aoedia_plain_text( $from->name ), 'into' => aoedia_plain_text( $to->name ), 'moved' => (int) $from->count, ); }