label. */ public static function taxonomies() { $list = array( 'category' => __( 'Categories', 'acs-article-generator' ), 'post_tag' => __( 'Tags', 'acs-article-generator' ), ); /** * Filters the taxonomies offered on the hub screen. * * @param array $list Taxonomy name => label. */ return apply_filters( 'acsagen_hub_taxonomies', $list ); } /** * Terms that have at least one published post, so an empty category is * never offered and never generated. * * @param string $taxonomy Taxonomy name. * @return array List of term summaries. */ public static function available_terms( $taxonomy ) { if ( ! array_key_exists( $taxonomy, self::taxonomies() ) ) { return array(); } $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', ) ); if ( is_wp_error( $terms ) ) { return array(); } $list = array(); foreach ( $terms as $term ) { $count = self::published_count( $taxonomy, (int) $term->term_id ); if ( $count < 1 ) { continue; } $list[] = array( 'term_id' => (int) $term->term_id, 'name' => (string) $term->name, 'count' => $count, 'hub' => self::existing_hub( $taxonomy, (int) $term->term_id ), ); } return $list; } /** * How many published posts a term holds. * * @param string $taxonomy Taxonomy name. * @param int $term_id Term id. * @return int */ public static function published_count( $taxonomy, $term_id ) { $query = new WP_Query( self::post_query_args( $taxonomy, $term_id, 1 ) ); return (int) $query->found_posts; } /** * Query arguments for the posts in a term. * * @param string $taxonomy Taxonomy name. * @param int $term_id Term id. * @param int $limit How many posts. * @return array */ private static function post_query_args( $taxonomy, $term_id, $limit ) { return array( 'post_type' => 'post', 'post_status' => 'publish', 'posts_per_page' => (int) $limit, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, // Selecting the posts of one term is the whole point of this // screen, so there is no way to avoid the taxonomy join. It runs // once, only when an editor presses the button, never on a page // view, and it is bounded by MAX_POSTS. 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => (int) $term_id, ), ), ); } /** * The most recent hub this plugin drafted for a term, if any. * * @param string $taxonomy Taxonomy name. * @param int $term_id Term id. * @return array|null */ public static function existing_hub( $taxonomy, $term_id ) { $found = get_posts( array( 'post_type' => array( 'page', 'post' ), 'post_status' => array( 'draft', 'pending', 'publish', 'private', 'future' ), 'posts_per_page' => 1, 'orderby' => 'date', 'order' => 'DESC', 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_term_cache' => false, // A direct meta lookup on a key this plugin owns. The number of // rows is one per hub page, so this stays small. 'meta_key' => self::META_TERM, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $taxonomy . ':' . (int) $term_id, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); if ( empty( $found ) ) { return null; } $hub = $found[0]; $posts = get_post_meta( $hub->ID, self::META_POSTS, true ); return array( 'post_id' => (int) $hub->ID, 'title' => (string) $hub->post_title, 'status' => (string) $hub->post_status, 'built' => (string) get_post_meta( $hub->ID, self::META_BUILT, true ), 'post_ids' => is_array( $posts ) ? array_map( 'intval', $posts ) : array(), 'edit_url' => (string) get_edit_post_link( $hub->ID, 'raw' ), ); } /** * Build a hub page for one term and save it as a draft. * * @param array $args { * @type string $taxonomy Taxonomy name. * @type int $term_id Term id. * @type string $post_type "page" or "post". * @type string $notes Extra instructions for the model. * } * @return array|WP_Error */ public static function build( $args ) { $taxonomy = isset( $args['taxonomy'] ) ? (string) $args['taxonomy'] : 'category'; $term_id = isset( $args['term_id'] ) ? (int) $args['term_id'] : 0; $post_type = isset( $args['post_type'] ) && 'post' === $args['post_type'] ? 'post' : 'page'; $notes = isset( $args['notes'] ) ? trim( (string) $args['notes'] ) : ''; if ( ! array_key_exists( $taxonomy, self::taxonomies() ) ) { return new WP_Error( 'acsagen_hub_bad_taxonomy', __( 'That is not a taxonomy a hub page can be built from.', 'acs-article-generator' ) ); } $term = get_term( $term_id, $taxonomy ); if ( ! $term || is_wp_error( $term ) ) { return new WP_Error( 'acsagen_hub_no_term', __( 'That category no longer exists. Reload the screen and choose another.', 'acs-article-generator' ) ); } $posts = self::collect_posts( $taxonomy, $term_id ); if ( empty( $posts ) ) { return new WP_Error( 'acsagen_hub_empty', sprintf( /* translators: %s: category or tag name. */ __( '"%s" has no published posts, so there is nothing to build a hub page from.', 'acs-article-generator' ), $term->name ) ); } $previous = self::existing_hub( $taxonomy, $term_id ); $raw = ACSAGEN_Client::generate( self::build_prompt( $term, $posts, $notes ), true ); if ( is_wp_error( $raw ) ) { return $raw; } $data = ACSAGEN_Client::decode_json( $raw ); if ( is_wp_error( $data ) ) { return $data; } foreach ( array( 'title', 'meta_description', 'slug', 'content_html' ) as $key ) { if ( empty( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) { return new WP_Error( 'acsagen_missing_field', sprintf( /* translators: %s: name of the missing field. */ __( 'The reply was missing the "%s" field. Please try again.', 'acs-article-generator' ), $key ) ); } } $linked = array(); $body = self::apply_real_links( (string) $data['content_html'], $posts, $linked ); $body = self::append_missing( $body, $posts, $linked ); $draft = ACSAGEN_Draft::create( array( 'title' => $data['title'], 'meta_description' => $data['meta_description'], 'slug' => $data['slug'], 'content_html' => $body, 'tags' => array(), 'categories' => array(), ), array( 'post_type' => $post_type, 'skip_terms' => true, ) ); if ( is_wp_error( $draft ) ) { return $draft; } $post_ids = wp_list_pluck( $posts, 'id' ); update_post_meta( $draft['post_id'], self::META_TERM, $taxonomy . ':' . $term_id ); update_post_meta( $draft['post_id'], self::META_POSTS, array_map( 'intval', $post_ids ) ); update_post_meta( $draft['post_id'], self::META_BUILT, current_time( 'mysql' ) ); return array( 'post_id' => $draft['post_id'], 'title' => $draft['title'], 'edit_url' => $draft['edit_url'], 'post_type' => $post_type, 'term_name' => (string) $term->name, 'covered' => count( $post_ids ), 'linked' => count( array_unique( $linked ) ), 'difference' => self::difference( $previous, $post_ids ), ); } /** * Compare this run with the previous hub for the same term. * * @param array|null $previous Previous hub details. * @param array $post_ids Ids covered this time. * @return array */ private static function difference( $previous, $post_ids ) { if ( empty( $previous ) || empty( $previous['post_ids'] ) ) { return array( 'first' => true, 'added' => array(), 'gone' => array(), ); } $before = array_map( 'intval', $previous['post_ids'] ); $now = array_map( 'intval', $post_ids ); return array( 'first' => false, 'previous' => (int) $previous['post_id'], 'added' => array_values( array_diff( $now, $before ) ), 'gone' => array_values( array_diff( $before, $now ) ), ); } /** * Collect the posts a hub page will cover. * * @param string $taxonomy Taxonomy name. * @param int $term_id Term id. * @return array */ private static function collect_posts( $taxonomy, $term_id ) { $query = new WP_Query( self::post_query_args( $taxonomy, $term_id, self::MAX_POSTS ) ); $posts = array(); foreach ( $query->posts as $post ) { // A password protected post stays in the index, but none of its // text is sent to the model or summarised into the draft, because // the person building the hub may not know the password. if ( '' !== (string) $post->post_password ) { $extract = ''; } else { $extract = has_excerpt( $post ) ? get_the_excerpt( $post ) : wp_strip_all_tags( strip_shortcodes( (string) $post->post_content ) ); } $extract = trim( (string) preg_replace( '/\s+/u', ' ', (string) $extract ) ); $posts[] = array( 'id' => (int) $post->ID, 'title' => (string) get_the_title( $post ), 'url' => (string) get_permalink( $post ), 'date' => (string) get_the_date( 'Y-m-d', $post ), 'extract' => mb_substr( $extract, 0, self::EXTRACT_CHARS ), ); } wp_reset_postdata(); return $posts; } /** * Build the hub prompt. * * @param WP_Term $term The term. * @param array $posts Posts to cover. * @param string $notes Extra instructions. * @return string */ private static function build_prompt( $term, $posts, $notes ) { $language = ACSAGEN_Settings::language_for_prompt(); $tone = ACSAGEN_Tone::get(); $concept = (string) ACSAGEN_Settings::get( 'site_concept', '' ); $list = ''; $number = 0; foreach ( $posts as $post ) { ++$number; $list .= "{$number}. [[POST:{$number}]] \"{$post['title']}\" (published {$post['date']})\n"; $list .= " {$post['extract']}\n\n"; } $sections = array(); $sections[] = 'You are building a hub page, also called a pillar page, for one section of a website. ' . 'Its job is to be the page someone lands on first, understand the whole subject from, and then move through the individual articles in a sensible order.'; $sections[] = "## The section\nThe category is \"{$term->name}\". It holds the " . count( $posts ) . ' published articles listed below.'; if ( '' !== $concept ) { $sections[] = "## What this site is about\n" . $concept; } if ( '' !== $tone ) { $sections[] = "## House style of this site, which you must follow\n" . $tone; } $sections[] = "## The articles, each with the placeholder that stands for it\n" . $list; if ( '' !== $notes ) { $sections[] = "## Extra instructions from the author\n" . $notes; } return implode( "\n\n", $sections ) . "\n\n" . "---\n\n" . "## The one rule you must not break\n" . 'You may not write a link, a URL, a web address or a domain name anywhere in your reply. ' . 'To refer to an article, write its placeholder exactly as it appears above, for example [[POST:3]]. ' . 'The placeholder will be turned into a working link for you. ' . 'Write the placeholder where the link belongs in the sentence; do not write the article title next to it, ' . "because the title is inserted with the link. Any address you write will be deleted.\n\n" . "## What the page must contain\n" . "1. An opening that says what this subject is and who the section is for, in three or four paragraphs. No placeholder needed here.\n" . "2. A short guide to reading order: where a beginner should start and what to read next, referring to articles by placeholder.\n" . '3. The body of the page, grouped into h2 sections by theme rather than by date. Every one of the ' . count( $posts ) . ' articles must appear at least once, as its placeholder, with two or three sentences ' . "saying what that article actually covers and who should read it. Base that on the extract given above and do not invent claims.\n" . '4. A short FAQ of four to six questions that someone new to this subject would ask, with answers of two to four sentences. ' . "Refer to articles by placeholder where one answers the question more fully.\n" . "5. A closing paragraph.\n\n" . "## Reply format, JSON only, nothing before or after it\n" . "{\n" . " \"title\": \"the hub page title\",\n" . " \"meta_description\": \"a meta description of at most 120 characters\",\n" . " \"slug\": \"a-short-lowercase-english-slug-with-hyphens\",\n" . " \"content_html\": \"the page body\"\n" . "}\n\n" . "## Rules for content_html\n" . "- WordPress block editor HTML, using wp:paragraph, wp:heading and wp:list comments.\n" . "- h2 and h3 headings only. No h1.\n" . "- No anchor tags, no addresses, no image placeholders, no script or style tags.\n" . "- Do not repeat an article's full text; summarise it.\n\n" . "## Language\n" . "Write everything except the slug in {$language}.\n\n" . 'Reply with the JSON object and nothing else.'; } /** * Replace the placeholders with real links, after stripping anything the * model wrote that could be a link or an address. * * @param string $html Body from the model. * @param array $posts Posts the hub covers. * @param array $linked Filled with the post ids that ended up linked. * @return string */ private static function apply_real_links( $html, $posts, &$linked ) { $linked = array(); // 1. Remove any anchor the model wrote, keeping the words inside it. $html = (string) preg_replace( '#]*>(.*?)#isu', '$1', $html ); // 2. Remove any leftover opening or closing anchor tag. $html = (string) preg_replace( '#]*>#i', '', $html ); // 3. Remove anything that looks like a web address, wherever it is. $html = (string) preg_replace( '#\bhttps?://[^\s<>"\'\]\)]+#iu', '', $html ); $html = (string) preg_replace( '#\bwww\.[^\s<>"\'\]\)]+#iu', '', $html ); // 4. Only now put real links in, built from the permalinks we read // from the database a moment ago. $html = (string) preg_replace_callback( self::TOKEN, function ( $matches ) use ( $posts, &$linked ) { $index = (int) $matches[1] - 1; if ( ! isset( $posts[ $index ] ) ) { return ''; } $post = $posts[ $index ]; $linked[] = (int) $post['id']; return '' . esc_html( $post['title'] ) . ''; }, $html ); // 5. Remove any placeholder shaped thing the model made up. $html = (string) preg_replace( '/\[\[[^\]]{0,60}\]\]/', '', $html ); // Tidy the double spaces the removals leave behind. $html = (string) preg_replace( '/[ \t]{2,}/', ' ', $html ); return $html; } /** * Make sure every post in the category is reachable from the hub page. * * If the model skipped one, it is added in a closing list, so the hub is * always a complete index of the category. * * @param string $html Body so far. * @param array $posts Posts the hub covers. * @param array $linked Post ids already linked. * @return string */ private static function append_missing( $html, $posts, $linked ) { $missing = array(); foreach ( $posts as $post ) { if ( ! in_array( (int) $post['id'], array_map( 'intval', $linked ), true ) ) { $missing[] = $post; } } if ( empty( $missing ) ) { return $html; } $items = ''; foreach ( $missing as $post ) { $items .= '
  • ' . esc_html( $post['title'] ) . '
  • ' . "\n"; } return $html . "\n\n

    " . esc_html__( 'Also in this category', 'acs-article-generator' ) . "

    \n\n" . "\n\n\n"; } }