*/ private array $sources; /** * @var TargetKeywordRepository */ private TargetKeywordRepository $repository; /** * @var WordTokenizer */ private WordTokenizer $tokenizer; /** * Whether the most recent {@see sync()} had a write refused. * * @var bool */ private bool $last_sync_failed = false; /** * @since 1.0.0 * * @param array $sources Registered sources. * @param TargetKeywordRepository $repository Persistence layer. * @param WordTokenizer $tokenizer For stemming phrases. */ public function __construct( array $sources, TargetKeywordRepository $repository, WordTokenizer $tokenizer ) { $this->sources = array_values( array_filter( $sources, static fn( $s ) => $s instanceof KeywordSourceInterface ) ); $this->repository = $repository; $this->tokenizer = $tokenizer; } /** * Returns the merged target keywords for a post or term. * * Reads from the database only — does not re-import from SEO plugins. * Call {@see sync()} first when you need fresh data. * * @since 1.0.0 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * * @return array */ public function get_keywords_for( int $post_id, string $post_type = 'post' ): array { return $this->repository->find_for( $post_id, $post_type ); } /** * Returns the stemmed keyword phrases for a post, ready for the engine. * * Convenience wrapper used by `WpPostCandidateProvider` to build the * `target_keywords` context array without hydrating full models. * * @since 1.0.0 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * * @return array Pre-stemmed, space-joined keyword phrases. */ public function get_stemmed_phrases_for( int $post_id, string $post_type = 'post' ): array { $models = $this->repository->find_for( $post_id, $post_type ); $out = array(); foreach ( $models as $model ) { if ( '' !== $model->stemmed ) { $out[] = $model->stemmed; } } return $out; } /** * Imports keywords from all available sources and persists them. * * Existing rows for each source are replaced so stale keywords from * a previous import don't linger. Manual keywords are never touched * by this method — they are managed by the admin UI directly. * * @since 1.0.0 * @since 1.18.1 Records refused writes; see {@see last_sync_failed()}. * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * * @return int Total number of keywords persisted across all sources. */ public function sync( int $post_id, string $post_type = 'post' ): int { $this->last_sync_failed = false; if ( $post_id <= 0 ) { return 0; } $total = 0; foreach ( $this->sources as $source ) { if ( ! $source->is_available() ) { continue; } // Skip the manual source — it is managed by the admin UI. if ( 'manual' === $source->get_id() ) { continue; } $phrases = $source->get_keywords_for( $post_id, $post_type ); /** * Filters the raw keyword phrases returned by a source before * they are stemmed and persisted. * * @since 1.0.0 * * @param array $phrases Raw phrases. * @param int $post_id Post or term identifier. * @param string $post_type Item type. * @param string $source_id Source identifier. */ $phrases = (array) apply_filters( 'postlinkly_target_keywords_' . $source->get_id(), $phrases, $post_id, $post_type, $source->get_id() ); if ( array() === $phrases ) { // Clear stale rows even when the source now returns nothing. $this->repository->delete_for_source( $post_id, $post_type, $source->get_id() ); continue; } // Replace stale rows for this source. $this->repository->delete_for_source( $post_id, $post_type, $source->get_id() ); foreach ( $phrases as $phrase ) { if ( ! is_string( $phrase ) || '' === trim( $phrase ) ) { continue; } $model = $this->build_model( $post_id, $post_type, $source->get_id(), trim( $phrase ) ); if ( $this->repository->upsert( $model ) ) { ++$total; } else { $this->last_sync_failed = true; } } } return $total; } /** * Whether the most recent {@see sync()} had a write refused by the * database. * * The count sync() returns cannot say this on its own: zero means both * "this post has no focus keywords" and "none could be saved", and only * the second is worth retrying. * * @since 1.18.1 * * @return bool */ public function last_sync_failed(): bool { return $this->last_sync_failed; } /** * Whether any source {@see sync()} imports from is active — that is, an * SEO plugin whose keywords can be read. * * The manual source does not count: sync() never imports it. * * @since 1.18.1 * * @return bool */ public function has_importable_sources(): bool { foreach ( $this->sources as $source ) { if ( 'manual' !== $source->get_id() && $source->is_available() ) { return true; } } return false; } /** * Removes every stored keyword for a post or term, from all sources. * * @since 1.18.1 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * * @return int Number of rows deleted. */ public function forget( int $post_id, string $post_type = 'post' ): int { return $this->repository->delete_all_for( $post_id, $post_type ); } /** * Saves a manually-entered keyword phrase. * * @since 1.0.0 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * @param string $phrase Raw phrase as entered by the owner. * * @return bool True on success. */ public function save_manual( int $post_id, string $post_type, string $phrase ): bool { if ( $post_id <= 0 || '' === trim( $phrase ) ) { return false; } $model = $this->build_model( $post_id, $post_type, 'manual', trim( $phrase ) ); return $this->repository->upsert( $model ); } /** * Deletes a manually-entered keyword phrase. * * @since 1.0.0 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. Default `post`. * * @return int Number of rows deleted. */ public function delete_manual( int $post_id, string $post_type = 'post' ): int { return $this->repository->delete_for_source( $post_id, $post_type, 'manual' ); } /** * Builds a KeywordModel with stemmed and normalised forms. * * @since 1.0.0 * * @param int $post_id Post or term identifier. * @param string $post_type Item type. * @param string $source Source identifier. * @param string $phrase Raw phrase. * * @return KeywordModel */ private function build_model( int $post_id, string $post_type, string $source, string $phrase ): KeywordModel { $stemmed = $this->stem_phrase( $phrase ); $normalized = $this->stem_phrase( WordTokenizer::remove_accents( $phrase ) ); return new KeywordModel( $post_id, $post_type, $source, $phrase, $stemmed, $normalized ); } /** * Returns the space-joined stemmed form of a phrase. * * @since 1.0.0 * * @param string $phrase Raw phrase. * * @return string Stemmed, space-joined phrase. */ private function stem_phrase( string $phrase ): string { $words = $this->tokenizer->stem_words( $phrase ); return implode( ' ', $words ); } }