hook_registrar = new HookRegistrar(); } /** * Returns the singleton plugin instance. * * @since 1.0.0 * * @return Plugin */ public static function instance(): Plugin { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Boots the plugin. * * Idempotent: calling it twice does nothing the second time. * Lifecycle hooks register immediately. Service hooks register on * `plugins_loaded` so other plugins are already loaded. * * @since 1.0.0 * * @return void */ public function boot(): void { if ( $this->booted ) { return; } $this->booted = true; $this->register_lifecycle_hooks(); add_action( 'plugins_loaded', array( $this, 'on_plugins_loaded' ) ); } /** * Registers activation, deactivation, and uninstall hooks. * * `uninstall.php` handles uninstall directly, so we don't register it here. * * @since 1.0.0 * * @return void */ private function register_lifecycle_hooks(): void { register_activation_hook( POSTLINKLY_PLUGIN_FILE, array( Activator::class, 'run' ) ); register_deactivation_hook( POSTLINKLY_PLUGIN_FILE, array( Deactivator::class, 'run' ) ); } /** * Runs on `plugins_loaded`. * * Loads translations, builds the service list, and wires hooks. * * @since 1.0.0 * @since 1.11.0 Runs pending migrations first, so services that depend * on schema added by a migration never register hooks before * that schema exists. * @since 1.11.0 Runs the one-time `prevent_cannibalization` default * correction alongside the migration check. * @since 1.18.0 Fires `postlinkly_loaded` once every service is wired. * @hook plugins_loaded * * @return void */ public function on_plugins_loaded(): void { $this->maybe_run_pending_migrations(); $this->maybe_correct_cannibalization_default(); $this->hook_registrar->register_all( $this->register_services() ); /** * Fires after Postlinkly has registered all of its services. * * Add-ons use it to confirm the plugin is active. Anything that must * change what Postlinkly registers (pages, AJAX handlers, index * stages, enhancers) has to be hooked before this point — those * filters have already run. * * @since 1.18.0 * * @param Plugin $plugin The plugin instance. */ do_action( 'postlinkly_loaded', $this ); } /** * Runs pending migrations when the plugin has been updated since the * last request, without waiting for reactivation. * * WordPress only fires `register_activation_hook` on a fresh * activation, never on an update — so a plugin update alone never runs * newly-added migrations. This is what let Migration_005 (the * stem-frequency table) go uncreated on any site that updated straight * from a pre-1.10.0 version without deactivating and reactivating, * which is the normal update path for virtually every WordPress site. * * @since 1.11.0 * * @return void */ private function maybe_run_pending_migrations(): void { $installed = get_option( self::INSTALLED_VERSION_OPTION, '' ); if ( POSTLINKLY_VERSION === $installed ) { return; } Activator::run(); $this->clear_legacy_index_state(); update_option( self::INSTALLED_VERSION_OPTION, POSTLINKLY_VERSION, true ); } /** * Removes bookkeeping left by the retired batched scan tools. * * Those tools tracked their progress with tokens, offsets, and a staged * id list that describe a model no longer in use. Leaving them behind * risks a future reader trusting half of one, which is exactly how a job * ends up in a state nothing can advance — so they are deleted outright * rather than migrated. * * @since 1.14.0 * * @return void */ private function clear_legacy_index_state(): void { foreach ( array( 'postlinkly_rescan_state', 'postlinkly_rescan_ids' ) as $option ) { delete_option( $option ); } foreach ( array( 'postlinkly_rescan_token', 'postlinkly_stem_rebuild_token', 'postlinkly_stem_rebuild_staging' ) as $transient ) { delete_transient( $transient ); } } /** * One-time correction for sites that saved settings before the 1.8.0 * default flip (plan 021) and never touched the checkbox since. * * `SuggestionSettings::from_array()`'s `?? true` fallback only applies * when the stored option has no `prevent_cannibalization` key at all — * correct for a fresh install, but a site with settings saved before * the flip has the key present with the old value `false`, so the * fallback never fires and the setting silently diverges from what a * new install gets. This runs once, ever, per site: if the stored * value is `false`, it is corrected to `true` and the marker is set * so this never runs again — including for a site that deliberately * unchecks the box afterward, which must stay `false` from then on. * * @since 1.11.0 * * @return void */ private function maybe_correct_cannibalization_default(): void { if ( get_option( self::CANNIBALIZATION_DEFAULT_CORRECTED_OPTION, false ) ) { return; } $settings = new \Postlinkly\Settings\Settings(); $suggestion = $settings->suggestion(); if ( ! $suggestion->prevent_cannibalization ) { $data = $suggestion->to_array(); $data['prevent_cannibalization'] = true; $settings->save_section( 'suggestion', $data ); } update_option( self::CANNIBALIZATION_DEFAULT_CORRECTED_OPTION, true, true ); } /** * Builds every service and returns the ones whose hooks get registered: * the admin layer (menu, assets, metabox, AJAX, notices), the REST * endpoint, the indexing job, and the stem-frequency indexer. * * @since 1.0.0 * @since 1.10.0 Wires the stem-frequency repository, its incremental * post-save/delete indexer, and its Tools-screen rebuild handler. * @since 1.18.0 Pages, AJAX handlers, index stages, enhancers, and * services pass through `postlinkly_*` filters so an add-on can * extend each list. * @since 1.18.1 Imports focus keywords from SEO plugins, as an index * stage and on every post save. * * @return array */ private function register_services(): array { /** * Filters the admin pages under the Postlinkly menu. * * @since 1.18.0 * * @param array $pages Pages, in menu order. */ $pages = $this->filter_instances( 'postlinkly_admin_pages', array( new DashboardPage(), new ReportPage(), new SettingsPage(), new ToolsPage(), ), AdminPage::class ); $general = ( new \Postlinkly\Settings\Settings() )->general(); $stem_frequency_repository = new \Postlinkly\Suggestion\StemFrequencyRepository(); $stem_frequency_tokenizer = new WordTokenizer( $this->resolve_stemmer( $general ), new IgnoreWords() ); $stem_frequency_indexer = new \Postlinkly\Suggestion\StemFrequencyIndexer( $stem_frequency_tokenizer, $stem_frequency_repository, $general->post_types, $general->post_statuses ); // Focus keywords are stemmed with the same tokenizer as the corpus // map. The engine compares them with sentences stemmed by the // identically configured tokenizer in build_suggestion_engine(); a // keyword stemmed any other way would never match. $target_keyword_manager = new \Postlinkly\Keywords\TargetKeywordManager( $this->keyword_sources(), new \Postlinkly\Keywords\TargetKeywordRepository(), $stem_frequency_tokenizer ); // The unified indexing job. Stage order is a dependency order, not a // preference: suggestions read the stem-frequency map, so it must be // built first. Stages an add-on appends run after these, once the // corpus can already be scored. /** * Filters the stages of the background indexing job. * * Append rather than reorder: the built-in stages must run first. * * @since 1.18.0 * * @param array $stages Stages, in run order. */ // Shared with LinkIndexSyncer below, so the job and the per-save // refresh apply exactly the same rules to the same posts. $links_stage = new \Postlinkly\Indexing\Stage\LinksStage( new \Postlinkly\Indexing\BatchUrlResolver(), $general->post_types, $general->post_statuses ); $index_stages = $this->filter_instances( 'postlinkly_index_stages', array( $links_stage, new \Postlinkly\Indexing\Stage\StemFrequencyStage( $stem_frequency_tokenizer, $stem_frequency_repository, $general->post_types, $general->post_statuses ), new \Postlinkly\Indexing\Stage\TargetKeywordsStage( $target_keyword_manager, $general->post_types, $general->post_statuses ), ), IndexStage::class ); $index_repository = new \Postlinkly\Indexing\JobStateRepository(); $index_lease = new \Postlinkly\Indexing\JobLease( $index_repository ); $index_runner = new \Postlinkly\Indexing\JobRunner( $index_stages, $index_repository, $index_lease ); $index_scheduler = new \Postlinkly\Indexing\JobScheduler( $index_runner, $index_repository, $index_lease ); $index_scheduler->register(); /** * Filters the enhancers that reorder or relabel ranked suggestions. * * @since 1.18.0 * * @param array $enhancers Enhancers. Default none. */ $enhancers = $this->filter_instances( 'postlinkly_suggestion_enhancers', array(), EnhancerInterface::class ); /** * Filters the enhancers that see the whole candidate pool before * anchors are deduplicated. * * @since 1.18.0 * * @param array $pool_enhancers Enhancers. Default none. */ $pool_enhancers = $this->filter_instances( 'postlinkly_suggestion_pool_enhancers', array(), CandidatePoolEnhancerInterface::class ); /** * Filters the suggestion cache context. * * An add-on whose enhancers change the output must return a value * that changes with its own settings; otherwise cached suggestions * from before a settings change are served afterwards. * * @since 1.18.0 * * @param string $context Cache context. Default empty. */ $cache_context = (string) apply_filters( 'postlinkly_suggestion_cache_context', '' ); $engine = $this->build_suggestion_engine( null, $enhancers, $pool_enhancers, $cache_context, $stem_frequency_repository ); /** * Filters the AJAX handlers routed by Postlinkly. * * @since 1.18.0 * * @param array $handlers Handlers. */ $ajax_handlers = $this->filter_instances( 'postlinkly_ajax_handlers', array( new GetSuggestionsHandler( $engine ), new \Postlinkly\Admin\Ajax\IndexJobHandler( $index_runner, $index_scheduler, $index_repository ), new \Postlinkly\Admin\Ajax\DismissSuggestionHandler(), new InsertLinkHandler(), new BulkInsertLinksHandler(), new FlushCacheHandler(), new SaveSettingsHandler(), new RunMigrationsHandler(), new GetPostStatsHandler(), new GetPostLinksHandler(), new GetInboundLinksHandler(), ), AjaxHandler::class ); /** * Filters the services whose hooks Postlinkly registers. * * @since 1.18.0 * * @param array $services Services. */ return $this->filter_instances( 'postlinkly_services', array( new AdminMenu( $pages ), new AssetRegistrar( $pages ), new SuggestionMetaBox(), new AjaxRouter( $ajax_handlers ), new NoticeRenderer(), new RestController( $engine ), new SettingsRenderer( new \Postlinkly\Settings\Settings() ), new PostColumns(), $stem_frequency_indexer, new \Postlinkly\Keywords\TargetKeywordSyncer( $target_keyword_manager, $general->post_types, $general->post_statuses ), new \Postlinkly\Indexing\LinkIndexSyncer( $links_stage ), ), ServiceProvider::class ); } /** * Passes one of the plugin's lists through a filter, keeping only * entries of the expected type. * * An add-on extends the plugin by appending to these lists. Anything it * returns that is not the expected type is dropped rather than trusted, * so a broken add-on cannot make the plugin fatal on a method call; a * filter that returns no array at all leaves the plugin's own list in * place. * * @since 1.18.0 * * @template T of object * * @param string $hook Filter name. * @param array $items The plugin's own list. * @param class-string $type Class or interface every entry must satisfy. * * @return array */ private function filter_instances( string $hook, array $items, string $type ): array { $filtered = apply_filters( $hook, $items ); if ( ! is_array( $filtered ) ) { return $items; } return array_values( array_filter( $filtered, static fn( $item ): bool => $item instanceof $type ) ); } /** * Builds a fully-wired suggestion engine, reading user settings. * * @since 1.0.0 * @since 1.7.0 Added the $ai_service_factory parameter. When given, the * candidate injector is built here (rather than by the caller) * so it can reuse the exact same $tokenizer/AnchorCandidateBuilder * local instances this method already constructs for the engine's * main scoring loop. * @since 1.10.0 Added the $stem_frequency_repository parameter. * @since 1.18.0 Removed the $ai_service_factory parameter. The candidate * injector now comes from the `postlinkly_candidate_injector` * filter. * * @param CandidateProvider|null $candidate_provider Optional override. * @param array $enhancers Optional suggestion enhancers. * @param array $pool_enhancers Optional candidate pool enhancers. * @param string $cache_context Optional cache context. * @param \Postlinkly\Suggestion\StemFrequencyRepositoryInterface|null $stem_frequency_repository * Optional. Source of the * persisted corpus IDF * map. Default null (the * engine falls back to * per-request derivation). * * @return SuggestionEngine */ public function build_suggestion_engine( ?CandidateProvider $candidate_provider = null, array $enhancers = array(), array $pool_enhancers = array(), string $cache_context = '', ?\Postlinkly\Suggestion\StemFrequencyRepositoryInterface $stem_frequency_repository = null ): SuggestionEngine { $settings = new \Postlinkly\Settings\Settings(); $general = $settings->general(); $suggestion = $settings->suggestion(); $advanced = $settings->advanced(); $stemmer = $this->resolve_stemmer( $general ); $tokenizer = new WordTokenizer( $stemmer, new IgnoreWords() ); if ( null === $candidate_provider ) { // Pass post types, statuses, and category/post exclusions from // settings. No corpus cap: the engine's two-phase candidate // loading (see SuggestionEngine::get_suggestions()) never // needed a hard limit to keep per-request cost bounded. $candidate_provider = new WpPostCandidateProvider( $tokenizer, $general->post_types, $general->excluded_post_ids, $suggestion->max_post_age_months, $general->post_statuses, $general->excluded_category_ids ); } // Cache TTL from advanced settings (hours → seconds). $cache_ttl = $advanced->suggestion_cache_ttl_hours * 3600; // Built once and reused below by both the engine's own main scoring // loop and (if an add-on supplies one) the candidate injector, so the // two share exactly one AnchorCandidateBuilder instance -- and // therefore one set of anchor-validity rules -- per engine build. $anchor_builder = new AnchorCandidateBuilder( $tokenizer, $stemmer, $suggestion->min_anchor_words, $suggestion->max_anchor_words ); /** * Filters the candidate injector, which adds suggestions for posts * the title-overlap gate turned away. * * Receives the engine's own anchor builder and tokenizer so injected * suggestions follow exactly the same anchor rules as the rest. * * @since 1.18.0 * * @param CandidateInjectorInterface|null $injector Injector. Default null (none). * @param AnchorCandidateBuilder $anchor_builder The engine's anchor builder. * @param WordTokenizer $tokenizer The engine's tokenizer. */ $candidate_injector = apply_filters( 'postlinkly_candidate_injector', null, $anchor_builder, $tokenizer ); if ( ! $candidate_injector instanceof CandidateInjectorInterface ) { $candidate_injector = null; } return new SuggestionEngine( new PhraseTokenizer(), $tokenizer, $candidate_provider, array( new TitleOverlapScorer( 4.0 ), // Primary: candidate title words in phrase. new TfIdfScorer( 1.5 ), // Secondary: corpus-relative term rarity. new TargetKeywordScorer( 3.0 ), // Boost: explicit owner-declared keywords. new KeywordScorer( 0.5 ), // Weak signal: general body word overlap. ), $anchor_builder, new SuggestionRanker(), new SuggestionCache( $cache_ttl, $cache_context ), $enhancers, $suggestion->ignore_phrases, // Phrases that must never be used as anchors. $pool_enhancers, $candidate_injector, $suggestion->prevent_cannibalization, $suggestion->skip_paragraphs, $stem_frequency_repository ); } /** * Returns the SEO plugins focus keywords can be imported from. * * Every supported plugin is listed; each source reports for itself * whether its plugin is active, and inactive ones are skipped at import. * * @since 1.18.1 * * @return array */ private function keyword_sources(): array { return array( new \Postlinkly\Keywords\Sources\YoastSource(), new \Postlinkly\Keywords\Sources\RankMathSource(), new \Postlinkly\Keywords\Sources\AioseoSource(), new \Postlinkly\Keywords\Sources\SeoPressSource(), new \Postlinkly\Keywords\Sources\SquirrlySource(), ); } /** * Resolves the stemmer to use from General settings' language override, * falling back to locale auto-detection. * * Single source of truth for this resolution: {@see build_suggestion_engine()} * and the stem-frequency indexer/rebuild wiring in {@see register_services()} * both call this so their tokenizers are always configured identically — * a stemming mismatch between the engine and the persisted corpus map * would make the map quietly useless. * * @since 1.10.0 * * @param \Postlinkly\Settings\GeneralSettings $general General settings. * * @return \Postlinkly\Text\Stemmer */ private function resolve_stemmer( \Postlinkly\Settings\GeneralSettings $general ): \Postlinkly\Text\Stemmer { $stemmer_loader = new StemmerLoader(); return '' !== $general->language ? $stemmer_loader->for_locale( $general->language ) : $stemmer_loader->for_current_locale(); } }