__( 'AI Editor Content', 'ai-editor' ), 'description' => __( 'Abilities that let AI Editor read existing WordPress content for block generation and editing.', 'ai-editor' ), ) ); } /** * Registers AI Editor content abilities. * * @return void */ public static function register_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( self::ABILITY_NAME, array( 'label' => __( 'Query Content', 'ai-editor' ), 'description' => __( 'Returns titles, excerpts, content, and links for WordPress posts, pages, and public custom post types.', 'ai-editor' ), 'category' => self::CATEGORY_SLUG, 'input_schema' => array( 'type' => 'object', 'properties' => array( 'post_type' => array( 'type' => 'string', 'description' => self::get_post_type_schema_description(), 'enum' => self::get_available_post_type_slugs(), ), 'post_id' => array( 'type' => 'integer', 'description' => __( 'Specific post ID to query. Use 0 when searching by post type and search term.', 'ai-editor' ), 'minimum' => 0, ), 'use_current_document' => array( 'type' => 'boolean', 'description' => __( 'Set true when the user asks about the current page or post.', 'ai-editor' ), ), 'limit' => array( 'type' => 'integer', 'description' => __( 'Maximum number of content items to return. Capped at 50 items.', 'ai-editor' ), 'minimum' => 1, 'maximum' => 50, ), 'post_status' => array( 'type' => 'string', 'description' => __( 'Content status to query.', 'ai-editor' ), 'enum' => array( 'publish', 'draft', 'pending', 'future', 'private', 'any' ), ), 'search' => array( 'type' => 'string', 'description' => __( 'Optional search term to match titles and content.', 'ai-editor' ), ), 'content_mode' => array( 'type' => 'string', 'description' => __( 'How much content to return. Use titles for lightweight title/link lookup, excerpts for short summaries, and text for readable Markdown content.', 'ai-editor' ), 'enum' => array( 'titles', 'excerpts', 'text' ), ), ), 'required' => array( 'post_type', 'post_id', 'use_current_document', 'limit', 'post_status', 'search', 'content_mode' ), 'additionalProperties' => false, 'default' => array( 'post_type' => 'post', 'post_id' => 0, 'use_current_document' => false, 'limit' => 10, 'post_status' => 'publish', 'search' => '', 'content_mode' => 'titles', ), ), 'output_schema' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'required' => array( 'id', 'title', 'status', 'type', 'permalink', 'edit_url', 'date', 'excerpt', 'content' ), 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'The post ID.', 'ai-editor' ), ), 'title' => array( 'type' => 'string', 'description' => __( 'The post title.', 'ai-editor' ), ), 'status' => array( 'type' => 'string', 'description' => __( 'The post status.', 'ai-editor' ), ), 'type' => array( 'type' => 'string', 'description' => __( 'The post type.', 'ai-editor' ), ), 'permalink' => array( 'type' => 'string', 'description' => __( 'The public post URL when available.', 'ai-editor' ), ), 'edit_url' => array( 'type' => 'string', 'description' => __( 'The admin edit URL when available.', 'ai-editor' ), ), 'date' => array( 'type' => 'string', 'description' => __( 'The post date.', 'ai-editor' ), ), 'excerpt' => array( 'type' => 'string', 'description' => __( 'The content excerpt when requested or available.', 'ai-editor' ), ), 'content' => array( 'type' => 'string', 'description' => __( 'Readable Markdown content when requested, with plain text fallback.', 'ai-editor' ), ), ), 'additionalProperties' => false, ), ), 'execute_callback' => array( __CLASS__, 'query_content' ), 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, 'meta' => array( 'ai_editor' => array( 'enabled' => true, 'action_name' => 'query_wordpress_content', 'description' => __( 'Use this to look up existing WordPress content when page/post context is needed for the request. Set use_current_document true when the user asks about the current page or post. Choose post_type from the available enum values. This returns text, not block markup.', 'ai-editor' ), ), ), ) ); } /** * Returns WordPress content titles, links, excerpts, and Markdown content when requested. * * @param array $input Ability input. * @return array>|WP_Error */ public static function query_content( $input = array() ) { $input = is_array( $input ) ? $input : array(); $post_type = isset( $input['post_type'] ) ? sanitize_key( (string) $input['post_type'] ) : 'post'; $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; $limit = isset( $input['limit'] ) ? absint( $input['limit'] ) : 10; $post_status = isset( $input['post_status'] ) ? sanitize_key( (string) $input['post_status'] ) : 'publish'; $search = isset( $input['search'] ) ? sanitize_text_field( (string) $input['search'] ) : ''; $content_mode = isset( $input['content_mode'] ) ? sanitize_key( (string) $input['content_mode'] ) : 'titles'; if ( ! in_array( $post_status, array( 'publish', 'draft', 'pending', 'future', 'private', 'any' ), true ) ) { $post_status = $post_id > 0 ? 'any' : 'publish'; } if ( ! in_array( $content_mode, array( 'titles', 'excerpts', 'text' ), true ) ) { $content_mode = 'titles'; } $limit = max( 1, min( 50, $limit ) ); if ( ! in_array( $post_type, self::get_available_post_type_slugs(), true ) ) { return new WP_Error( 'ai_editor_invalid_post_type', __( 'The requested post type is not available to AI Editor.', 'ai-editor' ) ); } $posts = get_posts( array( 'post_type' => $post_type, 'post_status' => 'any' === $post_status ? array( 'publish', 'draft', 'pending', 'future', 'private' ) : $post_status, 'numberposts' => $post_id > 0 ? 1 : $limit, 'orderby' => 'date', 'order' => 'DESC', 's' => $post_id > 0 ? '' : $search, 'include' => $post_id > 0 ? array( $post_id ) : array(), 'suppress_filters' => false, ) ); if ( ! is_array( $posts ) ) { return array(); } $results = array(); foreach ( $posts as $post ) { $post_id = isset( $post->ID ) ? absint( $post->ID ) : 0; $title = $post_id ? get_the_title( $post_id ) : ''; $permalink = $post_id ? get_permalink( $post_id ) : ''; $edit_url = $post_id ? get_edit_post_link( $post_id, '' ) : ''; $results[] = array( 'id' => $post_id, 'title' => '' !== $title ? $title : __( '(no title)', 'ai-editor' ), 'status' => $post_id ? get_post_status( $post_id ) : '', 'type' => $post_id ? get_post_type( $post_id ) : 'post', 'permalink' => is_string( $permalink ) ? $permalink : '', 'edit_url' => is_string( $edit_url ) ? $edit_url : '', 'date' => $post_id ? get_post_field( 'post_date', $post_id ) : '', 'excerpt' => 'excerpts' === $content_mode ? self::get_excerpt( $post_id ) : '', 'content' => 'text' === $content_mode ? self::get_markdown_content( $post_id ) : '', ); } return $results; } /** * Returns the available post types AI Editor may query. * * @return array Post type slug => label. */ private static function get_available_post_types() { $post_types = array( 'post' => __( 'Posts', 'ai-editor' ), 'page' => __( 'Pages', 'ai-editor' ), ); if ( function_exists( 'get_post_types' ) ) { $registered = get_post_types( array( 'public' => true ), 'objects' ); if ( is_array( $registered ) ) { foreach ( $registered as $slug => $post_type ) { $name = is_object( $post_type ) && isset( $post_type->name ) ? $post_type->name : $slug; $name = sanitize_key( (string) $name ); if ( '' === $name || 'attachment' === $name ) { continue; } $label = is_object( $post_type ) && isset( $post_type->label ) ? $post_type->label : $name; $post_types[ $name ] = sanitize_text_field( (string) $label ); } } } $post_types = apply_filters( 'ai_editor_content_query_post_types', $post_types ); if ( ! is_array( $post_types ) || empty( $post_types ) ) { return array( 'post' => __( 'Posts', 'ai-editor' ) ); } $normalized = array(); foreach ( $post_types as $slug => $label ) { $slug = sanitize_key( (string) $slug ); if ( '' === $slug ) { continue; } $normalized[ $slug ] = sanitize_text_field( (string) $label ); } return ! empty( $normalized ) ? $normalized : array( 'post' => __( 'Posts', 'ai-editor' ) ); } /** * Returns available post type slugs. * * @return array */ private static function get_available_post_type_slugs() { return array_values( array_keys( self::get_available_post_types() ) ); } /** * Returns the schema description for post type choices. * * @return string */ private static function get_post_type_schema_description() { $parts = array(); foreach ( self::get_available_post_types() as $slug => $label ) { $parts[] = $slug . ' = ' . $label; } return sprintf( /* translators: %s: comma-separated post type options. */ __( 'WordPress post type to query. Available values: %s.', 'ai-editor' ), implode( ', ', $parts ) ); } /** * Returns a post excerpt, falling back to trimmed content. * * @param int $post_id Post ID. * @return string */ private static function get_excerpt( $post_id ) { $excerpt = (string) get_post_field( 'post_excerpt', $post_id ); if ( '' !== $excerpt ) { return sanitize_text_field( $excerpt ); } $content = wp_strip_all_tags( (string) get_post_field( 'post_content', $post_id ) ); if ( function_exists( 'wp_trim_words' ) ) { return wp_trim_words( $content, 55, '...' ); } return sanitize_text_field( $content ); } /** * Returns readable Markdown from saved post content without rendering dynamic blocks. * * @param int $post_id Post ID. * @return string */ private static function get_markdown_content( $post_id ) { $content = (string) get_post_field( 'post_content', $post_id ); $html = self::strip_gutenberg_comments( $content ); $markdown = self::html_to_markdown( $html ); if ( '' !== trim( $markdown ) ) { return $markdown; } return self::normalize_plain_text( $html ); } /** * Remove Gutenberg block comments while keeping saved static HTML. * * @param string $content Saved post content. * @return string */ private static function strip_gutenberg_comments( $content ) { $content = preg_replace( '//', "\n", $content ); return is_string( $content ) ? $content : ''; } /** * Convert common saved HTML into Markdown. Returns an empty string on failure. * * @param string $html HTML fragment. * @return string */ private static function html_to_markdown( $html ) { if ( '' === trim( $html ) || ! class_exists( 'DOMDocument' ) ) { return ''; } $previous_errors = libxml_use_internal_errors( true ); $dom = new DOMDocument(); $loaded = $dom->loadHTML( '
' . $html . '
', LIBXML_NOWARNING | LIBXML_NOERROR ); libxml_clear_errors(); libxml_use_internal_errors( $previous_errors ); if ( ! $loaded ) { return ''; } $root = $dom->getElementById( 'ai-editor-markdown-root' ); if ( ! $root ) { return ''; } return self::normalize_markdown( self::node_children_to_markdown( $root ) ); } /** * Convert child nodes to Markdown. * * @param DOMNode $node Parent node. * @return string */ private static function node_children_to_markdown( $node ) { $output = ''; foreach ( $node->childNodes as $child ) { $output .= self::node_to_markdown( $child ); } return $output; } /** * Convert a DOM node to Markdown. * * @param DOMNode $node DOM node. * @return string */ private static function node_to_markdown( $node ) { if ( XML_TEXT_NODE === $node->nodeType ) { return html_entity_decode( $node->nodeValue, ENT_QUOTES, 'UTF-8' ); } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); $children = self::node_children_to_markdown( $node ); $text = trim( $children ); if ( '' === $text && 'img' !== $tag && 'br' !== $tag ) { return ''; } if ( preg_match( '/^h([1-6])$/', $tag, $matches ) ) { return "\n\n" . str_repeat( '#', (int) $matches[1] ) . ' ' . $text . "\n\n"; } switch ( $tag ) { case 'p': case 'div': case 'section': case 'article': return "\n\n" . $text . "\n\n"; case 'br': return "\n"; case 'strong': case 'b': return '**' . $text . '**'; case 'em': case 'i': return '*' . $text . '*'; case 'blockquote': return "\n\n" . self::prefix_markdown_lines( $text, '> ' ) . "\n\n"; case 'a': $href = $node->hasAttribute( 'href' ) ? trim( $node->getAttribute( 'href' ) ) : ''; return '' !== $href ? '[' . $text . '](' . $href . ')' : $text; case 'img': $src = $node->hasAttribute( 'src' ) ? trim( $node->getAttribute( 'src' ) ) : ''; $alt = $node->hasAttribute( 'alt' ) ? trim( $node->getAttribute( 'alt' ) ) : ''; return '' !== $src ? '![' . $alt . '](' . $src . ')' : ''; case 'ul': return "\n" . self::list_to_markdown( $node, false ) . "\n"; case 'ol': return "\n" . self::list_to_markdown( $node, true ) . "\n"; case 'table': return "\n\n" . self::table_to_markdown( $node ) . "\n\n"; case 'pre': return "\n\n```\n" . trim( $node->textContent ) . "\n```\n\n"; case 'code': return '`' . trim( $node->textContent ) . '`'; default: return $children; } } /** * Convert list children to Markdown list items. * * @param DOMNode $node List node. * @param bool $ordered Whether the list is ordered. * @return string */ private static function list_to_markdown( $node, $ordered ) { $output = ''; $index = 1; foreach ( $node->childNodes as $child ) { if ( XML_ELEMENT_NODE !== $child->nodeType || 'li' !== strtolower( $child->nodeName ) ) { continue; } $prefix = $ordered ? $index . '. ' : '- '; $item = trim( self::node_children_to_markdown( $child ) ); if ( '' !== $item ) { $output .= $prefix . preg_replace( "/\n+/", "\n ", $item ) . "\n"; } $index++; } return rtrim( $output ); } /** * Convert a simple HTML table to Markdown. * * @param DOMNode $node Table node. * @return string */ private static function table_to_markdown( $node ) { $rows = array(); foreach ( $node->getElementsByTagName( 'tr' ) as $row ) { $cells = array(); foreach ( $row->childNodes as $cell ) { if ( XML_ELEMENT_NODE !== $cell->nodeType || ! in_array( strtolower( $cell->nodeName ), array( 'th', 'td' ), true ) ) { continue; } $cells[] = str_replace( '|', '\\|', trim( preg_replace( '/\s+/', ' ', self::node_children_to_markdown( $cell ) ) ) ); } if ( ! empty( $cells ) ) { $rows[] = $cells; } } if ( empty( $rows ) ) { return trim( self::node_children_to_markdown( $node ) ); } $markdown = '| ' . implode( ' | ', $rows[0] ) . " |\n"; $markdown .= '| ' . implode( ' | ', array_fill( 0, count( $rows[0] ), '---' ) ) . " |\n"; for ( $i = 1; $i < count( $rows ); $i++ ) { $markdown .= '| ' . implode( ' | ', $rows[ $i ] ) . " |\n"; } return rtrim( $markdown ); } /** * Prefix each Markdown line. * * @param string $text Text. * @param string $prefix Line prefix. * @return string */ private static function prefix_markdown_lines( $text, $prefix ) { $lines = preg_split( "/\r\n|\r|\n/", $text ); if ( ! is_array( $lines ) ) { return $prefix . $text; } return implode( "\n", array_map( static function ( $line ) use ( $prefix ) { return $prefix . $line; }, $lines ) ); } /** * Normalize Markdown whitespace. * * @param string $markdown Markdown content. * @return string */ private static function normalize_markdown( $markdown ) { $markdown = preg_replace( "/[ \t]+\n/", "\n", $markdown ); $markdown = preg_replace( "/\n{3,}/", "\n\n", $markdown ); return trim( is_string( $markdown ) ? $markdown : '' ); } /** * Normalize HTML or saved block fragments into readable single-line text. * * @param string $html HTML or saved content. * @return string */ private static function normalize_plain_text( $html ) { $text = wp_strip_all_tags( $html ); $text = preg_replace( '/\s+/', ' ', $text ); return trim( is_string( $text ) ? $text : '' ); } } AIEditor_Post_Abilities::init();