post_mime_type || 'inherit' !== $post->post_status ) { return false; } if ( '1' === (string) get_post_meta( $post->ID, self::META_EXCLUDE, true ) ) { return false; } if ( $post->post_parent ) { $parent = get_post( $post->post_parent ); if ( $parent && ! self::status_ok( $parent ) ) { return false; } } $file = get_attached_file( $post->ID ); return $file && is_readable( $file ) && filesize( $file ) <= self::MAX_PDF_BYTES; } /** * Queues a sync when a PDF is uploaded or edited. * * @param int $post_id Attachment id. */ public static function on_attachment( $post_id ) { $post = get_post( $post_id ); if ( ! Keys::is_connected() || ! $post || 'application/pdf' !== $post->post_mime_type ) { return; } if ( self::is_eligible( $post ) ) { self::queue( self::HOOK_SYNC, $post_id ); } elseif ( get_post_meta( $post_id, self::META_HASH, true ) ) { self::queue( self::HOOK_DELETE, $post_id ); } } /** * Whether a post may be indexed: published, public, not password * protected, a selected type, and not marked noindex in Yoast or Rank Math. * * @param WP_Post|null $post Post. * @return bool */ public static function is_eligible( $post ) { if ( ! $post instanceof WP_Post ) { return false; } if ( 'attachment' === $post->post_type ) { return self::is_eligible_pdf( $post ); } if ( ! self::status_ok( $post ) ) { return false; } if ( ! in_array( $post->post_type, self::post_types(), true ) || ! is_post_type_viewable( $post->post_type ) ) { return false; } // The posts page ("Blog", "News") shows the post list, never its own // content; its posts are indexed one by one. if ( 'page' === $post->post_type && (int) get_option( 'page_for_posts' ) === $post->ID ) { return false; } if ( '1' === (string) get_post_meta( $post->ID, self::META_EXCLUDE, true ) ) { return false; } $excluded = self::excluded_terms(); if ( $excluded && is_object_in_taxonomy( $post->post_type, 'category' ) && has_category( $excluded, $post ) ) { return false; } if ( '1' === (string) get_post_meta( $post->ID, '_yoast_wpseo_meta-robots-noindex', true ) ) { return false; } $rank_math = get_post_meta( $post->ID, 'rank_math_robots', true ); if ( is_array( $rank_math ) && in_array( 'noindex', $rank_math, true ) ) { return false; } return true; } /** * Published and public, or private / password-protected when members-only * content includes them (sent as members-only, served to members only). * * @param WP_Post $post Post. * @return bool */ public static function status_ok( $post ) { if ( 'publish' === $post->post_status && '' === $post->post_password ) { return true; } return Access::include_private() && in_array( $post->post_status, array( 'publish', 'private' ), true ); } /** * Queues a sync or a delete when a post changes status or is updated. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post. */ public static function on_transition( $new_status, $old_status, $post ) { if ( ! Keys::is_connected() || wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return; } if ( self::is_eligible( $post ) ) { self::queue( self::HOOK_SYNC, $post->ID ); } elseif ( 'publish' === $old_status || get_post_meta( $post->ID, self::META_HASH, true ) ) { self::queue( self::HOOK_DELETE, $post->ID ); } } /** * Queues a delete when a post is deleted permanently. * * @param int $post_id Post id. */ public static function on_delete( $post_id ) { if ( Keys::is_connected() && get_post_meta( $post_id, self::META_HASH, true ) ) { self::queue( self::HOOK_DELETE, $post_id ); } } /** * Queues one background job per post, without duplicates. * * @param string $hook Action hook. * @param int $post_id Post id. */ public static function queue( $hook, $post_id ) { $args = array( (int) $post_id ); if ( ! as_has_scheduled_action( $hook, $args, self::GROUP ) ) { as_enqueue_async_action( $hook, $args, self::GROUP ); } } /** * Plain text with headings marked "## ", for passage splitting and * citations. * * @param WP_Post $post Post. * @return string */ public static function text( WP_Post $post ) { $html = apply_filters( 'the_content', $post->post_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter. $html = preg_replace( '#<(script|style|noscript)\b[^>]*>.*?#is', '', (string) $html ); $html = preg_replace_callback( '#]*>(.*?)#is', static function ( $m ) { return "\n\n" . str_repeat( '#', (int) $m[1] ) . ' ' . trim( wp_strip_all_tags( $m[2] ) ) . "\n\n"; }, $html ); $html = preg_replace( '#<(br|/p|/li|/div|/tr|/blockquote|/ul|/ol|/table|/figure)\b[^>]*>#i', "\n", $html ); $text = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $text = preg_replace( "/[ \t]+/", ' ', $text ); $text = preg_replace( "/\n\s*\n+/", "\n\n", $text ); return mb_substr( trim( $text ), 0, self::MAX_CONTENT ); } /** * The document sent for a post, with its content hash. * * @param WP_Post $post Post. * @return array */ public static function document( WP_Post $post ) { $title = html_entity_decode( self::plain_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $url = get_permalink( $post ); $content = self::text( $post ); // Category slugs, for assistants scoped to part of the site. $terms = is_object_in_taxonomy( $post->post_type, 'category' ) ? wp_get_post_terms( $post->ID, 'category', array( 'fields' => 'slugs' ) ) : array(); $terms = is_array( $terms ) ? array_values( array_slice( $terms, 0, 50 ) ) : array(); $vis = Access::visibility_for( $post ); // Visibility is part of the hash, so a changed mark is sent again. $seal = 'public' === $vis['visibility'] ? '' : "\n" . $vis['visibility'] . ':' . implode( ',', $vis['roles'] ); return array( 'externalId' => (string) $post->ID, 'postType' => $post->post_type, 'url' => $url, 'title' => $title, 'content' => $content, 'terms' => $terms, 'visibility' => $vis['visibility'], 'roles' => $vis['roles'], 'contentHash' => hash( 'sha256', $title . "\n" . $url . "\n" . $content . "\n" . implode( ',', $terms ) . $seal ), ); } /** * The title without WordPress's "Private:" / "Protected:" prefix, which * members-only items would otherwise carry into search results. * * @param WP_Post $post Post. * @return string */ public static function plain_title( WP_Post $post ) { $plain = static function () { return '%s'; }; add_filter( 'private_title_format', $plain ); add_filter( 'protected_title_format', $plain ); $title = get_the_title( $post ); remove_filter( 'private_title_format', $plain ); remove_filter( 'protected_title_format', $plain ); return $title; } /** * Hash of a post's current document, reusing the stored one when the post * has not changed since it was last computed. * * @param WP_Post $post Post. * @return string */ public static function current_hash( WP_Post $post ) { $stored = get_post_meta( $post->ID, self::META_HASH, true ); if ( $stored && get_post_meta( $post->ID, self::META_MODIFIED, true ) === self::modified( $post ) ) { return $stored; } return 'attachment' === $post->post_type ? self::pdf_hash( $post ) : self::document( $post )['contentHash']; } /** * When a post last changed; for PDFs also when the file did. * * @param WP_Post $post Post. * @return string */ public static function modified( WP_Post $post ) { $access = '|a' . Access::version(); if ( 'attachment' !== $post->post_type ) { return $post->post_modified_gmt . $access; } $file = get_attached_file( $post->ID ); return $post->post_modified_gmt . '|' . ( $file && file_exists( $file ) ? (string) filemtime( $file ) . ':' . (string) filesize( $file ) : '' ) . $access; } /** * Hash of a PDF: the file, its title and its address. * * @param WP_Post $post Attachment. * @return string */ public static function pdf_hash( WP_Post $post ) { $file = get_attached_file( $post->ID ); $vis = Access::visibility_for( $post ); $seal = 'public' === $vis['visibility'] ? '' : "\n" . $vis['visibility'] . ':' . implode( ',', $vis['roles'] ); return hash( 'sha256', ( $file ? (string) hash_file( 'sha256', $file ) : '' ) . "\n" . self::plain_title( $post ) . "\n" . wp_get_attachment_url( $post->ID ) . $seal ); } /** * Uploads a PDF. The service reads its text and does not keep the file. * * @param WP_Post $post Attachment. * @throws \RuntimeException When the service cannot be reached. */ public static function sync_pdf( WP_Post $post ) { $file = get_attached_file( $post->ID ); $bytes = $file ? file_get_contents( $file ) : false; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local upload. if ( false === $bytes ) { return; } $hash = self::pdf_hash( $post ); $vis = Access::visibility_for( $post ); $query = http_build_query( array( 'externalId' => (string) $post->ID, 'title' => mb_substr( html_entity_decode( self::plain_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ), 0, 500 ), 'url' => wp_get_attachment_url( $post->ID ), 'hash' => $hash, 'visibility' => $vis['visibility'], 'roles' => implode( ',', $vis['roles'] ), ), '', '&', PHP_QUERY_RFC3986 ); $result = Api::signed( 'POST', '/v1/plugin/documents/pdf?' . $query, $bytes, 60, 'application/pdf' ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( esc_html( $result->get_error_message() ) ); } $status = $result['status'] ?? 'unknown'; if ( in_array( $status, array( 'stored', 'unchanged', 'no_text' ), true ) ) { update_post_meta( $post->ID, self::META_HASH, $hash ); update_post_meta( $post->ID, self::META_MODIFIED, self::modified( $post ) ); } if ( in_array( $status, array( 'stored', 'unchanged' ), true ) ) { delete_post_meta( $post->ID, self::META_PDF_ISSUE ); } else { update_post_meta( $post->ID, self::META_PDF_ISSUE, sanitize_key( $status ) ); } } /** * Background job: sends one post, or removes it if it is no longer eligible. * * @param int $post_id Post id. * @throws \RuntimeException When the service cannot be reached, so Action Scheduler records the failure. */ public static function sync_post( $post_id ) { $post = get_post( $post_id ); if ( ! self::is_eligible( $post ) ) { self::delete_post( $post_id ); return; } if ( 'attachment' === $post->post_type ) { self::sync_pdf( $post ); return; } $doc = self::document( $post ); $result = Api::signed( 'POST', '/v1/plugin/documents', wp_json_encode( array( 'documents' => array( $doc ) ) ) ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( esc_html( $result->get_error_message() ) ); } $status = $result['results'][0]['status'] ?? 'unknown'; if ( in_array( $status, array( 'stored', 'unchanged', 'no_text' ), true ) ) { update_post_meta( $post->ID, self::META_HASH, $doc['contentHash'] ); update_post_meta( $post->ID, self::META_MODIFIED, self::modified( $post ) ); } } /** * Background job: removes a post from the index. * * @param int $post_id Post id. * @throws \RuntimeException When the service cannot be reached. */ public static function delete_post( $post_id ) { $result = Api::signed( 'POST', '/v1/plugin/documents/delete', wp_json_encode( array( 'externalIds' => array( (string) $post_id ) ) ) ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( esc_html( $result->get_error_message() ) ); } delete_post_meta( $post_id, self::META_HASH ); delete_post_meta( $post_id, self::META_MODIFIED ); } /** * Background job (daily, and on demand): sends the list of eligible items * and queues whatever the service is missing. * * @return array{needed:int,removed:int} * @throws \RuntimeException When the service cannot be reached. */ public static function reconcile() { $items = array(); $paged = 1; do { $ids = get_posts( array( 'post_type' => self::post_types(), 'post_status' => Access::include_private() ? array( 'publish', 'private' ) : 'publish', 'has_password' => Access::include_private() ? null : false, 'posts_per_page' => self::BATCH, 'paged' => $paged, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $ids as $id ) { $post = get_post( $id ); if ( self::is_eligible( $post ) ) { $items[] = array( 'externalId' => (string) $id, 'contentHash' => self::current_hash( $post ), 'postType' => $post->post_type, ); } } ++$paged; $full_batch = count( $ids ) === self::BATCH; } while ( $full_batch ); if ( self::pdfs_enabled() ) { $paged = 1; do { $ids = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'post_mime_type' => 'application/pdf', 'posts_per_page' => self::BATCH, 'paged' => $paged, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', ) ); foreach ( $ids as $id ) { $post = get_post( $id ); if ( self::is_eligible( $post ) ) { $items[] = array( 'externalId' => (string) $id, 'contentHash' => self::current_hash( $post ), 'postType' => $post->post_type, ); } } ++$paged; $full_batch = count( $ids ) === self::BATCH; } while ( $full_batch ); } $result = Api::signed( 'POST', '/v1/plugin/documents/manifest', wp_json_encode( array( 'items' => $items ) ) ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( esc_html( $result->get_error_message() ) ); } $needed = isset( $result['needed'] ) && is_array( $result['needed'] ) ? $result['needed'] : array(); foreach ( $needed as $id ) { delete_post_meta( (int) $id, self::META_HASH ); self::queue( self::HOOK_SYNC, (int) $id ); } update_option( 'askmypages_last_reconcile', time(), false ); return array( 'needed' => count( $needed ), 'removed' => (int) ( $result['removed'] ?? 0 ), ); } /** * Starts syncing after a successful connection: one reconciliation now, * then daily. */ public static function start() { // Daily first, so the check below is not fooled by the immediate run. if ( ! as_next_scheduled_action( self::HOOK_RECONCILE, array(), self::GROUP ) ) { as_schedule_recurring_action( time() + HOUR_IN_SECONDS, DAY_IN_SECONDS, self::HOOK_RECONCILE, array(), self::GROUP ); } as_enqueue_async_action( self::HOOK_RECONCILE, array(), self::GROUP ); } /** * Stops all sync jobs and forgets local sync state. */ public static function stop() { as_unschedule_all_actions( '', array(), self::GROUP ); delete_post_meta_by_key( self::META_HASH ); delete_post_meta_by_key( self::META_MODIFIED ); delete_post_meta_by_key( self::META_PDF_ISSUE ); delete_option( 'askmypages_last_reconcile' ); } /** * Item counts from the service, cached for a minute. * * @return array|null */ public static function status() { $cached = get_transient( 'askmypages_status' ); if ( is_array( $cached ) ) { return $cached; } $result = Api::signed( 'GET', '/v1/plugin/documents/status' ); if ( is_wp_error( $result ) ) { return null; } set_transient( 'askmypages_status', $result, MINUTE_IN_SECONDS ); if ( isset( $result['customPostTypes'] ) ) { $allowed = $result['customPostTypes'] ? '1' : '0'; if ( get_option( self::OPT_CUSTOM_TYPES, '0' ) !== $allowed ) { update_option( self::OPT_CUSTOM_TYPES, $allowed, false ); // The plan changed: bring the index in line. as_enqueue_async_action( self::HOOK_RECONCILE, array(), self::GROUP ); } } return $result; } }