post_type || 'publish' !== $post->post_status || '' !== $post->post_password ) { return false; } if ( 'excluded' === get_post_meta( $post->ID, '_spfaq_sync', true ) ) { return false; } return (bool) apply_filters( 'spfaq_knowledge_eligible', true, $post ); } /** * Stable ID for a FAQ, created on first use. Survives edits, slug changes and exports. * * @param int $post_id Post ID. * @return string */ public static function uid( $post_id ) { $uid = (string) get_post_meta( $post_id, '_spfaq_uid', true ); if ( '' === $uid ) { $uid = wp_generate_uuid4(); update_post_meta( $post_id, '_spfaq_uid', $uid ); } return $uid; } /** * Resolve a stable ID (or numeric ID) to a post. * * @param string $id UID or numeric ID. * @return WP_Post|null */ public static function resolve( $id ) { $id = sanitize_text_field( (string) $id ); if ( ctype_digit( $id ) ) { $post = get_post( (int) $id ); return ( $post && 'spfaq_faq' === $post->post_type ) ? $post : null; } if ( ! preg_match( '/^[0-9a-f-]{36}$/i', $id ) ) { return null; } $found = get_posts( array( 'post_type' => 'spfaq_faq', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'meta_key' => '_spfaq_uid', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- single indexed lookup 'meta_value' => strtolower( $id ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); return $found ? get_post( $found[0] ) : null; } /** * Plain-text answer. * * @param WP_Post $post Post. * @return string */ private static function answer_text( $post ) { $text = wp_strip_all_tags( strip_shortcodes( $post->post_content ), true ); return trim( preg_replace( '/\s+/u', ' ', html_entity_decode( $text, ENT_QUOTES, 'UTF-8' ) ) ); } /** * Terms of a taxonomy as small arrays. * * @param int $post_id Post ID. * @param string $taxonomy Taxonomy. * @return array */ private static function terms( $post_id, $taxonomy ) { $terms = get_the_terms( $post_id, $taxonomy ); $out = array(); if ( is_array( $terms ) ) { foreach ( $terms as $t ) { $out[] = array( 'id' => (int) $t->term_id, 'slug' => $t->slug, 'name' => wp_strip_all_tags( $t->name ), ); } } return $out; } /** * Content hash. Changes whenever the question, answer, URL or topics change. * * @param WP_Post $post Post. * @return string */ public static function content_hash( $post ) { $cats = wp_list_pluck( self::terms( $post->ID, 'spfaq_category' ), 'slug' ); $tags = wp_list_pluck( self::terms( $post->ID, 'spfaq_tag' ), 'slug' ); sort( $cats ); sort( $tags ); return hash( 'sha256', wp_json_encode( array( wp_strip_all_tags( $post->post_title ), self::answer_text( $post ), get_permalink( $post ), $cats, $tags, ) ) ); } /** * Sync status for a FAQ: excluded, pending, indexed or stale. * * @param WP_Post $post Post. * @param string $hash Current content hash. * @return string */ public static function sync_status( $post, $hash ) { if ( 'excluded' === get_post_meta( $post->ID, '_spfaq_sync', true ) ) { return 'excluded'; } $ack = (string) get_post_meta( $post->ID, '_spfaq_sync_ack', true ); if ( '' === $ack ) { return 'pending'; } return hash_equals( $ack, $hash ) ? 'indexed' : 'stale'; } /** * Public representation of a FAQ. * * @param WP_Post $post Post. * @return array */ public static function item( $post ) { $hash = self::content_hash( $post ); $item = array( 'id' => self::uid( $post->ID ), 'wp_id' => (int) $post->ID, 'title' => wp_strip_all_tags( $post->post_title ), 'answer_text' => self::answer_text( $post ), 'answer_html' => wp_kses_post( wpautop( strip_shortcodes( $post->post_content ) ) ), 'url' => get_permalink( $post ), 'anchor' => 'faq-' . $post->post_name, 'updated_gmt' => self::iso( $post->post_modified_gmt ), 'published_gmt' => self::iso( $post->post_date_gmt ), 'content_hash' => $hash, 'language' => get_locale(), 'categories' => self::terms( $post->ID, 'spfaq_category' ), 'groups' => self::terms( $post->ID, 'spfaq_group' ), 'tags' => self::terms( $post->ID, 'spfaq_tag' ), 'votes' => array( 'yes' => (int) get_post_meta( $post->ID, 'spfaq_yes', true ), 'no' => (int) get_post_meta( $post->ID, 'spfaq_no', true ), ), 'sync' => array( 'status' => self::sync_status( $post, $hash ), 'indexed_at' => (string) get_post_meta( $post->ID, '_spfaq_sync_ack_at', true ), ), ); /** * Filter the knowledge item before it is returned. * * @param array $item Item. * @param WP_Post $post Post. */ return apply_filters( 'spfaq_knowledge_item', $item, $post ); } /** * GMT MySQL date to ISO 8601 with an explicit Z, so clients never guess the zone. * * @param string $mysql_gmt Date as Y-m-d H:i:s in GMT. * @return string */ public static function iso( $mysql_gmt ) { $ts = strtotime( $mysql_gmt . ' UTC' ); return $ts ? gmdate( 'Y-m-d\TH:i:s\Z', $ts ) : ''; } /** * Parse an ISO 8601 date to a GMT MySQL string, or false. * * @param string $value Date. * @return string|false */ public static function parse_date( $value ) { $ts = strtotime( (string) $value ); return $ts ? gmdate( 'Y-m-d H:i:s', $ts ) : false; } /** * Base query arguments for eligible FAQs. * * @return array */ public static function base_args() { return array( 'post_type' => 'spfaq_faq', 'post_status' => 'publish', 'has_password' => false, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- needed to honour the per-FAQ exclusion flag 'relation' => 'OR', array( 'key' => '_spfaq_sync', 'compare' => 'NOT EXISTS', ), array( 'key' => '_spfaq_sync', 'value' => 'excluded', 'compare' => '!=', ), ), ); } }