prepare(). define( 'NLHEALTH_VERSION', '1.7.3' ); define( 'NLHEALTH_DB_VERSION', '2.5' ); define( 'NLHEALTH_BATCH_SIZE', 5 ); define( 'NLHEALTH_PLUGIN_FILE', __FILE__ ); define( 'NLHEALTH_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); define( 'NLHEALTH_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'NLHEALTH_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'NLHEALTH_TEXT_DOMAIN', 'linkvane' ); /* * The author's wordpress.org username. Keep it identical to the leading entry on * readme.txt's `Contributors:` line; wordpress.org rejects a Contributors entry * naming an account that does not exist. Nothing reads this constant at runtime. */ if ( ! defined( 'NLHEALTH_AUTHOR_WP_HANDLE' ) ) { define( 'NLHEALTH_AUTHOR_WP_HANDLE', 'lenrique5454' ); } require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-activator.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-deactivator.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-scanner.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-link-graph.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-link-recommendations.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-rules-engine.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-seo-audit.php'; require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-export.php'; require_once NLHEALTH_PLUGIN_DIR . 'admin/class-nlhealth-admin.php'; /* * WP-CLI commands. Registered here rather than inside nlhealth_bootstrap(): that * function is runtime hook wiring, and registering commands is not a hook — * it has no business entangled with the is_admin() branch. This block sits * after the scanner/link-graph/seo-audit requires so the CLI class can name * them without load-order surprises. */ if ( defined( 'WP_CLI' ) && WP_CLI ) { require_once NLHEALTH_PLUGIN_DIR . 'includes/class-nlhealth-cli.php'; WP_CLI::add_command( 'nlhealth', 'NLHealth_CLI' ); WP_CLI::add_command( 'nlhealth juice', 'NLHealth_CLI_Juice' ); WP_CLI::add_command( 'nlhealth db', 'NLHealth_CLI_DB' ); } register_activation_hook( __FILE__, array( 'NLHealth_Activator', 'activate' ) ); register_deactivation_hook( __FILE__, array( 'NLHealth_Deactivator', 'deactivate' ) ); // A subsite created after a network activation never ran the activation hook. add_action( 'wp_initialize_site', array( 'NLHealth_Activator', 'activate_new_site' ), 900 ); add_filter( 'cron_schedules', array( 'NLHealth_Activator', 'add_cron_schedule' ) ); // wp_schedule_event() freezes the interval at scheduling time, so a frequency // change only takes effect once the booked event is cleared and re-created. add_action( 'update_option_nlhealth_scan_frequency', array( 'NLHealth_Activator', 'reschedule_cron' ) ); // add_option fires instead of update_option when the value is written for // the initial time, and the option is only seeded by set_default_options() during // activate() — which an in-place plugin update never runs. On such an install // the very first frequency change would otherwise save the new value while the // scan silently kept its old cadence. add_action( 'add_option_nlhealth_scan_frequency', array( 'NLHealth_Activator', 'reschedule_cron' ) ); /** * Returns the capability required to view the plugin's admin screens. * * Read access covers the dashboards (broken links, SEO audit, Link Juice) and * the read-only AJAX endpoints behind them. On agency sites the intended user * is often an Editor or a dedicated SEO role rather than an administrator, so * this is filterable. Destructive actions route through * nlhealth_get_write_capability() instead — lowering the bar for *looking* is not * the same as lowering it for *changing post content*. * * @since 1.5.6 * @return string Capability slug. */ function nlhealth_get_capability(): string { /** * Filters the capability required to read the plugin's admin screens. * * @since 1.5.6 * @param string $capability Default 'manage_options'. */ $capability = apply_filters( 'nlhealth_capability', 'manage_options' ); return is_string( $capability ) && '' !== $capability ? $capability : 'manage_options'; } /** * Returns the capability required for destructive/write actions. * * Applies to anything that edits post content or changes configuration: * single/bulk corrections, Link Juice re-linking, the ignore list, the rules * editor, and the Settings page. Read-only work (dashboards, scans, re-checks, * the SEO audit, PageRank recomputation, CSV export of data already visible on * the dashboard) stays on nlhealth_get_capability(). * * This deliberately does NOT inherit from nlhealth_get_capability(): lowering the * bar to let an SEO role *look* at the dashboard must not silently also hand * that role bulk find-and-replace over every post on the site. Opening up reads * is one decision; opening up writes is a second, separate one, so a site that * wants a single gate has to say so by filtering both. * * @since 1.5.6 * @return string Capability slug. */ function nlhealth_get_write_capability(): string { /** * Filters the capability required for the plugin's destructive actions. * * @since 1.5.6 * @param string $capability Default 'manage_options' — independent of nlhealth_capability. */ $capability = apply_filters( 'nlhealth_write_capability', 'manage_options' ); return is_string( $capability ) && '' !== $capability ? $capability : 'manage_options'; } /** * Whether the current user may read the plugin's admin screens. * * @since 1.5.6 * @return bool */ function nlhealth_current_user_can_read(): bool { return current_user_can( nlhealth_get_capability() ); } /** * Whether the current user may perform the plugin's destructive actions. * * @since 1.5.6 * @return bool */ function nlhealth_current_user_can_write(): bool { return current_user_can( nlhealth_get_write_capability() ); } /** * Updates a link in post content with the native WordPress HTML tag processor. * * @param int $post_id Post ID. * @param string $old_url URL to replace. * @param string $new_url Replacement URL. * @return bool */ function nlhealth_update_post_link( int $post_id, string $old_url, string $new_url ): bool { $scanner = new NLHealth_Scanner(); return $scanner->update_post_link( $post_id, $old_url, $new_url ); } /** * Boots plugin runtime hooks. * * @return void */ function nlhealth_bootstrap(): void { $scanner = new NLHealth_Scanner(); add_action( 'nlhealth_run_batch', array( $scanner, 'run_batch' ) ); add_action( 'save_post', array( $scanner, 'handle_post_saved' ), 10, 3 ); add_action( 'deleted_post', 'nlhealth_cleanup_deleted_post' ); add_action( 'delete_comment', 'nlhealth_cleanup_deleted_comment' ); if ( is_admin() ) { // Plugin file updates don't re-run activation, so apply any pending DB // schema upgrades (e.g. the v2.1 link-map tables) on existing installs. add_action( 'admin_init', array( 'NLHealth_Activator', 'maybe_upgrade' ) ); $admin = new NLHealth_Admin( $scanner ); $admin->init(); } elseif ( defined( 'WP_CLI' ) && WP_CLI ) { /* * is_admin() is false under WP-CLI, so the admin_init above never * fires. Without this, `wp nlhealth scan` on a site whose admin has not * been visited since the update runs against a stale schema — exactly * the headless installs the CLI exists for. * * elseif rather than an unconditional add_action keeps the front-end * path byte-identical (no extra option read per pageview, which is why * the check was gated to admin to begin with). Priority 1 on init * rather than an immediate call because maybe_upgrade() → upgrade() → * upgrade_to_2_1() requires wp-admin/includes/upgrade.php and dbDelta * wants $wpdb->charset/collate initialised; init is comfortably before * WP-CLI dispatches the command and comfortably after WP is ready, and * it leaves `wp --skip-plugins` / `wp core install` bootstraps alone. */ add_action( 'init', array( 'NLHealth_Activator', 'maybe_upgrade' ), 1 ); } } nlhealth_bootstrap(); /** * Cleans up link scan data when a post is deleted. * * @since 1.0.1 * @param int $post_id Deleted post ID. */ function nlhealth_cleanup_deleted_post( int $post_id ): void { global $wpdb; $post_id = (int) $post_id; if ( $post_id <= 0 ) { return; } $errors_table = $wpdb->prefix . 'nlhealth_link_errors'; $scanner = new NLHealth_Scanner(); $suffix = $scanner->state_key_suffix( 'post', $post_id ); // "{id}". // Read the hashes BEFORE deleting the rows — they are the only handle on // this post's option/transient state. $hashes = $wpdb->get_col( $wpdb->prepare( 'SELECT url_hash FROM %i WHERE post_id = %d AND source_type = %s', $errors_table, $post_id, 'post' ) ); foreach ( (array) $hashes as $hash ) { delete_option( 'nlhealth_last_ok_' . $hash . '_' . $suffix ); delete_option( 'nlhealth_last_soft_' . $hash . '_' . $suffix ); delete_transient( 'nlhealth_retry_' . $hash . '_' . $suffix ); delete_transient( 'nlhealth_fail_' . $hash . '_' . $suffix ); // Do NOT delete 'nlhealth_ok_' . $hash — that cache is keyed by URL alone // and is shared site-wide; clearing it would force every other post // using the same URL back over the network on the next scan. } // Only the post's own link records use this post_id; comment records reuse // the post_id column for the comment id, so scope the errors delete by source. $wpdb->delete( $errors_table, array( 'post_id' => $post_id, 'source_type' => 'post', ), array( '%d', '%s' ) ); // Events are source-scoped since DB 2.4: without the source_type clause // this would also destroy the events of the *comment* whose id happens to // equal this post id. $wpdb->delete( $wpdb->prefix . 'nlhealth_link_events', array( 'post_id' => $post_id, 'source_type' => 'post', ), array( '%d', '%s' ) ); // nlhealth_correction_log carries post ids only — no path ever corrects a // comment — so it needs no source scoping. $wpdb->delete( $wpdb->prefix . 'nlhealth_correction_log', array( 'post_id' => $post_id ), array( '%d' ) ); // Drop the post from the internal link graph (both as a source and a // target) and remove its cached juice score. ( new NLHealth_Link_Graph() )->delete_post( $post_id ); } /** * Cleans up link scan data when a comment is deleted. * * Comment-sourced broken-link records key the comment id into the post_id * column with source_type 'comment', so removal is scoped to that comment. * * Hooked to delete_comment rather than deleted_comment because it fires while * the row still exists, which is what makes reading the url hashes possible. * trash_comment / spam_comment are deliberately NOT wired here: a trashed or * spammed comment can be restored, and discarding its records would lose * discovered_at history and could leave the forward-only comment cursor * (nlhealth_comment_scan_last_id) skipping it for a whole cycle. * * @param int|string $comment_id Deleted comment ID. */ function nlhealth_cleanup_deleted_comment( $comment_id ): void { global $wpdb; $comment_id = (int) $comment_id; if ( $comment_id <= 0 ) { return; } $errors_table = $wpdb->prefix . 'nlhealth_link_errors'; $scanner = new NLHealth_Scanner(); $suffix = $scanner->state_key_suffix( 'comment', $comment_id ); // "comment_{id}". // Read the hashes BEFORE deleting the rows — they are the only handle on // this comment's option/transient state. $hashes = $wpdb->get_col( $wpdb->prepare( 'SELECT url_hash FROM %i WHERE post_id = %d AND source_type = %s', $errors_table, $comment_id, 'comment' ) ); foreach ( (array) $hashes as $hash ) { delete_option( 'nlhealth_last_ok_' . $hash . '_' . $suffix ); delete_option( 'nlhealth_last_soft_' . $hash . '_' . $suffix ); delete_transient( 'nlhealth_retry_' . $hash . '_' . $suffix ); delete_transient( 'nlhealth_fail_' . $hash . '_' . $suffix ); // Do NOT delete 'nlhealth_ok_' . $hash — that cache is keyed by URL alone // and is shared site-wide; clearing it would force every post using // the same URL back over the network on the next scan. } $wpdb->delete( $errors_table, array( 'post_id' => $comment_id, 'source_type' => 'comment', ), array( '%d', '%s' ) ); $wpdb->delete( $wpdb->prefix . 'nlhealth_link_events', array( 'post_id' => $comment_id, 'source_type' => 'comment', ), array( '%d', '%s' ) ); // nlhealth_correction_log is left untouched on purpose: no path corrects a // comment (see the source_type fix in the bulk-correct query). }