prefix . 'awb_broken_links'; // Check the table exists before querying its columns. $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( $table_exists ) { $col = $wpdb->get_results( "SHOW COLUMNS FROM {$table} LIKE 'fix_status'" ); if ( empty( $col ) ) { $wpdb->query( "ALTER TABLE {$table} ADD COLUMN fix_status VARCHAR(20) NOT NULL DEFAULT 'not_fixed' AFTER status_code" ); } } }, 5 ); /** * Fires after all free AWBSEO classes are loaded and initialized. * AWBSEO Pro hooks here to bootstrap its own classes. * * @since 1.0.0 */ do_action( 'awbseo_loaded' ); } /** * Migrate options from the old awb_seo_* prefix to the awbseo_* prefix * required by WordPress.org (prefixes must be > 4 characters). * Each mapping is migrate-once: if the new key already exists, skip. */ private static function maybe_migrate_options() { $map = [ // core settings 'awb_seo_settings' => 'awbseo_settings', 'awb_seo_db_version' => 'awbseo_db_version', // feature options 'awb_seo_tracked_keywords' => 'awbseo_tracked_keywords', 'awb_seo_decay_scores' => 'awbseo_decay_scores', 'awb_seo_woo_settings' => 'awbseo_woo_settings', 'awb_seo_robots_txt' => 'awbseo_robots_txt', 'awb_seo_pro_notice_dismissed'=> 'awbseo_pro_notice_dismissed', // OAuth tokens/properties 'awb_seo_gsc_tokens' => 'awbseo_gsc_tokens', 'awb_seo_gsc_property' => 'awbseo_gsc_property', 'awb_seo_ga4_tokens' => 'awbseo_ga4_tokens', 'awb_seo_ga4_property' => 'awbseo_ga4_property', 'awb_seo_bing_api_key' => 'awbseo_bing_api_key', 'awb_seo_bing_property' => 'awbseo_bing_property', ]; foreach ( $map as $old => $new ) { if ( false === get_option( $new ) ) { $val = get_option( $old ); if ( false !== $val ) { add_option( $new, $val ); delete_option( $old ); } } elseif ( false !== get_option( $old ) ) { // New key exists - just clean up the old one. delete_option( $old ); } } } public static function activate() { global $wpdb; $charset = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; // Redirects table. dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}awb_redirects ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, source VARCHAR(512) NOT NULL, target VARCHAR(512) NOT NULL, type SMALLINT NOT NULL DEFAULT 301, hits BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY source (source(255)) ) {$charset};" ); // Broken links cache table. dbDelta( "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}awb_broken_links ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL, url VARCHAR(2048) NOT NULL, status_code SMALLINT NOT NULL DEFAULT 0, fix_status VARCHAR(20) NOT NULL DEFAULT 'not_fixed', last_checked DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY post_id (post_id) ) {$charset};" ); // Migration: add fix_status column to existing installations that predate it. $col = $wpdb->get_results( "SHOW COLUMNS FROM {$wpdb->prefix}awb_broken_links LIKE 'fix_status'" ); if ( empty( $col ) ) { $wpdb->query( "ALTER TABLE {$wpdb->prefix}awb_broken_links ADD COLUMN fix_status VARCHAR(20) NOT NULL DEFAULT 'not_fixed' AFTER status_code" ); } // Rank history table. AWBSEO_Rank_Tracker::create_table(); // Local SEO table. AWBSEO_Local_SEO::create_table(); // Schedule cron jobs. // Broken link scan is NOT scheduled on activation - users opt in via Settings. // This prevents background HTTP requests to all posts without user consent. if ( ! wp_next_scheduled( 'awbseo_cwv_daily_check' ) ) { wp_schedule_event( time(), 'daily', 'awbseo_cwv_daily_check' ); } add_option( 'awbseo_settings', self::default_settings() ); AWBSEO_Client_Reports_Compat::maybe_register_rewrites(); AWBSEO_Sitemap::register_rewrites(); flush_rewrite_rules(); update_option( 'awbseo_db_version', AWBSEO_VERSION, false ); /** * Fires during AWBSEO free plugin activation. * Pro plugin uses this to create its own DB tables. * * @since 1.0.0 */ do_action( 'awbseo_activate' ); } public static function deactivate() { wp_clear_scheduled_hook( 'awbseo_broken_link_scan' ); wp_clear_scheduled_hook( 'awbseo_cwv_daily_check' ); wp_clear_scheduled_hook( 'awbseo_analysis_cron_step' ); flush_rewrite_rules(); do_action( 'awbseo_deactivate' ); } public static function default_settings() { return [ 'separator' => '–', 'title_format' => '%title% %sep% %sitename%', 'homepage_title' => '', 'homepage_desc' => '', 'noindex_cats' => false, 'noindex_tags' => false, 'noindex_date' => true, 'noindex_author' => false, 'og_default_image' => '', 'twitter_card' => 'summary_large_image', 'twitter_site' => '', 'sitemap_post_types' => [ 'post', 'page' ], 'sitemap_taxonomies' => [ 'category' ], 'sitemap_images' => true, 'llms_txt_enabled' => true, 'hreflang_enabled' => false, 'hreflang_pairs' => [], 'cwv_threshold_lcp' => 2500, 'cwv_threshold_fid' => 100, 'cwv_threshold_inp' => 200, 'cwv_threshold_cls' => 0.1, 'pagespeed_api_key' => '', 'openai_api_key' => '', 'anthropic_api_key' => '', 'gemini_api_key' => '', 'perplexity_api_key' => '', 'serp_api_key' => '', 'acf_enabled' => false, 'acf_meta_title_field' => '', 'acf_meta_desc_field' => '', 'acf_schema_mapping' => '', 'acf_by_post_type' => [], 'admin_language' => 'en', 'admin_language_auto' => true, ]; } /** * Renders post content as a visitor would see it, stripping unrendered * shortcodes. Used by on-page analyzer and related features. */ public static function get_rendered_content( $post ) { if ( ! $post || empty( $post->post_content ) ) { return ''; } $rendered = apply_filters( 'the_content', $post->post_content ); // Strip any leftover shortcode bracket syntax (deactivated plugin tags, // builder shortcodes that didn't fire in admin AJAX context, etc.). // [^\]] matches any character except ] without backtracking, so this // stays O(n) on large WPBakery pages instead of hitting PCRE limits. // Inner text between [tag]...[/tag] pairs is preserved; only the // bracket tokens themselves are replaced with a space. $rendered = preg_replace( '/\[[^\]]*\]/', ' ', $rendered ); return $rendered; } /** * Extract plain text directly from raw $post_content without relying on * shortcode handler callbacks. Works even when page builder hooks do not * fire in admin AJAX context (WPBakery, Elementor, etc.). * * Used by the on-page analyzer as a word-count fallback when the rendered * HTML returns suspiciously little text. * * Two extraction passes on the raw content: * 1. Iteratively unwrap [tag attrs]content[/tag] -> content (inner text). * 2. Pull attribute values from self-closing shortcodes, e.g. * [vc_custom_heading text="My Heading"] or [vc_btn title="Click"]. */ public static function extract_raw_text( $post_content ) { // Pass 1: remove all [shortcode] tokens, preserving the inner text // that sits between opening and closing pairs. // [^\]] (any char except ]) never backtracks, so this is O(n) and // safe on large WPBakery pages that previously hit PCRE backtrack limits. $text = preg_replace( '/\[[^\]]*\]/', ' ', $post_content ); $text = wp_strip_all_tags( $text ); // Pass 2: pull visible text out of shortcode attributes such as // [vc_custom_heading text="My Heading"] or [vc_btn title="Click Me"]. // These are self-closing, so their text does not appear as inner content // and would be missed by Pass 1 alone. preg_match_all( '/\s(?:text|content|title|description|caption|heading|label|subtitle|value|excerpt)\s*=\s*["\']([^"\'<>\[\]]{2,})["\']/', $post_content, $attr_m ); $attr_text = ! empty( $attr_m[1] ) ? implode( ' ', $attr_m[1] ) : ''; return trim( $text . ' ' . $attr_text ); } // ── Activity logging helpers ─────────────────────────────────────────────── /** * Shared guard: returns true if this save should be skipped for logging. */ private static function should_skip_log( $post_type ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return true; } if ( 'revision' === $post_type ) { return true; } $public = get_post_types( [ 'public' => true ] ); return ! in_array( $post_type, $public, true ); } /** * Log when a post is published for the first time or unpublished. */ public static function log_status_transition( $new_status, $old_status, $post ) { if ( self::should_skip_log( $post->post_type ) ) { return; } $type = ucfirst( $post->post_type ); $title = get_the_title( $post->ID ); if ( 'publish' === $new_status && 'publish' !== $old_status ) { do_action( 'awbseo_activity', 'content', 'Published ' . $type, $title ); } elseif ( 'publish' === $old_status && 'publish' !== $new_status ) { do_action( 'awbseo_activity', 'content', 'Unpublished ' . $type, $title ); } } /** * Log content, title, and excerpt changes on already-existing posts. * Fires AFTER save_post so comparison is old vs new. * * @param int $post_id * @param WP_Post $post_after * @param WP_Post $post_before */ public static function log_content_update( $post_id, $post_after, $post_before ) { if ( self::should_skip_log( $post_after->post_type ) ) { return; } // Only log changes on published posts (drafts handled by status transition). if ( 'publish' !== $post_after->post_status ) { return; } $title = get_the_title( $post_id ); if ( $post_after->post_title !== $post_before->post_title ) { do_action( 'awbseo_activity', 'content', 'Updated post title', $title ); } if ( $post_after->post_content !== $post_before->post_content ) { do_action( 'awbseo_activity', 'content', 'Updated page content', $title ); } if ( $post_after->post_excerpt !== $post_before->post_excerpt ) { do_action( 'awbseo_activity', 'content', 'Updated excerpt', $title ); } } /** * Log when a featured image is set. */ public static function log_featured_image_set( $post_id, $thumbnail_id ) { $post = get_post( $post_id ); if ( ! $post || self::should_skip_log( $post->post_type ) ) { return; } do_action( 'awbseo_activity', 'on-page', 'Set featured image', get_the_title( $post_id ) ); } /** * Log when a featured image is removed. */ public static function log_featured_image_removed( $post_id ) { $post = get_post( $post_id ); if ( ! $post || self::should_skip_log( $post->post_type ) ) { return; } do_action( 'awbseo_activity', 'on-page', 'Removed featured image', get_the_title( $post_id ) ); } /** * Log when categories or tags are assigned to a post. * * @param int $object_id * @param array $terms * @param array $tt_ids * @param string $taxonomy */ public static function log_terms_change( $object_id, $terms, $tt_ids, $taxonomy ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! in_array( $taxonomy, [ 'category', 'post_tag' ], true ) ) { return; } if ( empty( $terms ) ) { return; } $post = get_post( $object_id ); if ( ! $post || self::should_skip_log( $post->post_type ) ) { return; } $label = 'category' === $taxonomy ? 'categories' : 'tags'; do_action( 'awbseo_activity', 'content', 'Updated ' . $label, get_the_title( $object_id ) ); } } /** * Thin compatibility shim so Client Reports rewrites registered by the Pro * plugin don't cause a fatal error if Pro isn't installed. The free plugin's * activate/init code calls this instead of AWBSEO_Client_Reports directly. */ class AWBSEO_Client_Reports_Compat { public static function maybe_register_rewrites() { if ( class_exists( 'AWBSEO_Client_Reports' ) ) { AWBSEO_Client_Reports::register_rewrites(); } } }