*/ private array $post_types; /** * @var array */ private array $post_statuses; /** * @since 1.10.0 * * @param WordTokenizer $tokenizer Tokenizer used to build the * post's stem set. Must be * configured identically to * the one {@see WpPostCandidateProvider} * uses, or the persisted map * stops matching what the * engine would derive. * @param StemFrequencyRepositoryInterface $repository Repository to apply deltas to. * @param array $post_types Configured candidate post types. * @param array $post_statuses Configured candidate post statuses. */ public function __construct( WordTokenizer $tokenizer, StemFrequencyRepositoryInterface $repository, array $post_types, array $post_statuses ) { $this->tokenizer = $tokenizer; $this->repository = $repository; $this->post_types = $post_types; $this->post_statuses = $post_statuses; } /** * @since 1.10.0 * * @return array */ public function get_hooks(): array { return array( // save_post fires with the post's CURRENT status, including // trashed (wp_trash_post() saves the post with post_status = // 'trash') -- so this one hook covers publish, edit, unpublish, // and trash without needing separate transition hooks. array( 'save_post', array( $this, 'on_post_save' ), 20, 3 ), // Permanent deletion (bypassing trash, or trash emptied) does not // fire save_post again, so it needs its own cleanup. array( 'delete_post', array( $this, 'on_post_delete' ), 10, 1 ), ); } /** * Syncs one post's contribution to the corpus frequency map after save. * * @since 1.10.0 * * @param int $post_id Post id. * @param WP_Post|mixed $post Post object. * @param bool $update Whether this is an update. Unused but * required by the save_post hook signature. * * @return void */ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $update required by save_post hook signature. public function on_post_save( int $post_id, $post, bool $update ): void { if ( ! $post instanceof WP_Post ) { return; } $qualifies_by_type_and_status = in_array( $post->post_type, $this->post_types, true ) && in_array( $post->post_status, $this->post_statuses, true ); $new_stems = $qualifies_by_type_and_status ? CandidateWordMapBuilder::build( $post->post_title, $post->post_content, $this->tokenizer ) : array(); $this->sync( $post_id, $new_stems ); } /** * Removes a permanently-deleted post's contribution, if any. * * @since 1.10.0 * * @param int $post_id Post id being deleted. * * @return void */ public function on_post_delete( int $post_id ): void { $this->sync( $post_id, array() ); } /** * Diffs a post's new stem set against its previously indexed one and * applies the delta. * * A post only counts toward the corpus (both `doc_count` and its * stems' `doc_count`) when it currently has at least one meaningful * word AND qualifies by type/status -- mirroring * {@see WpPostCandidateProvider}'s own "empty word map => not a * candidate" rule. `$new_stems` is already `[]` when either condition * fails, so this method only has to compare "was it counted before" * against "is it counted now". * * @since 1.10.0 * * @param int $post_id Post id. * @param array $new_stems Post's current stem set, or [] * when it no longer qualifies. * * @return void */ private function sync( int $post_id, array $new_stems ): void { $meta = get_post_meta( $post_id, self::META_KEY, true ); $previous_stems = is_array( $meta ) ? $meta : array(); $was_indexed = is_array( $meta ) && array() !== $meta; $is_qualifying = array() !== $new_stems; if ( ! $was_indexed && ! $is_qualifying ) { // Never indexed and still doesn't qualify -- nothing changed. return; } $stem_deltas = array(); foreach ( $new_stems as $stem => $_count ) { if ( ! isset( $previous_stems[ $stem ] ) ) { $stem_deltas[ $stem ] = ( $stem_deltas[ $stem ] ?? 0 ) + 1; } } foreach ( $previous_stems as $stem => $_count ) { if ( ! isset( $new_stems[ $stem ] ) ) { $stem_deltas[ $stem ] = ( $stem_deltas[ $stem ] ?? 0 ) - 1; } } $doc_delta = 0; if ( $was_indexed && ! $is_qualifying ) { $doc_delta = -1; } elseif ( ! $was_indexed && $is_qualifying ) { $doc_delta = 1; } // Else: already indexed and still qualifying -- an edit changes // which stems are present, not whether the post itself counts. $applied = true; if ( array() !== $stem_deltas || 0 !== $doc_delta ) { $applied = $this->repository->apply_delta( $stem_deltas, $doc_delta ); } if ( ! $applied ) { // The repository write failed (e.g. the table did not exist yet // -- see plan 035). Do not record this post as indexed: leaving // the previous meta (or no meta) in place means the next save // retries the same delta instead of sync() concluding nothing // changed and silently skipping it forever. A full Tools-screen // rebuild also remains a correct recovery path regardless of // this meta's state. Logger::warning( 'Stem-frequency delta not applied for post %d -- leaving indexed-state meta unchanged so the next save retries it.', array( $post_id ) ); return; } if ( $is_qualifying ) { update_post_meta( $post_id, self::META_KEY, $new_stems ); } elseif ( $was_indexed ) { delete_post_meta( $post_id, self::META_KEY ); } } }