prefix-derived table names or keys from hardcoded internal arrays (never request input). Direct queries hit plugin-owned tables that have no WP REST/cache layer. Variables flagged as non-prefixed are local helpers / loop indices, not globals. CSV import/export streams use fopen()/fread()/fwrite()/fclose() because WP_Filesystem_API has no equivalent streaming interface and the operations are admin-only. set_time_limit()/ini_set() are needed inside long-running CSV exports + activation cleanup; they're capability-gated and short-circuit when running under PHP-FPM. /* Plugin Name: AI eShop Optimizer Plugin URI: https://rooster.systems/get-aieo/ Description: Adaptive productivity, conversion & retention for eshops and content sites. Anthropic/Voyage AI: messaging, recommendations, attribution, loyalty, merchandising. Version: 8.2 Requires at least: 7.0 Tested up to: 7.0 Requires PHP: 7.4 Author: Oxford Metadata Ltd Author URI: https://oxfordmetadata.co.uk License: GPL3 Tags: ai-chat, content-recommendations, semantic-search, conversational-ai, claude */ // Ensure the plugin is only loaded within WordPress if (!defined('ABSPATH')) { exit; } /** * Centralised debug logger. Calls error_log() only when WP_DEBUG and * WP_DEBUG_LOG are both enabled, so production sites stay quiet by default. * Used in place of bare error_log() throughout the plugin. * * @param string|mixed $message Message or value to log. */ if (!function_exists('aieo_debug_log')) { function aieo_debug_log($message) { if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Single gated entry point for plugin diagnostics. error_log(is_scalar($message) ? (string) $message : wp_json_encode($message)); } } } /** * Prefer the plugin's BUNDLED translations over an incomplete wp.org language * pack. WordPress loads .mo/.l10n.php from wp-content/languages/plugins/ (the * translate.wordpress.org GlotPress pack) BEFORE the plugin's own languages/ * dir and returns early — so a stub pack (e.g. the 10-string Greek pack) would * shadow our full bundled file and leave most of the admin UI in English. * Redirect any global-dir load for our domain to the bundled file whenever we * ship one for that locale; other locales keep using their wp.org packs. */ if (!function_exists('aieo_prefer_bundled_textdomain')) { function aieo_prefer_bundled_textdomain($mofile, $domain) { if ('ai-eshop-optimizer' !== $domain) { return $mofile; } // Only override when WP is about to load from the global languages dir. if (0 === strpos($mofile, WP_LANG_DIR)) { $bundled = plugin_dir_path(__FILE__) . 'languages/' . basename($mofile); if (is_readable($bundled)) { return $bundled; } } return $mofile; } } add_filter('load_textdomain_mofile', 'aieo_prefer_bundled_textdomain', 10, 2); /** * Stop WordPress from (re)installing the incomplete translate.wordpress.org * language pack for this plugin. We are listed on wp.org, so WP's translations * updater keeps fetching the community GlotPress pack — which currently has * only ~6 Greek strings — into wp-content/languages/plugins/ on every * translations cron and plugin update. That stub then shows up as a second, * "stale" Greek file in Loco; although aieo_prefer_bundled_textdomain() above * already redirects loading back to our COMPLETE bundled catalogue, the stub * keeps reappearing, confuses translators and clutters the languages dir. * Dropping our slug from the update transient means the stub is never fetched. */ if (!function_exists('aieo_block_wporg_translation_pack')) { function aieo_block_wporg_translation_pack($transient) { if (isset($transient->translations) && is_array($transient->translations)) { foreach ($transient->translations as $i => $t) { if (isset($t['slug']) && 'ai-eshop-optimizer' === $t['slug']) { unset($transient->translations[$i]); } } $transient->translations = array_values($transient->translations); } return $transient; } } add_filter('pre_set_site_transient_update_plugins', 'aieo_block_wporg_translation_pack'); add_filter('site_transient_update_plugins', 'aieo_block_wporg_translation_pack'); // Load text domain IMMEDIATELY for AJAX requests (before any classes load) // This ensures translations are available during whitelisted AJAX requests if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Fallback for non-WordPress.org hosted translations load_plugin_textdomain( 'ai-eshop-optimizer', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); } if (!defined('AIEO_VERSION')) { define('AIEO_VERSION', '8.2'); } // Schema/data-migration counter, independent of the plugin version. Bumped when // aieo_run_db_migrations() gains a new step. v1=#33 sessions index trim, // v2=#34 reco reverse-ref table + backfill, v3=#35 analytics atomic-swap procs. if (!defined('AIEO_DB_VERSION')) { define('AIEO_DB_VERSION', 3); } define('AIEO_FILE', __FILE__); define('AIEO_BASE', trailingslashit(plugin_basename(AIEO_FILE))); define('AIEO_DIR', plugin_dir_path(__FILE__)); define('AIEO_URI', plugin_dir_url(__FILE__)); define('AIEO_orders_upload_endpoint', 'https://uploads.eshop-optimizer.com/wp-json/aieo/v1/order_uploads'); // Capability required to access the plugin admin pages // Using 'edit_pages' allows editor, shop_manager, and admin roles to access // (edit_pages is shared by Editor, Shop Manager, and Administrator) define('AIEO_CAPABILITY', 'edit_pages'); // Declare WooCommerce feature compatibility if WooCommerce is active. // `variation_gallery` + `wc-visual-attribute` are WC 10.9+ experimental features; // AIEO writes its data into WC's canonical keys so they Just Work when toggled on. add_action('before_woocommerce_init', function () { if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', __FILE__, true); \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('variation_gallery', __FILE__, true); \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('wc-visual-attribute', __FILE__, true); } }); // Load the plugin class require_once plugin_dir_path(__FILE__) . '/includes/class-eshop-optimizer.php'; // SQL layer split (Phase-1 perf, 2026-06-13). sprocs.php (~6.3k lines of // CREATE PROCEDURE installers, which itself pulls in dmm-schema.php ~3.5k // lines + brand-vitals-schema.php) used to load HERE on every request, // including anonymous storefront page views that never touch it. Now: // • sql-runtime-helpers.php — the handful of helpers runtime code calls // (charset/collate, aieo_dbdelta, table probes, role columns) — always; // • the full DDL layer — only where installers/exports actually run: // admin, AJAX, cron, CLI, plus activation/deactivation/upgrade via // aieo_load_sql_layer() (called inside those entry points). require_once plugin_dir_path(__FILE__) . '/includes/sql/sql-runtime-helpers.php'; /** * Load the full SQL/DDL layer (sprocs + dmm-schema + brand-vitals schema) * on demand. Idempotent. MUST run before any aieo_create_* / aieo_drop_sp_* * call — the activation / deactivation / upgrade entry points all invoke it * as their first statement, so every call path is covered regardless of * request context. */ function aieo_load_sql_layer() { require_once plugin_dir_path(__FILE__) . '/includes/sql/sprocs.php'; } if (is_admin() || wp_doing_ajax() || wp_doing_cron() || (defined('WP_CLI') && WP_CLI)) { aieo_load_sql_layer(); } // Load Session Tracker (Core analytics functionality). // AIEO Session Tracker has a canonical copy at // ai-eshop-optimizer-runtime/includes/class-aieo-session-tracker.php // (moved 2026-05-16 — see runtime plugin's STATUS.md). When the // autoloader mu-plugin AND the runtime plugin are present, this // require_once is a no-op (the autoloader resolved the class first). // When the admin plugin is installed STANDALONE — typical for a fresh // wp.org download where neither the autoloader nor the runtime ship — // the require_once loads our own fallback copy. This guard is the // stability boundary that lets the admin plugin work on any host // without external prerequisites. if ( ! class_exists( 'AIEO_Session_Tracker' ) ) { require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-session-tracker.php'; } // Load Session Schema Migrator (Database migration tools) require_once plugin_dir_path(__FILE__) . '/includes/class-session-schema-migrator.php'; // Load Recommendations Cache (Multi-tier cache for product recommendations) require_once plugin_dir_path(__FILE__) . '/includes/class-recommendations-cache.php'; // Serve built *.min.css / *.min.js via a global URL-rewrite filter (no // per-enqueue edits). No-ops when SCRIPT_DEBUG is on or a .min file is absent. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-asset-min.php'; AIEO_Asset_Min::register(); // Central wp_set_script_translations() wiring for all JS handles that use wp.i18n. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-script-translations.php'; require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-admin-color.php'; // Per-device (desktop/mobile) value controls for EVERY AIEO block — adds the // editor "Responsive (per device)" panel + render-time scoped CSS via global // filters keyed on aieo/* (covers all 19 blocks + any future one automatically). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-responsive-controls.php'; AIEO_Responsive_Controls::register(); // wpDataTables report-page enhancements (glass tooltips + origin tagging), // ported from the standalone om-wpdt-enhancements plugin. register() only // adds hooks — the front-end enqueue is page-scoped (aieo_wpdt_enhance_pages // option, managed on Tools → Product/Orders analytics) and the origin-tag // fires solely on the wdt_save_table_frontend AJAX action. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-wpdt-enhancements.php'; AIEO_WPDT_Enhancements::register(); // Load Wishlist (favorites storage + AJAX endpoints; phase-1 of the // migration from om-product-page-essentials). // AIEO_Wishlist has a canonical copy in the runtime plugin // (ai-eshop-optimizer-runtime/includes/class-aieo-wishlist.php — moved // 2026-05-06). With autoloader+runtime present, the require_once below // is a no-op. Without them, the admin plugin loads its own fallback // copy so wishlist still works on a standalone install. if ( ! class_exists( 'AIEO_Wishlist' ) ) { require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-wishlist.php'; } // Phase-2 PPE compatibility shim — proxies om_ppe_wishlist_toggle → // aieo_wishlist_toggle so themes / widgets / external callers that // reference the legacy endpoint keep working after PPE's wishlist // module is disabled. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-wishlist-compat.php'; // Star Ratings (AIEO_Ratings) — canonical copy lives in the runtime // plugin (ai-eshop-optimizer-runtime/includes/class-aieo-ratings.php — // moved 2026-05-06). With autoloader+runtime present, the require_once // below is a no-op. Without them, the admin plugin loads its own // fallback copy so ratings still work on a standalone install. The // REST sibling class is admin-only and always loads from here. if ( ! class_exists( 'AIEO_Ratings' ) ) { require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-ratings.php'; } require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-ratings-rest.php'; // Per-brand flat projection (sibling of core_product_vitals). Read+refresh // model for wp_aieo_core_brand_vitals — sole front-end source for every // brand-shopping surface (A-Z catalogue, sliders, archive, navigator). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-core-brand-vitals.php'; // Classic-PHP shortcode wrappers for the three "post-related-*" blocks // (brands / categories / needs). Re-registers the legacy Pods-era // `[related_brand_shortcode]` and `[secondary_brand_shortcode]` aliases // so themes that hard-code them in single.php keep working. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-post-relations-shortcodes.php'; // FAQ content type (`faq`) + grouping taxonomy (`faq_group`) + the [faq] / // [aieo_faq] accordion shortcode (self-contained, own scoped CSS). The entry // list is surfaced from the DMM "Custom Post Types" nav group, not a stray // top-level menu. Self-instantiates at file end. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-faq.php'; // The store CPTs — theme-owned `store_location` + plugin-owned `aieo_store` — // are surfaced from the DMM "Custom Post Types" nav group, so drop their stray // top-level sidebar menus. (The `faq` CPT already registers show_in_menu=false.) add_filter('register_post_type_args', function ($args, $post_type) { if (in_array($post_type, array('store_location', 'aieo_store'), true)) { $args['show_in_menu'] = false; } return $args; }, 20, 2); // `aieo/menu` block — renders a WP menu as an inline strip (header) or // accordion (footer); replaces the roosterx-ff [ff_simple_menu] shortcode + // the hand-coded footer markup with a proper menu-picker block. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-menu-block.php'; // `aieo/trust-badges` block — configurable trust strip (icon + title + // subtitle columns); replaces the hand-coded ff-trust-strip / roosterx trust // tiles. Bundled in the runtime too (class_exists-guarded). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-trust-badges-block.php'; // `aieo/social-icons` block — social links (FB/IG/Pinterest/YouTube/TikTok) with // colour, shape (round/square) + responsive gap. Bundled in the runtime too. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-social-icons-block.php'; // Per-customer engagement-signal query helpers (favourite brands, // wishlisted brands, rated brands) — bridges brand_affinity_json + // wp_aieo_wishlist + wp_aieo_ratings to blocks/navigator/push triggers. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-customer-intelligence-query.php'; // Attribution — source tracking for every add-to-cart event. // See ATTRIBUTION-SCHEMA-BRIEF.md for the schema + GA4 contract. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-attribution.php'; // Canonical read-through for per-order facts (revenue + channel). D1 single- // source-of-truth accessor — see documentation/ORDER_DATA_MAP.md. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-order-facts.php'; // ROI Wizard cost ledger (operator/connector ad-spend per dimension per period). // See documentation/ROI_WIZARD_PLAN.md. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-attribution-cost.php'; if ( class_exists( 'AIEO_Attribution_Cost' ) ) { AIEO_Attribution_Cost::init(); } // Order origin + attribution block in the staff (admin) order emails — replaces // the unreliable PixelYourSite origin line (marketplace vs Site + channel/campaign). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-order-origin-email.php'; if ( class_exists( 'AIEO_Order_Origin_Email' ) ) { AIEO_Order_Origin_Email::register(); } // ROI Wizard subtab — self-registers onto the DMM module bus when it loads // (same pattern as the marketplace-order-importer). See documentation/ROI_WIZARD_PLAN.md. add_action( 'aieo_dmm_register_dynamic_modules', function ( $dmm ) { if ( ! class_exists( 'AIEO_DMM_Module_Base' ) ) { return; } require_once plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-roi-wizard.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-ad-connectors.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-utm-explorer.php'; if ( is_object( $dmm ) && method_exists( $dmm, 'register_module' ) ) { if ( class_exists( 'AIEO_DMM_ROI_Wizard' ) ) { $dmm->register_module( new AIEO_DMM_ROI_Wizard() ); } if ( class_exists( 'AIEO_DMM_Ad_Connectors' ) ) { $dmm->register_module( new AIEO_DMM_Ad_Connectors() ); } // UTM Explorer is consolidated onto the ROI Wizard page (the ROI Wizard // composes + renders it), so it is required above but NOT a separate tab. } }, 10, 1 ); // Tier-2 session × attribution bridge — query helpers JOINing the // Session Tracker tables with the Attribution Engine table for joint // engagement-attribution analytics. Backs 11 Claude abilities. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-session-attribution-bridge.php'; // Heuristic conversion-probability model — buckets historical sessions // on engagement (scroll-depth × time-on-page) and reports empirical // P(cart-add) per bucket. Backs the buy-probability-* abilities. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-conversion-probability-model.php'; // Perf — source-level PageSpeed mitigations: non-critical CSS deferral // + LCP image preload on PDP. Hookless until front-end render. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-perf.php'; // AIEO Star Ratings → SEO-plugin schema bridge. Feeds the materialized // `wp_aieo_rating_stats` aggregate into Product JSON-LD emitted by // AIOSEO / Yoast / Rank Math so Google's rich snippet uses the AIEO // star data (WC reviews are off on this site). Falls back to its own // JSON-LD block if no SEO plugin is detected. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-ratings-schema.php'; // SEO engine (Stage A). The offline head BUILDER (AIEO_SEO_Head_Builder) // materialises wp_aieo_seo_head_cache on save/cron; the thin, bundled // AIEO_SEO_Head emitter serves that cache with one read. All AIEO_SEO_* // classes autoload from includes/seo/ via the mu-plugin autoloader. The // builder + store (AIEO_SEO_Store) are core-only; the emitter is bundled in // both plugins. The builder self-gates on AIEO_SEO_Store::is_active_provider() // so registering its hooks is harmless while AIOSEO is still active. if ( class_exists( 'AIEO_SEO_Head_Builder' ) ) { AIEO_SEO_Head_Builder::register_build_hooks(); } // SEO editor metabox (admin only) — the canonical-category picker + SEO title / // meta / canonical-override / noindex, all written to wp_aieo_seo_posts // (nothing in postmeta). Allowed regardless of provider state so operators can // pin canonical categories before cut-over. if ( is_admin() && class_exists( 'AIEO_SEO_Post_Metabox' ) ) { AIEO_SEO_Post_Metabox::register(); } // Slug monitor (admin only, A2) — auto-create a 301 when a published object's // slug/URL changes so existing links don't 404. Writes via AIEO_SEO_Redirects. if ( is_admin() && class_exists( 'AIEO_SEO_Slug_Monitor' ) ) { AIEO_SEO_Slug_Monitor::register(); } // SEO schema self-heal — the same maybe_install() delta pattern every other // feature uses (Carts/Ratings/Attribution/Consent). Creates/upgrades the SEO // tables when the bundled schema version is newer than what's installed, so an // existing site picks up new tables (e.g. the A2 redirects + 404 log) on the // next admin load after a file upload — no reactivation / version bump needed. if ( is_admin() ) { add_action( 'admin_init', static function () { $seo_schema = AIEO_DIR . 'includes/dmm/sql/seo-schema.php'; if ( file_exists( $seo_schema ) ) { require_once $seo_schema; if ( function_exists( 'aieo_seo_maybe_install' ) ) { aieo_seo_maybe_install(); } } }, 5 ); } // SEO score column on the product/post/page list tables (toggle via Screen // Options). Reads stored scores in one query per page; gated on seo.enabled. if ( is_admin() && class_exists( 'AIEO_SEO_Admin_Columns' ) ) { AIEO_SEO_Admin_Columns::register(); } // Front-end emit: when the runtime plugin is present it OWNS the SEO head // (defines AIEO_RUNTIME_OWNS_SEO_HEAD + registers AIEO_SEO_Head itself); // otherwise the core registers the emitter standalone. DEFERRED to // plugins_loaded@11: the runtime defines its OWNS constant at its own file // load, which runs AFTER this core file is included (plugins load // alphabetically: ai-eshop-optimizer before ai-eshop-optimizer-runtime), so an // include-time !defined() check would always be true and the core would // register too. Checking on plugins_loaded — after every plugin's main file // has run — makes the skip reliable. Mirrors the AIEO_RUNTIME_OWNS_SSO_DISPLAY // gate precedent; register() is idempotent as a second safety net. add_action( 'plugins_loaded', static function () { // Canonical-category permalink filters (A1.3) — registered UNCONDITIONALLY // (NOT under the OWNS_SEO_HEAD gate): register() is idempotent (static // $done), so whichever plugin calls first wins and the other no-ops. This // decouples the permalink filter from the head-emitter OWNS constant, so it // can never end up registered in NEITHER plugin. Self-gated on // active-provider; harmless no-op without %product_cat%. NOT admin-gated: // must be active during the offline build so get_permalink() materialises // the canonical-category URL. if ( class_exists( 'AIEO_SEO_Primary_Term' ) ) { AIEO_SEO_Primary_Term::register(); } // Visible breadcrumb shortcode [aieo_breadcrumb] (the aieo/breadcrumb block // auto-registers via block.json). Idempotent; shares AIEO_SEO_Breadcrumb's // resolver with the JSON-LD BreadcrumbList so the trail uses the pinned // canonical category. if ( class_exists( 'AIEO_SEO_Breadcrumb' ) ) { AIEO_SEO_Breadcrumb::register(); } // Static sitemap (A1.4): the /sitemap.xml PHP fallback + the robots.txt // Sitemap line — self-gated on active-provider (core-free). In production // nginx serves the static file at the edge; the build runs offline via the // seo_sitemap data-prep stage. if ( class_exists( 'AIEO_SEO_Sitemap' ) ) { AIEO_SEO_Sitemap::register(); } // Redirect engine (A2): serves 301/302/307/410 + logs 404s. The runtime OWNS // it when present — its bundled copy + the aieo-seo-redirects mu-plugin serve // front-end redirects, so the core must NOT touch the class here. Referencing // it while the runtime copy is already declared triggered a "Cannot redeclare // class AIEO_SEO_Redirects" fatal on admin requests (e.g. product save). The // core registers it ONLY standalone (runtime absent). if ( ! defined( 'AIEO_RUNTIME_OWNS_SEO_HEAD' ) && class_exists( 'AIEO_SEO_Redirects' ) ) { AIEO_SEO_Redirects::register(); } // Head emitter: the runtime OWNS it when present (defines // AIEO_RUNTIME_OWNS_SEO_HEAD + registers its bundled copy); the core // registers it only standalone. if ( defined( 'AIEO_RUNTIME_OWNS_SEO_HEAD' ) ) { return; } if ( class_exists( 'AIEO_SEO_Head' ) ) { AIEO_SEO_Head::register(); } }, 11 ); // Product Swatches runtime + AJAX — canonical copies live in the // runtime plugin (ai-eshop-optimizer-runtime/includes/dmm/modules/). // With the runtime present, these require_once calls are no-ops // (class_exists short-circuits). Without it — i.e. on sites that // run the main plugin standalone — the admin plugin loads its own // bundled copy so the frontend swatch render still works. if ( ! class_exists( 'AIEO_DMM_Swatches_Runtime' ) ) { require_once plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-swatches-runtime.php'; } if ( ! class_exists( 'AIEO_DMM_Swatches_Ajax' ) ) { require_once plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-swatches-ajax.php'; } // Load AI Chat addon (Claude-powered conversational search) require_once plugin_dir_path(__FILE__) . '/ai-eshop-chat-addon.php'; // Load Discount & Marketplace Management (DMM) bootstrap. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-dmm.php'; // Saved & Abandoned Carts subsystem — storefront save/share/resume on a // dedicated table pair (wp_aieo_carts + wp_aieo_cart_items) PLUS a permanent // abandoned-cart log captured from wc-checkout-draft orders (with outcome // tracking) before WooCommerce purges them. Bootstrap loads the data layer, // the storefront feature, and the hourly capture job. require_once plugin_dir_path(__FILE__) . '/includes/carts/class-aieo-carts.php'; // Server Events (Track B) — server-side conversion forwarding (Facebook CAPI // today; GA4 Measurement Protocol later). Loaded + booted UNCONDITIONALLY: the // server side is pure backend with no front-end surface and must run with or // without the runtime. Hook callbacks lazy-reference their deps, so load order // is irrelevant. The browser pixel surface (B2b) is wired separately behind a // dedicated AIEO_RUNTIME_OWNS_PIXEL constant. require_once plugin_dir_path(__FILE__) . '/includes/server-events/class-aieo-server-events.php'; AIEO_Server_Events::instance(); // First-party GDPR consent (Track C) — one source of truth (cookie + allows() // API), Google Consent Mode v2 signals, and a thin first-party banner. The // session-tracker consent gates consult this first; external CMPs remain the // fallback when the banner is off. Core-loaded (front-end surface). require_once plugin_dir_path(__FILE__) . '/includes/consent/class-aieo-consent.php'; AIEO_Consent::instance()->register(); // Google One Tap (recognise-only) — thin front-end loader. Renders the GSI // loader for logged-out visitors when the merchant enables One Tap (Consent/GDPR // page) + a Google client_id is set (Social Login). The JS gates on the // `personalize` consent flag + skips logged-in browsers. Default-OFF. require_once plugin_dir_path(__FILE__) . '/includes/social-login/class-aieo-one-tap.php'; AIEO_One_Tap::register(); // Panel manifest — the single descriptor the Rooster panel polls // (uploads/aieo-panel/manifest.json). Self-contained; rewrites on push/SEO engine // + VAPID changes (debounced). Registers the `wp aieo push prepare` / // `verify-readiness` CLI commands. Activation also defaults a FRESH site's push // engine to paused (add_option no-op → existing live sites untouched). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-panel-manifest.php'; AIEO_Panel_Manifest::register(); register_activation_hook(__FILE__, 'aieo_panel_manifest_activate'); // WC 10.9 forward-compat migration — relocates botiga_variation_gallery // + product_attribute_* term metas into WooCommerce's canonical keys // (`_product_image_gallery`, `color`) so that when a merchant enables // the `variation_gallery` / `wc-visual-attribute` feature toggles the // data is already where WC expects it. Idempotent, batched via Action // Scheduler; also exposed as `wp aieo migrate wc-compat`. require_once plugin_dir_path(__FILE__) . '/includes/migrations/class-aieo-wc-compat-migration.php'; AIEO_WC_Compat_Migration::bootstrap(); // Load Claude Abilities API bootstrap — registers 21 read-only abilities // (audience priorities, product needs, recos, attribution, ratings, // brand vitals, customer lookalikes) under the `aieo` category for the // woocommerce-claude MCP server / Claude Code skill harness to call. // Exposed at /wp-json/wp-abilities/v1/abilities/ when WP 6.9+. require_once plugin_dir_path(__FILE__) . '/includes/claude/class-aieo-claude-bootstrap.php'; add_action('init', array('AIEO\\Claude\\Bootstrap', 'init'), 5); // Merchant Insights Chat - wp-admin chat page. if ( is_admin() || ( defined('DOING_AJAX') && DOING_AJAX ) ) { require_once plugin_dir_path(__FILE__) . '/includes/chat/class-aieo-merchant-chat.php'; add_action('init', array('AIEO_Merchant_Chat', 'init'), 20); } // External-database connection registry (ERP / sister-store DBs). // Always loaded — its hooks are admin-conditional internally, but the // connection helper (AIEO_ERP::for_connection_id) needs to be reachable // from cron and wp-cli too. require_once plugin_dir_path(__FILE__) . '/includes/erp/class-aieo-erp.php'; // Retention + Remote-Export bootstrap (2026-05). // Schedules the nightly aggregate-rollup cron, the configurable remote // export cron, and binds AE/Reco archive handlers to the shared // `aieo_archive_run` hook fired by AIEO_Session_Tracker::archive_sessions(). // Installs the 5 rollup tables + remote-export state bookkeeping table. // MUST load AFTER class-aieo-erp.php — the remote-export manager calls // AIEO_ERP::for_connection_id() to talk to the destination DB. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-retention-bootstrap.php'; AIEO_Retention_Bootstrap::init(); // IAPI control panel (Phase C-α+ admin UI). if ( is_admin() ) { require_once plugin_dir_path(__FILE__) . '/includes/admin/class-aieo-iapi-admin.php'; } // Ctrl/Cmd+K command palette — overlay restricted to AIEO admin pages only. if ( is_admin() ) { require_once plugin_dir_path(__FILE__) . '/includes/admin/class-aieo-command-palette.php'; } // Initialize the plugin function AIEO_init() { // Phase-1 perf gate: class AIEO is the admin monolith (menus, CSV-export // pipeline, Rooster API client, DB optimizer — ~6.4k lines). Its hooks // are admin_menu / admin_bar_menu / admin_post_* / wp_ajax_* plus ONE // frontend-relevant hook: the wp_head admin-bar CSS fix. So: // • admin + AJAX requests construct it eagerly (status quo); // • all other requests defer to init and construct it only for // logged-in users — the only visitors who can see the admin bar. // Deciding at init (not here at plugins_loaded) avoids forcing // early user determination, which breaks late-registered auth // filters (session tokens, 2FA). // Anonymous storefront / REST / cron traffic skips the class entirely: // none of its hooks can fire there. if (is_admin() || wp_doing_ajax()) { new AIEO(); } else { add_action('init', function () { if (is_user_logged_in()) { new AIEO(); } }, 0); } // ALWAYS initialize Session Tracker to register REST endpoints // The tracker will handle whether to enqueue scripts based on settings AIEO_Session_Tracker::get_instance(); // Initialize Recommendations Cache hooks (invalidation on product save/delete) // Only when WooCommerce is active (recommendations are WooCommerce-specific) if ( class_exists( 'WooCommerce' ) && class_exists( 'AIEO_Recommendations_Cache' ) ) { AIEO_Recommendations_Cache::init_hooks(); } // Discount & Marketplace Management bootstrap (menu, modules, hooks). if ( class_exists( 'AIEO_DMM' ) ) { AIEO_DMM::instance(); } // Saved & Abandoned Carts — boots the storefront save/share/resume // hooks + the hourly abandoned-cart capture job. Tables install lazily. // Skipped when the runtime plugin is present: it owns the frontend // surface (AIEO_RUNTIME_OWNS_AJAX) and instantiates AIEO_Carts itself, // so the feature keeps working on trimmed-whitelist requests where this // admin plugin isn't loaded. (AIEO_Carts::instance() is a singleton, so // even un-gated double calls are harmless — this just honours the contract.) if ( class_exists( 'AIEO_Carts' ) && ! defined( 'AIEO_RUNTIME_OWNS_AJAX' ) ) { AIEO_Carts::instance(); } // IAPI control panel — admin-only. if ( is_admin() && class_exists( 'AIEO_IAPI_Admin' ) ) { AIEO_IAPI_Admin::instance(); } // Wishlist — table install runs lazily on plugins_loaded via the // class's own maybe_install(); this just kicks the singleton so // the AJAX hooks + asset localisation register on every load. if ( class_exists( 'AIEO_Wishlist' ) ) { AIEO_Wishlist::instance(); } // PPE compatibility shim — routes legacy `om_ppe_wishlist_toggle` // AJAX calls to the AIEO endpoint + emits a shim `omPPEWishlist` // global. Safe to run alongside PPE (our handler wins on priority). if ( class_exists( 'AIEO_Wishlist_PPE_Compat' ) ) { AIEO_Wishlist_PPE_Compat::instance(); } // Star Ratings — table install runs lazily; this kicks the singleton // so the AJAX hooks + asset enqueue + admin menu register on every load. if ( class_exists( 'AIEO_Ratings' ) ) { AIEO_Ratings::instance(); } if ( class_exists( 'AIEO_Ratings_REST' ) ) { AIEO_Ratings_REST::instance(); } // Product Swatches runtime — frontend renderer that emits per-card // swatch markup from `wp_aieo_dmm_swatches_cache` without // instantiating WC_Product_Variation objects. Gated internally by // `aieo_dmm_swatches_runtime_enabled` so the kill-switch flip // happens via the Swatches admin tab. AJAX class always registers // its endpoints so the cart/quick-view round-trips work regardless // of the render flag. if ( class_exists( 'AIEO_DMM_Swatches_Runtime' ) ) { AIEO_DMM_Swatches_Runtime::instance(); } if ( class_exists( 'AIEO_DMM_Swatches_Ajax' ) ) { AIEO_DMM_Swatches_Ajax::instance(); } // Core Brand Vitals — registers auto-mark-dirty hooks on product/term // saves and the AJAX bundle endpoint for the brand-slider block. if ( class_exists( 'AIEO_Core_Brand_Vitals' ) ) { AIEO_Core_Brand_Vitals::instance(); } // Attribution — boots the singleton so AJAX endpoints + schema // install + order backfill hook all register. Cheap no-op when // the table is already in place. if ( class_exists( 'AIEO_Attribution' ) ) { AIEO_Attribution::instance(); } // Let the pricing engine preload per-request on archive/single views. if ( class_exists( 'AIEO_DMM_Pricing_Engine' ) ) { AIEO_DMM_Pricing_Engine::instance()->register_preload_hooks(); } } add_action('plugins_loaded', 'AIEO_init'); function aieo_is_index_wp_mysql_for_speed_active() { if (!function_exists('is_plugin_active')) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active('index-wp-mysql-for-speed/index-wp-mysql-for-speed.php'); } function aieo_set_default_options() { global $wpdb; if (get_option('aieo_version') === false) { add_option('aieo_version', AIEO_VERSION, '', 'no'); } // Check if the option already exists. If not, add it. if (get_option('aieo_site_prefix') === false) { // Set the value for 'aieo_site_prefix' option $prefix_name = $wpdb->prefix; add_option('aieo_site_prefix', $prefix_name, '', 'no'); } // Always ensure correct table names (fix old installations with wrong suffix) $correct_orders_table = $wpdb->prefix . 'aieo_temp_orders'; // Use update_option instead of add_option to ensure immediate availability update_option('aieo_site_orders_table', $correct_orders_table, 'no'); $correct_store_orders_table = $wpdb->prefix . 'aieo_temp_store_orders'; update_option('aieo_store_orders_table', $correct_store_orders_table, 'no'); update_option('aieo_offline_orders', $correct_store_orders_table, 'no'); $correct_products_table = $wpdb->prefix . 'aieo_temp_product_catalogue'; update_option('aieo_site_products_table', $correct_products_table, 'no'); if (get_option('aieo_core_attribute_brands') === false) { // Set the value for 'aieo_core_attribute_brands' option $default_attribute_for_brand = 'product_brand'; add_option('aieo_core_attribute_brands', $default_attribute_for_brand, '', 'no'); } if (get_option('aieo_core_attribute_size') === false) { // Set the value for 'aieo_core_attribute_size' option $default_attribute_for_size = 'pa_size,pa_noumero,pa_megethos'; add_option('aieo_core_attribute_size', $default_attribute_for_size, '', 'no'); } if (get_option('aieo_core_attribute_color') === false) { // Set the value for 'aieo_core_attribute_color' option $default_attribute_for_color = 'pa_color,pa_colour'; add_option('aieo_core_attribute_color', $default_attribute_for_color, '', 'no'); } if (get_option('aieo_core_attribute_need') === false) { // Set the value for 'aieo_core_attribute_need' option $default_attribute_for_need = 'pa_need'; add_option('aieo_core_attribute_need', $default_attribute_for_need, '', 'no'); } if (get_option('aieo_season_days_past') === false) { // Set the value for 'aieo_season_days_past' option $default_season_days_past = '15'; add_option('aieo_season_days_past', $default_season_days_past, '', 'no'); } if (get_option('aieo_analytics_eponymous_id_field') === false) { // Set the value for 'aieo_analytics_eponymous_id_field' option $default_analytics_eponymous_id_field = 'EponymousId'; add_option('aieo_analytics_eponymous_id_field', $default_analytics_eponymous_id_field, '', 'no'); } if (get_option('aieo_season_days_after') === false) { // Set the value for 'aieo_season_days_after' option $default_season_days_after = '60'; add_option('aieo_season_days_after', $default_season_days_after, '', 'no'); } // Use update_option instead of add_option to ensure immediate availability $table_name = $wpdb->prefix . 'aieo_core_product_attributes'; update_option('aieo_core_product_attributes_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_product_category_hierarchy'; update_option('aieo_core_product_category_hierarchy_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_category_brand_popularity'; update_option('aieo_core_category_brand_popularity_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_product_vitals'; update_option('aieo_core_product_vitals_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_variation_vitals'; update_option('aieo_core_variation_vitals_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_reciprocal_products'; update_option('aieo_core_reciprocal_products_table', $table_name, 'no'); $table_name = $wpdb->prefix . 'aieo_core_brand_vitals'; update_option('aieo_core_brand_vitals_table', $table_name, 'no'); if (get_option('aieo_erp_store_products_table') === false) { // Set the value for 'aieo_erp_store_products_table' option $table_name = $wpdb->prefix . 'aieo_erp_store_products'; add_option('aieo_erp_store_products_table', $table_name, '', 'no'); } if (get_option('aieo_erp_store_orders_table') === false) { // Set the value for 'aieo_erp_store_orders_table' option $table_name = $wpdb->prefix . 'aieo_erp_store_orders'; add_option('aieo_erp_store_orders_table', $table_name, '', 'no'); } if (get_option('aieo_erp_store_customers_table') === false) { // Set the value for 'aieo_erp_store_customers_table' option $table_name = $wpdb->prefix . 'aieo_erp_store_customers'; add_option('aieo_erp_store_customers_table', $table_name, '', 'no'); } if (get_option('aieo_google_user_journey_table') === false) { // Set the value for 'aieo_google_user_journey_table' option $table_name = $wpdb->prefix . 'aieo_google_user_journey'; add_option('aieo_google_user_journey_table', $table_name, '', 'no'); } if (get_option('aieo_order_id') === false) { add_option('aieo_order_id', '1', '', 'no'); } if (get_option('aieo_max_records') === false) { add_option('aieo_max_records', '1000', '', 'no'); } if (get_option('aieo_batch_size') === false) { add_option('aieo_batch_size', '100', '', 'no'); } if (get_option('aieo_batch_sleep') === false) { add_option('aieo_batch_sleep', '0', '', 'no'); } if (get_option('aieo_include_text') === false) { add_option('aieo_include_text', '1', '', 'no'); } if (get_option('aieo_include_attributes') === false) { add_option('aieo_include_attributes', '1', '', 'no'); } // Export checkbox options - default to '0' (unchecked) so user chooses what to run if (get_option('aieo_create_attributes') === false) { add_option('aieo_create_attributes', '0', '', 'no'); } if (get_option('aieo_create_needs') === false) { add_option('aieo_create_needs', '0', '', 'no'); } if (get_option('aieo_create_need_suggestions') === false) { add_option('aieo_create_need_suggestions', '0', '', 'no'); } if (get_option('aieo_create_catalogue') === false) { add_option('aieo_create_catalogue', '0', '', 'no'); } if (get_option('aieo_create_orders') === false) { add_option('aieo_create_orders', '0', '', 'no'); } if (get_option('aieo_erp_consolidation') === false) { add_option('aieo_erp_consolidation', '0', '', 'no'); } if (get_option('aieo_erp_merge_orders') === false) { add_option('aieo_erp_merge_orders', '0', '', 'no'); } if (get_option('aieo_include_unsold') === false) { add_option('aieo_include_unsold', '0', '', 'no'); } if (get_option('aieo_include_aggregate_stats') === false) { add_option('aieo_include_aggregate_stats', '0', '', 'no'); } if (get_option('aieo_include_aggregate_category_brand_stats') === false) { add_option('aieo_include_aggregate_category_brand_stats', '0', '', 'no'); } if (get_option('aieo_include_aggregate_cust_std_stats') === false) { add_option('aieo_include_aggregate_cust_std_stats', '0', '', 'no'); } if (get_option('aieo_include_aggregate_cust_adv_stats') === false) { add_option('aieo_include_aggregate_cust_adv_stats', '0', '', 'no'); } if (get_option('aieo_create_uuids') === false) { add_option('aieo_create_uuids', '0', '', 'no'); } if (get_option('aieo_create_export') === false) { add_option('aieo_create_export', '0', '', 'no'); } if (get_option('aieo_include_full_text') === false) { add_option('aieo_include_full_text', '0', '', 'no'); } if (get_option('aieo_include_price') === false) { add_option('aieo_include_price', '1', '', 'no'); } if (get_option('aieo_variations_choice') === false) { add_option('aieo_variations_choice', '1', '', 'no'); } if (get_option('aieo_include_eponymous') === false) { add_option('aieo_include_eponymous', '1', '', 'no'); } if (get_option('aieo_include_reciprocity') === false) { add_option('aieo_include_reciprocity', '0', '', 'no'); } if (get_option('aieo_include_seasonality') === false) { add_option('aieo_include_seasonality', '0', '', 'no'); } if (get_option('aieo_include_restricted_seasonality') === false) { add_option('aieo_include_restricted_seasonality', '0', '', 'no'); } if (get_option('aieo_instore_anonymous') === false) { add_option('aieo_instore_anonymous', '0', '', 'no'); } if (get_option('aieo_activate_utm_stats') === false) { add_option('aieo_activate_utm_stats', '0', '', 'no'); } if (get_option('aieo_google_ga4') === false) { add_option('aieo_google_ga4', '0', '', 'no'); } if (get_option('aieo_display_upsells') === false) { add_option('aieo_display_upsells', '0', '', 'no'); } if (get_option('aieo_display_cross_sells') === false) { add_option('aieo_display_cross_sells', '0', '', 'no'); } if (get_option('aieo_display_related') === false) { add_option('aieo_display_related', '0', '', 'no'); } if (get_option('aieo_display_recently_viewed_products') === false) { add_option('aieo_display_recently_viewed_products', '0', '', 'no'); } if (get_option('aieo_display_same_needs') === false) { add_option('aieo_display_same_needs', '0', '', 'no'); } if (get_option('aieo_display_categories_by_sales') === false) { add_option('aieo_display_categories_by_sales', '0', '', 'no'); } if (get_option('aieo_category_sales_period') === false) { add_option('aieo_category_sales_period', 'all_time', '', 'no'); } if (get_option('aieo_season_days_past') === false) { add_option('aieo_season_days_past', '15', '', 'no'); } if (get_option('aieo_season_days_after') === false) { add_option('aieo_season_days_after', '60', '', 'no'); } // Record initial activation date (only set once, never updated) if (get_option('aieo_initial_activation_date') === false) { add_option('aieo_initial_activation_date', current_time('mysql', true), '', 'no'); } } register_activation_hook(__FILE__, 'aieo_set_default_options'); // ----------------------------------------------------------------------------- // MU-plugin installer // ----------------------------------------------------------------------------- // AIEO ships two companion mu-plugins: // - 0-aieo-action-scheduler-core.php — AS query-limiting + retention + bypass // switch. The `0-` prefix forces it to load first (alphabetical order in // mu-plugins). Required by the new AIEO_DMM_As_Cleanup module. // - aieo-autoload.php — class autoloader for ai-eshop-optimizer + // ai-eshop-optimizer-runtime so neither plugin needs `require_once` chains // for AIEO_* classes. // Both ship inside this plugin at install/mu-plugins/. On activation we copy // them into wp-content/mu-plugins/ if either is missing OR if the bundled // version's mtime is newer than what's already there (auto-refresh on update). // Filesystem errors are reported via a transient admin notice so the operator // knows to copy them by hand on hosts where mu-plugins/ isn't writable. register_activation_hook(__FILE__, 'aieo_install_bundled_mu_plugins'); function aieo_install_bundled_mu_plugins() { $src_dir = plugin_dir_path(__FILE__) . 'install/mu-plugins/'; if (!is_dir($src_dir)) { return; } $dst_dir = defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins'; if (!is_dir($dst_dir)) { // wp_mkdir_p() honours fs perms set by the host's WP config. if (!wp_mkdir_p($dst_dir)) { set_transient( 'aieo_mu_install_error', sprintf('Could not create %s. Copy install/mu-plugins/* there manually.', $dst_dir), DAY_IN_SECONDS ); return; } } // Files we ship, with optional `supersedes` clauses. Listed peer files // are legacy forks owned by the OM Optimizer Hub family. AIEO is the // canonical owner of these mu-plugins going forward — we install the // AIEO version and then DELETE the legacy peer so only one provider // of each function lives in mu-plugins/ at a time. AIEO's files // include om_* function aliases (wrapped in `function_exists()` guards // — see install/mu-plugins/0-aieo-action-scheduler-core.php tail) so // any caller of the old om_as_* names keeps working after the legacy // file is removed. $files = array( '0-aieo-action-scheduler-core.php' => array( 'supersedes' => array('0-om-action-scheduler-core.php'), ), 'aieo-autoload.php' => array(), // Lean front-end redirect server — must-use so it survives the // active_plugins load-optimisation that trims the AIEO runtime/core on // storefront + 404 paths. Loads ONLY the bundled AIEO_SEO_Redirects // class (via the autoloader above), never the full plugin. 'aieo-seo-redirects.php' => array(), 'aieo-as-ajax-query-protection.php' => array( 'supersedes' => array('om-as-ajax-query-protection.php'), ), // Collapses runaway recurring AS jobs (AutomateWoo's // `delete_old_logs/start`, which the AS-ajax read protection above // would otherwise let duplicate on every admin_init/heartbeat) back to // a single pending action. See the file header for the full mechanism. 'aieo-as-dedup-recurring-jobs.php' => array(), ); $failed = array(); foreach ($files as $name => $opts) { $src = $src_dir . $name; $dst = $dst_dir . '/' . $name; if (!file_exists($src)) { continue; } // 1. Copy the bundled AIEO version into mu-plugins/ (idempotent — // skip if the target is missing OR bundled is newer). $needs_copy = !file_exists($dst) || @filemtime($dst) < @filemtime($src); if ($needs_copy) { if (!@copy($src, $dst)) { $failed[] = $name; continue; } } // 2. Retire legacy forks AFTER the replacement is in place. // Two-step (copy then unlink) — never leaves the site without // the function definitions, even for a single request. Bundled // AIEO file exposes om_as_* aliases so legacy callers keep // working transparently. if (!empty($opts['supersedes']) && is_array($opts['supersedes'])) { foreach ($opts['supersedes'] as $peer) { if ($peer === $name) { continue; } $peer_path = $dst_dir . '/' . $peer; if (file_exists($peer_path)) { @unlink($peer_path); } } } } if (!empty($failed)) { set_transient( 'aieo_mu_install_error', 'Could not write to ' . $dst_dir . '. Copy these by hand from install/mu-plugins/: ' . implode(', ', $failed), DAY_IN_SECONDS ); } } // Surface the install error (if any) as an admin notice on the next page load. add_action('admin_notices', function () { $msg = get_transient('aieo_mu_install_error'); if (!$msg || !current_user_can('manage_options')) { return; } printf( '

AIEO: %s

', esc_html($msg) ); }); // Inside your plugin's main file or in a file that gets loaded during plugin activation // Register a hook to execute your table creation function during plugin activation register_activation_hook(__FILE__, 'aieo_create_temp_tables_activation_hook'); // Define the activation hook function function aieo_create_temp_tables_activation_hook() { global $wpdb; // The table creators live in the lazily-loaded SQL layer. aieo_load_sql_layer(); // Enable error reporting for debugging $wpdb->show_errors(); aieo_debug_log('AIEO: Starting table creation activation hook'); // Load WordPress database manipulation functions require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); // CRITICAL FIX: Set options for backward compatibility with stored procedures // that still read these options aieo_set_default_options(); aieo_debug_log('AIEO: Options set'); // CRITICAL FIX: Call table creation functions WITHOUT parameters // Table names are now constructed internally like chat tables (using $wpdb->prefix directly) // This eliminates dependency on options and prevents race conditions aieo_debug_log('AIEO: Creating temp_orders table'); aieo_create_temp_orders_table(); aieo_debug_log('AIEO: Creating temp_store_orders table'); aieo_create_temp_store_orders_table(); aieo_debug_log('AIEO: Creating temp_products table'); aieo_create_temp_products_table(); aieo_debug_log('AIEO: Creating core_product_attributes table'); aieo_create_core_product_attributes_table(); aieo_debug_log('AIEO: Creating core_product_category_hierarchy table'); aieo_create_core_product_category_hierarchy_table(); aieo_debug_log('AIEO: Creating core_category_brand_popularity table'); aieo_create_core_category_brand_popularity_table(); aieo_debug_log('AIEO: Creating core_product_vitals table'); aieo_create_core_product_vitals_table(); aieo_debug_log('AIEO: Creating core_variation_vitals table'); aieo_create_core_variation_vitals_table(); aieo_debug_log('AIEO: Creating core_reciprocal_products table'); aieo_create_core_reciprocal_products_table(); aieo_debug_log('AIEO: Creating core_brand_vitals table'); aieo_create_core_brand_vitals_table(); // Create AI Chat tables aieo_debug_log('AIEO: Creating chat_embeddings table'); aieo_create_chat_embeddings_table(); aieo_debug_log('AIEO: Creating chat_history table'); aieo_create_chat_history_table(); // Create Session Tracker tables aieo_debug_log('AIEO: Creating sessions table'); aieo_create_sessions_table(); aieo_debug_log('AIEO: Creating session archive tables'); aieo_create_session_archive_tables(); // Session-analytics pipeline tables (journeys + product metrics). aieo_debug_log('AIEO: Creating session_journeys table'); aieo_create_session_journeys_table(); aieo_debug_log('AIEO: Creating product_session_metrics table'); aieo_create_product_session_metrics_table(); // Schedule the session-analytics cron jobs (idempotent; the cron handlers // are registered per-request by aieo_bootstrap_session_analytics()). $sa_proc_file = plugin_dir_path(__FILE__) . 'includes/class-session-analytics-processor.php'; if (file_exists($sa_proc_file)) { require_once $sa_proc_file; if (class_exists('AIEO_Session_Analytics_Processor')) { AIEO_Session_Analytics_Processor::get_instance()->schedule_analytics_cron(); } } // Create AI Models Registry table aieo_debug_log('AIEO: Creating ai_models table'); aieo_create_ai_models_table(); // Create Product Recommendations Cache table aieo_debug_log('AIEO: Creating product_recommendations table'); aieo_create_product_recommendations_table(); // Create the reco reverse-reference map (DB #34) — indexed invalidation. aieo_debug_log('AIEO: Creating reco_reverse_refs table'); aieo_create_reco_reverse_refs_table(); // Create Product Need Suggestions table aieo_debug_log('AIEO: Creating product_need_suggestions table'); aieo_create_product_need_suggestions_table(); // Create Discount & Marketplace Management (DMM) tables and migrate legacy data. if (function_exists('aieo_dmm_create_tables')) { aieo_debug_log('AIEO: Creating DMM tables'); aieo_dmm_create_tables(); } // Provision the /brand-shopping page (idempotent — only inserts if // missing). Loads the installer file directly so activation works // even before plugins_loaded fires. $brand_page_installer = plugin_dir_path(__FILE__) . 'includes/dmm/class-aieo-brand-page-installer.php'; if (file_exists($brand_page_installer)) { require_once $brand_page_installer; if (class_exists('AIEO_Brand_Page_Installer')) { $page_id = AIEO_Brand_Page_Installer::install(); aieo_debug_log('AIEO: Brand-shopping page install -> ' . (is_wp_error($page_id) ? $page_id->get_error_message() : (string) $page_id)); } } // Run version-gated DB migrations now that every table exists (idempotent; // covers both fresh activation and the version-bump upgrade path that also // calls this function). Index trim (#33), reco reverse-ref backfill (#34), // analytics atomic-swap procs (#35). aieo_run_db_migrations(); aieo_debug_log('AIEO: Table creation activation hook completed'); } function aieo_create_stored_procedures() { global $wpdb; // The aieo_create_sp_* / aieo_drop_sp_* installers live in the // lazily-loaded SQL layer. aieo_load_sql_layer(); // CRITICAL FIX: Ensure options are set BEFORE trying to read them // This fixes the race condition where stored procedures weren't created on first activation aieo_set_default_options(); $sp_prefix = get_option('aieo_site_prefix'); $sp_orders_table = get_option('aieo_site_orders_table'); $sp_store_orders_table = get_option('aieo_store_orders_table'); $sp_products_table = get_option('aieo_site_products_table'); $sp_core_product_attributes_table_name = get_option('aieo_core_product_attributes_table'); $sp_core_product_category_hierarchy_table_name = get_option('aieo_core_product_category_hierarchy_table'); $sp_core_product_vitals_table_name = get_option('aieo_core_product_vitals_table'); $sp_core_variation_vitals_table_name = get_option('aieo_core_variation_vitals_table'); $sp_erp_store_products_table_name = get_option('aieo_erp_store_products_table'); $sp_erp_store_orders_table_name = get_option('aieo_erp_store_orders_table'); $sp_erp_store_customers_table_name = get_option('aieo_erp_store_customers_table'); $sp_google_user_journey_table_name = get_option('aieo_google_user_journey_table'); $domain_name = get_site_url(); // CRITICAL FIX: Drop all procedures BEFORE creating them // This prevents "PROCEDURE already exists" errors during activation // Suppress errors during drop (procedures might not exist on first activation) $wpdb->hide_errors(); $wpdb->suppress_errors(true); @aieo_drop_sp_AIEO_InsertCoreProductAttributes($sp_prefix); @aieo_drop_sp_AIEO_InsertProductCategoryHierarchy($sp_prefix); @aieo_drop_sp_AIEO_InsertProductVitals($sp_prefix); @aieo_drop_sp_AIEO_InsertVariationVitals($sp_prefix); @aieo_drop_sp_AIEO_CombineVariationVitalsInProductVitals($sp_prefix); @aieo_drop_sp_AIEO_UpdateOrdersWithUnsold($sp_prefix); @aieo_drop_sp_AIEO_FindDifferences($sp_prefix); @aieo_drop_sp_AIEO_Create_Product_Catalogue_HPOS($sp_prefix); @aieo_drop_sp_AIEO_Export_Orders_HPOS($sp_prefix); @aieo_drop_sp_AIEO_Export_Store_Orders($sp_prefix); @aieo_drop_sp_AIEO_Merge_Store_Orders($sp_prefix); @aieo_drop_sp_AIEO_Export_Orders_non_HPOS($sp_prefix); @aieo_drop_sp_AIEO_RenameTable($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentWordCount($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentOutgoingLinks($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentIntroPlain($sp_prefix); @aieo_drop_sp_AIEO_UpdateProductCentricStats($sp_prefix); @aieo_drop_sp_AIEO_UpdateCustomerCentricStdStats($sp_prefix); @aieo_drop_sp_AIEO_UpdateCustomerCentricAdvStats($sp_prefix); @aieo_drop_sp_AIEO_GenerateGraphDBUUIDs($sp_prefix); @aieo_drop_sp_AIEO_GenerateGraphDBUUIDFreqs($sp_prefix); @aieo_drop_sp_AIEO_PopulateProductNeeds($sp_prefix); // Drop MySQL functions @aieo_drop_function_AIEO_StripHTML(); @aieo_drop_function_AIEO_CountWords(); @aieo_drop_function_AIEO_CountLinks(); // Drop legacy procedures that are no longer created but may exist from older versions @aieo_drop_sp_AIEO_Include_Products_Without_Sales(); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- DDL statement @$wpdb->query("DROP PROCEDURE IF EXISTS UpdateShortDescriptionForProduct"); // Re-enable error reporting for CREATE statements $wpdb->show_errors(); $wpdb->suppress_errors(false); // Create optimized v2 procedures aieo_create_sp_AIEO_InsertCoreProductAttributes($sp_prefix, $sp_core_product_attributes_table_name); aieo_create_sp_AIEO_InsertProductCategoryHierarchy($sp_prefix, $sp_core_product_category_hierarchy_table_name); aieo_create_sp_AIEO_InsertProductVitals($sp_prefix, $sp_core_product_vitals_table_name, $domain_name); aieo_create_sp_AIEO_InsertVariationVitals($sp_prefix, $sp_core_variation_vitals_table_name); aieo_create_sp_AIEO_CombineVariationVitalsInProductVitals($sp_prefix, $sp_core_product_vitals_table_name, $domain_name); aieo_create_sp_AIEO_UpdateOrdersWithUnsold($sp_prefix, $sp_orders_table, $sp_products_table); aieo_create_sp_AIEO_FindDifferences($sp_prefix); aieo_create_sp_AIEO_Create_Product_Catalogue_HPOS($sp_prefix, $sp_products_table); aieo_create_sp_AIEO_Export_Orders_HPOS($sp_prefix, $sp_orders_table); aieo_create_sp_AIEO_Export_Store_Orders($sp_prefix, $sp_store_orders_table, $sp_erp_store_products_table_name, $sp_erp_store_orders_table_name, $sp_erp_store_customers_table_name); aieo_create_sp_AIEO_Merge_Store_Orders($sp_prefix); aieo_create_sp_AIEO_Export_Orders_non_HPOS($sp_prefix, $sp_orders_table); // UNUSED - Never called in current codebase aieo_create_sp_AIEO_RenameTable($sp_prefix); aieo_create_sp_AIEO_UpdateContentWordCount($sp_prefix); aieo_create_sp_AIEO_UpdateContentOutgoingLinks($sp_prefix); aieo_create_sp_AIEO_UpdateContentIntroPlain($sp_prefix); aieo_create_sp_UpdateProductCentricStats($sp_prefix); aieo_create_sp_AIEO_UpdateCustomerCentricStdStats_Smart($sp_prefix, $sp_orders_table); aieo_create_sp_AIEO_UpdateCustomerCentricAdvStats($sp_prefix, $sp_orders_table); aieo_create_sp_AIEO_GenerateGraphDBUUIDs($sp_prefix); aieo_create_sp_AIEO_GenerateGraphDBUUIDFreqs($sp_prefix); aieo_create_sp_AIEO_PopulateProductNeeds($sp_prefix); // UNUSED - Never called in current codebase // aieo_create_sp_AIEO_Orchestrate_all_non_HPOS_SPs($sp_prefix); aieo_create_function_AIEO_StripHTML(); aieo_create_function_AIEO_CountWords(); aieo_create_function_AIEO_CountLinks(); } function aieo_drop_stored_procedures($drop_tables = true) { global $wpdb; // $drop_tables=false → refresh procedures/functions ONLY, leave every // table (and its data) in place. The version-bump upgrade path passes // false so a release no longer wipes the materialized working tables or // live session data (see aieo_check_for_plugin_update). Deactivation // still passes the default true and honours the per-table retention // options below. // The aieo_drop_sp_* installers live in the lazily-loaded SQL layer. aieo_load_sql_layer(); aieo_debug_log('AIEO: Starting deactivation - aieo_drop_stored_procedures()'); // Set aggressive timeouts set_time_limit(120); @ini_set('max_execution_time', 120); @ini_set('mysql.connect_timeout', 10); // Suppress errors during cleanup $wpdb->hide_errors(); $wpdb->suppress_errors(true); $sp_prefix = get_option('aieo_site_prefix'); // CRITICAL FIX: Construct table names directly using $wpdb->prefix (same as creation functions) // This ensures tables are properly dropped even if options are empty/outdated $sp_temp_orders_table_name = $wpdb->prefix . 'aieo_temp_orders'; $sp_temp_store_orders_table_name = $wpdb->prefix . 'aieo_temp_store_orders'; $sp_temp_products_table_name = $wpdb->prefix . 'aieo_temp_product_catalogue'; $sp_core_product_attributes_table_name = $wpdb->prefix . 'aieo_core_product_attributes'; $sp_core_product_category_hierarchy_table_name = $wpdb->prefix . 'aieo_core_product_category_hierarchy'; $sp_core_category_brand_popularity_table_name = $wpdb->prefix . 'aieo_core_category_brand_popularity'; $sp_core_product_vitals_table_name = $wpdb->prefix . 'aieo_core_product_vitals'; $sp_core_variation_vitals_table_name = $wpdb->prefix . 'aieo_core_variation_vitals'; $sp_core_reciprocal_products_table_name = $wpdb->prefix . 'aieo_core_reciprocal_products'; $sp_core_brand_vitals_table_name = $wpdb->prefix . 'aieo_core_brand_vitals'; $sp_ai_models_table_name = $wpdb->prefix . 'aieo_ai_models'; $sp_performance_tuning_table_name = $wpdb->prefix . 'aieo_performance_tuning'; aieo_debug_log('AIEO: Table names constructed. Prefix: ' . $wpdb->prefix); aieo_debug_log('AIEO: Will drop: ' . $sp_temp_orders_table_name); @aieo_drop_sp_AIEO_InsertCoreProductAttributes($sp_prefix); @aieo_drop_sp_AIEO_InsertProductCategoryHierarchy($sp_prefix); @aieo_drop_sp_AIEO_InsertProductVitals($sp_prefix); @aieo_drop_sp_AIEO_InsertVariationVitals($sp_prefix); @aieo_drop_sp_AIEO_CombineVariationVitalsInProductVitals($sp_prefix); @aieo_drop_sp_AIEO_UpdateOrdersWithUnsold($sp_prefix); @aieo_drop_sp_AIEO_FindDifferences($sp_prefix); @aieo_drop_sp_AIEO_Create_Product_Catalogue_HPOS($sp_prefix); @aieo_drop_sp_AIEO_Export_Orders_HPOS($sp_prefix); @aieo_drop_sp_AIEO_Export_Store_Orders($sp_prefix); @aieo_drop_sp_AIEO_Export_Orders_non_HPOS($sp_prefix); @aieo_drop_sp_AIEO_Merge_Store_Orders($sp_prefix); // UNUSED - Never called in current codebase // @aieo_drop_sp_AIEO_StripHTMLAndReplicateContentIntro($sp_prefix); @aieo_drop_sp_AIEO_RenameTable($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentWordCount($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentOutgoingLinks($sp_prefix); @aieo_drop_sp_AIEO_UpdateContentIntroPlain($sp_prefix); @aieo_drop_sp_AIEO_UpdateProductCentricStats($sp_prefix); // V1 statistics procedures - DEPRECATED - No longer created // @aieo_drop_sp_AIEO_UpdateCustomerCentricAdvStats($sp_prefix); @aieo_drop_sp_AIEO_UpdateCustomerCentricAdvStats($sp_prefix); @aieo_drop_sp_AIEO_GenerateGraphDBUUIDs($sp_prefix); @aieo_drop_sp_AIEO_GenerateGraphDBUUIDFreqs($sp_prefix); @aieo_drop_sp_AIEO_PopulateProductNeeds($sp_prefix); // @aieo_drop_sp_AIEO_Orchestrate_all_non_HPOS_SPs($sp_prefix); // @aieo_drop_sp_AIEO_UpdateCustomerCentricStdStats($sp_prefix); @aieo_drop_sp_AIEO_UpdateCustomerCentricStdStats_v2($sp_prefix); @aieo_drop_sp_AIEO_UpdateCustomerCentricStdStats($sp_prefix); // Drop legacy procedures that are no longer created but may exist from older versions @aieo_drop_sp_AIEO_Include_Products_Without_Sales(); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- DDL statement @$wpdb->query("DROP PROCEDURE IF EXISTS UpdateShortDescriptionForProduct"); // Drop functions SECOND (they don't depend on tables) @aieo_drop_function_AIEO_StripHTML(); @aieo_drop_function_AIEO_CountWords(); @aieo_drop_function_AIEO_CountLinks(); // Table drops are skipped entirely when $drop_tables is false (the // version-bump path) — procedures/functions above are refreshed, data // tables are preserved. if ($drop_tables) { // Drop tables LAST (after nothing references them) // These are "working" tables that are rebuilt on activation - safe to drop // SIMPLIFIED: Just drop tables directly - DROP TABLE automatically removes indexes // This avoids the slow index-by-index dropping that was causing hangs aieo_debug_log('AIEO: Dropping working tables (direct DROP TABLE)...'); $tables_to_drop = array( $sp_core_product_attributes_table_name, $sp_core_product_category_hierarchy_table_name, $sp_core_category_brand_popularity_table_name, $sp_core_product_vitals_table_name, $sp_core_variation_vitals_table_name, $sp_core_reciprocal_products_table_name, $sp_core_brand_vitals_table_name, $sp_temp_orders_table_name, $sp_temp_store_orders_table_name, $sp_temp_products_table_name, $sp_ai_models_table_name, $sp_performance_tuning_table_name, ); foreach ($tables_to_drop as $table) { aieo_debug_log('AIEO: Dropping table ' . $table); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS `{$table}`"); aieo_debug_log('AIEO: Dropped ' . $table); } // Data retention based on user settings (configured in Chat Settings > Process Automation) // Default behavior: keep embeddings and chat history, delete session archives // Chat Embeddings table if (get_option('aieo_keep_embeddings_on_deactivation', '1') !== '1') { $embeddings_table = $wpdb->prefix . 'aieo_chat_embeddings'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$embeddings_table}"); aieo_debug_log('AIEO: Dropped chat embeddings table (user preference)'); } // Chat History table if (get_option('aieo_keep_chat_history_on_deactivation', '1') !== '1') { $chat_history_table = $wpdb->prefix . 'aieo_chat_history'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$chat_history_table}"); aieo_debug_log('AIEO: Dropped chat history table (user preference)'); } // Session Archive tables (default: DROP - not kept unless explicitly requested) if (get_option('aieo_keep_session_archive_on_deactivation', '0') !== '1') { $archive_table = $wpdb->prefix . 'aieo_sessions_archive'; $archive_statics_table = $wpdb->prefix . 'aieo_sessions_statics_archive'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$archive_table}"); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$archive_statics_table}"); aieo_debug_log('AIEO: Dropped session archive tables (user preference)'); } // Session Tracker tables (transient/live data) // Default: DROP - these are working tables that get rebuilt on activation // User can choose to keep them if they want to preserve session data across deactivations if (get_option('aieo_keep_sessions_on_deactivation', '0') !== '1') { $sessions_table = $wpdb->prefix . 'aieo_sessions'; $sessions_statics_table = $wpdb->prefix . 'aieo_sessions_statics'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$sessions_table}"); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- DDL statement with safe table name $wpdb->query("DROP TABLE IF EXISTS {$sessions_statics_table}"); aieo_debug_log('AIEO: Dropped session tracker tables (user preference)'); } aieo_debug_log('AIEO: Deactivation complete - tables handled per user data retention settings'); } // end if ($drop_tables) // Re-enable errors $wpdb->show_errors(); $wpdb->suppress_errors(false); } register_deactivation_hook(__FILE__, 'aieo_drop_stored_procedures'); register_activation_hook(__FILE__, 'aieo_create_stored_procedures'); /** * Version-gated DB migrations, folded in from the validated * BetaDistribution/db-migrations artifacts so a normal plugin update applies them * (keeping local dev + Apollo production aligned). Gated on the `aieo_db_version` * option vs AIEO_DB_VERSION; each step probes INFORMATION_SCHEMA / option state * before doing irreversible work, so it is safe to call on every activation and * version-bump. Wrapped in a connection-scoped GET_LOCK so concurrent PHP-FPM * workers don't run it twice (it auto-releases on connection close, so a fatal * mid-migration can't wedge it). See docs/DB-OPTIMIZATION-STAGED-2026-06.md. * * v1 — #33 sessions index trim (drop left-prefix-redundant indexes) * v2 — #34 reco reverse-ref table + one-time backfill + readiness flag * v3 — #35 analytics atomic-swap aggregate procedures (force reinstall) * * @return void */ function aieo_run_db_migrations() { global $wpdb; $target = (int) AIEO_DB_VERSION; $current = (int) get_option('aieo_db_version', 0); if ($current >= $target) { return; // up to date — zero DB work on the hot path. } // Only one worker migrates; the rest bail. Auto-releases on connection close. $got_lock = $wpdb->get_var("SELECT GET_LOCK('aieo_db_migrations', 0)"); if ($got_lock !== '1' && $got_lock !== 1) { return; } // Re-read inside the lock — another worker may have finished while we waited. $current = (int) get_option('aieo_db_version', 0); // Table/index creators + helpers live in the lazily-loaded SQL layer. if (function_exists('aieo_load_sql_layer')) { aieo_load_sql_layer(); } // Each step advances aieo_db_version ONLY after its mandatory dependency was // available and the work actually ran — a missing dependency (partial deploy, // opcache, parse error) leaves the version unchanged so the step retries on the // next run instead of permanently marking incomplete work as done. // ---- v1 / #33 — sessions index trim --------------------------------- // idx_session_id and idx_product_id on wp_aieo_sessions(+ _archive) are pure // left-prefixes of wider composites (idx_session_product/idx_sequence cover // session_id; idx_product_event covers product_id), so dropping them removes // write amplification on the ~5s-per-visitor INSERT table with no read loss. // aieo_drop_index_if_exists() is INFORMATION_SCHEMA-guarded → idempotent. if ($current < 1) { if (function_exists('aieo_drop_index_if_exists')) { $trims = array( array($wpdb->prefix . 'aieo_sessions', 'idx_session_id'), array($wpdb->prefix . 'aieo_sessions', 'idx_product_id'), array($wpdb->prefix . 'aieo_sessions_archive', 'idx_session_id'), array($wpdb->prefix . 'aieo_sessions_archive', 'idx_product_id'), ); foreach ($trims as $t) { aieo_drop_index_if_exists($t[1], $t[0]); } update_option('aieo_db_version', 1, false); $current = 1; } // else: SQL layer not loaded — leave at 0, retry next run. } // ---- v2 / #34 — reco reverse-ref table + RESUMABLE background backfill ---- // Create the indexed reverse map, then hand the (potentially large) backfill // to a resumable background job (aieo_reco_reverse_refs_backfill). The readiness // flag aieo_reco_reverse_refs_ready is flipped by THAT job only when the // backfill is provably complete — never here — so the cache keeps using the // legacy LIKE path until the map is fully built. This avoids the // foreground-timeout / restart-loop failure mode of an inline whole-table walk. if ($current < 2) { if (function_exists('aieo_create_reco_reverse_refs_table')) { aieo_create_reco_reverse_refs_table(); // Run one bounded slice now (small caches finish instantly) and let the // job reschedule itself for the remainder. Does NOT set the ready flag // unless it completes in this slice. aieo_reco_reverse_refs_backfill_step(); update_option('aieo_db_version', 2, false); $current = 2; } // else: reverse-ref creator unavailable — leave at 1, retry next run. } // ---- v3 / #35 — analytics atomic-swap aggregate procedures ---------- // The two aggregate procs now build into a shadow table and publish with one // atomic RENAME (no empty-table window for dashboard readers). They are also // reinstalled from the .sql files on every analytics run, but force a refresh // here so the fix is live immediately after the upgrade. if ($current < 3) { if (!class_exists('AIEO_Session_Analytics_Processor')) { $proc_file = plugin_dir_path(__FILE__) . 'includes/class-session-analytics-processor.php'; if (file_exists($proc_file)) { require_once $proc_file; } } if (class_exists('AIEO_Session_Analytics_Processor')) { AIEO_Session_Analytics_Processor::get_instance()->create_stored_procedures(); update_option('aieo_db_version', 3, false); $current = 3; } // else: processor class unavailable — the procs self-heal via the analytics // reinstall path anyway; leave at 2 to retry. } $wpdb->query("SELECT RELEASE_LOCK('aieo_db_migrations')"); } /** * Resumable, bounded backfill of the reco reverse-ref map (#34). Processes one * budgeted slice (rows / seconds) from the persisted high-water id, reschedules * itself while rows remain, and flips aieo_reco_reverse_refs_ready ONLY when the * whole table is done. Hooked on the aieo_reco_reverse_refs_backfill cron event * AND called once inline by the migration so small caches finish immediately. * Serialized by a connection-scoped GET_LOCK so the inline call + a cron tick (or * two ticks) never double-process. * * @return void */ function aieo_reco_reverse_refs_backfill_step() { global $wpdb; if (get_option('aieo_reco_reverse_refs_ready') === '1') { return; // already complete } if (!class_exists('AIEO_Recommendations_Cache')) { $cache_file = plugin_dir_path(__FILE__) . 'includes/class-recommendations-cache.php'; if (file_exists($cache_file)) { require_once $cache_file; } } if (!class_exists('AIEO_Recommendations_Cache')) { return; // can't backfill yet — retry on the next scheduled tick } $got = $wpdb->get_var("SELECT GET_LOCK('aieo_reco_backfill', 0)"); if ($got !== '1' && $got !== 1) { return; // another worker is backfilling } $start = (int) get_option('aieo_reco_reverse_refs_backfill_last_id', 0); // Budget per slice: 3000 rows or 8s, comfortably under any FPM/cron timeout. $res = AIEO_Recommendations_Cache::backfill_reverse_refs($start, 3000, 8); update_option('aieo_reco_reverse_refs_backfill_last_id', (int) $res['last_id'], false); if (!empty($res['done'])) { // Autoloaded scalar — read on the set()/cold-build path, so keep it free. update_option('aieo_reco_reverse_refs_ready', '1', true); delete_option('aieo_reco_reverse_refs_backfill_last_id'); } elseif (!wp_next_scheduled('aieo_reco_reverse_refs_backfill')) { wp_schedule_single_event(time() + 30, 'aieo_reco_reverse_refs_backfill'); } $wpdb->query("SELECT RELEASE_LOCK('aieo_reco_backfill')"); } add_action('aieo_reco_reverse_refs_backfill', 'aieo_reco_reverse_refs_backfill_step'); // Decouple the DB-migration gate from the plugin VERSION bump: also run it on // admin_init whenever aieo_db_version is behind AIEO_DB_VERSION, so a future // db-version-only bump applies without needing AIEO_VERSION to increase. Cheap — // aieo_run_db_migrations() early-returns (one option read) once up to date, and // this never touches storefront requests. add_action('admin_init', 'aieo_run_db_migrations'); /** * Bootstrap the session-analytics pipeline (journeys -> product metrics) ONLY on * cron / admin / WP-CLI requests, so storefront pageviews never load the * processor. On cron it registers the job handlers (so WP-Cron can fire the * scheduled events); in admin it adds the Session Analytics viewer + its rebuild * AJAX; on all three it re-ensures the events are scheduled. * * @return void */ function aieo_bootstrap_session_analytics() { if (!wp_doing_cron() && !is_admin() && !(defined('WP_CLI') && WP_CLI)) { return; } if (!class_exists('AIEO_Session_Analytics_Processor')) { $f = plugin_dir_path(__FILE__) . 'includes/class-session-analytics-processor.php'; if (file_exists($f)) { require_once $f; } } if (class_exists('AIEO_Session_Analytics_Processor')) { AIEO_Session_Analytics_Processor::bootstrap(); } } add_action('plugins_loaded', 'aieo_bootstrap_session_analytics', 20); function aieo_check_for_plugin_update() { $stored_version = get_option('aieo_version', false); if ($stored_version === false) { update_option('aieo_version', AIEO_VERSION); return; } if (version_compare(AIEO_VERSION, $stored_version, '>')) { global $wpdb; // Atomic, connection-scoped lock. This hook fires on every // plugins_loaded; during an in-place update many PHP-FPM workers see // the version mismatch at once. The old transient guard was // check-then-set, so two workers could BOTH pass get_transient() // before either set it and run the update in parallel (interleaved // DROP/CREATE → "already exists" / "Duplicate entry" log spam). // GET_LOCK(…, 0) returns 1 only to the first worker; the rest get 0 // and bail. It auto-releases when the DB connection closes, so a fatal // mid-update can't wedge it (the transient persisted 5 minutes). $got_lock = $wpdb->get_var("SELECT GET_LOCK('aieo_plugin_update', 0)"); if ($got_lock !== '1' && $got_lock !== 1) { return; } // Double-checked lock: re-read the version INSIDE the lock. A worker that // saw the old version before another worker committed the bump would // otherwise re-run the whole drop/recreate after the lock frees. Re-check // and bail if we're no longer behind. if (!version_compare(AIEO_VERSION, (string) get_option('aieo_version', '0'), '>')) { $wpdb->query("SELECT RELEASE_LOCK('aieo_plugin_update')"); return; } // Refresh the bundled companion mu-plugins (autoloader, AS core, // AJAX query protection). On upgrade-in-place via the WP admin // uploader, register_activation_hook does NOT re-fire, so the // copy step we register on activation is skipped. Re-running it // here on version-bump covers that path. Function is idempotent // — it mtime-compares before copying and skips conflicting peers. if (function_exists('aieo_install_bundled_mu_plugins')) { aieo_install_bundled_mu_plugins(); } // Refresh the stored PROCEDURES/FUNCTIONS only — pass false so the // materialized working tables (core_*_vitals, temp_*, ai_models) and // the LIVE session tables are NOT dropped on a version bump. The old // unconditional drop wiped the storefront's read substrate (and live // sessions, default keep=0) on every single release, leaving the shop // degraded until the 15–25 min data-prep pipeline repopulated it. // Schema changes to those tables go through their own versioned // migrations, not a blanket drop. aieo_drop_stored_procedures(false); // CREATE TABLE IF NOT EXISTS — creates anything missing, leaves // existing tables (and their data) untouched. aieo_create_temp_tables_activation_hook(); aieo_create_stored_procedures(); // Wishlist table — ensure it exists on every version bump // (covers 4.x → 5.0 upgrades-in-place where the activation // hook never fires). if (class_exists('AIEO_Wishlist')) { AIEO_Wishlist::install(); update_option(AIEO_Wishlist::SCHEMA_OPTION, AIEO_Wishlist::SCHEMA_VERSION, false); } // Ensure initial activation date is set for existing installations upgrading to 3.3.0+ if (get_option('aieo_initial_activation_date') === false) { add_option('aieo_initial_activation_date', current_time('mysql', true), '', 'no'); } // Then update the version value in the database update_option('aieo_version', AIEO_VERSION); // Release the advisory lock. (Future calls short-circuit on the // version check above anyway; the lock would also auto-release on // connection close, but explicit release returns it immediately.) $wpdb->query("SELECT RELEASE_LOCK('aieo_plugin_update')"); } } add_action('plugins_loaded', 'aieo_check_for_plugin_update'); /** * One-time autoload tuning (Phase-1 perf, 2026-06-13; bump the flag value to * re-run after changing the lists). * * The plugin seeds all its options with autoload='no' * (aieo_set_default_options), yet ~30 of them are read on EVERY request — * tracker toggles, IAPI flags, schema versions, perpetual migration flags, * display toggles. Without a persistent object cache each one is its own * `SELECT option_value …` round-trip per page view. Flipping these small * scalars to autoload='yes' folds them into the single alloptions query. * * Conversely, the JWT/auth + export-status payloads were written with * update_option() DEFAULT autoload (= yes), permanently bloating alloptions * with full HTTP response bodies. Those flip to 'no' (their write sites now * pass $autoload=false too). * * Runs once from admin_init (never costs the frontend anything); fresh * installs pick it up on the first wp-admin visit after activation. */ function aieo_autoload_tuning_v1() { if (get_option('aieo_autoload_tuning_done') === 'v1') { return; } if (!function_exists('wp_set_option_autoload_values')) { return; // WP < 6.4 — skip; behavior simply stays as it was. } $hot_small_scalars = array( // Version / perpetual flags read at plugins_loaded on every request. 'aieo_version', 'aieo_post_core_upgrade_purge_v1', 'aieo_om_ppe_legacy_migration_v1', 'aieo_chat_split_loader', // Session tracker — enqueue gate + loader config, every page view. // (custom_image_click_rules_js is usually empty; operator-authored // rules ride along in alloptions — acceptable, they're emitted into // the page anyway.) 'aieo_session_tracking_enabled', 'aieo_session_bypass_consent', 'aieo_session_tracking_require_consent', 'aieo_session_tracking_default_consent', 'aieo_session_flush_interval', 'aieo_track_scroll_depth', 'aieo_track_clicks', 'aieo_throttle_clicks', 'aieo_track_mouse_movement', 'aieo_session_tracker_version', 'aieo_custom_image_click_rules_js', // Recommendations display toggles — frontend constructor reads. 'aieo_display_cross_sells', 'aieo_display_recently_viewed_products', 'aieo_display_related', 'aieo_display_same_needs', 'aieo_display_upsells', 'aieo_recommendations_display_order', // IAPI master + per-module flags — up to 10 reads per page view in // aieo_runtime_should_load_iapi(). 'roosterx_iapi_enabled', 'aieo_dmm_pricing_iapi_enabled', 'aieo_dmm_wishlist_iapi_enabled', 'aieo_dmm_waitlist_iapi_enabled', 'aieo_dmm_complementary_iapi_enabled', 'aieo_dmm_reco_iapi_enabled', 'aieo_dmm_ratings_iapi_enabled', 'aieo_dmm_swatches_iapi_enabled', 'aieo_dmm_sticky_atc_iapi_enabled', 'aieo_dmm_side_cart_iapi_enabled', // Swatches runtime kill-switch — checked on every frontend request. 'aieo_dmm_swatches_runtime_enabled', // Schema-version stamps checked at plugins_loaded by frontend-loaded // modules (admin-only modules' stamps intentionally left alone). 'aieo_reco_events_schema_version', 'aieo_effective_prices_schema_version', 'aieo_effective_eligibility_schema_version', 'aieo_effective_recos_schema_version', 'aieo_recompute_queue_schema_version', 'aieo_wishlist_schema_version', 'aieo_ratings_schema_version', 'aieo_attribution_schema_version', 'aieo_waitlist_schema_version', 'aieo_push_schema_version', ); $blobs_out_of_alloptions = array( 'aieo_jwt_response', 'aieo_jwt_user_details', 'aieo_jwt_debugging', 'aieo_export_orders_message', ); $values = array(); foreach ($hot_small_scalars as $k) { $values[$k] = true; } foreach ($blobs_out_of_alloptions as $k) { $values[$k] = false; } // Only flips rows that exist; missing options are untouched (they adopt // the right autoload when first written). wp_set_option_autoload_values($values); update_option('aieo_autoload_tuning_done', 'v1', true); } add_action('admin_init', 'aieo_autoload_tuning_v1'); /** * Purge every AIEO-owned cache when WordPress core is upgraded. * * Bug context (NEXT_RELEASE_PRIORITY_BUGS.md issue #3, 2026-05-21): * after WP 7.0 auto-upgrade on femme-fatale.gr (prod1), every product * URL that carried a `?attribute_pa_=` query string * fatalled at `wp-includes/theme.php:325` (the * `apply_filters('template', get_option('template'))` line inside * `get_template()`) with the integer-underflow signature `tried to * allocate 4295229440 bytes (≈ 0xFFFFFFFE)`. That number is the * classic unsigned-underflow value PHP `unserialize()` returns when * it reads a corrupted length field — and the canonical way to * corrupt a length field is to unserialize an object whose class * has CHANGED SHAPE (different property set) or been REMOVED * between the time the blob was stored and the time it's read. * * The WP 7.0 upgrade replaces enough of `wp-includes/` that any * cached blob containing serialised WP/WC core class instances * (WP_Term, WP_Post, WC_Product, etc.) becomes a landmine. WC has * its own variation-prices cache (`wc_var_prices_*` transients); * AIEO has 81+ get_transient/wp_cache_get call sites across the * plugin, several of which page-cache results that may contain * core-class instances directly or transitively. * * Strategy: on WP-core upgrade fire-and-forget delete every AIEO * transient + every WC variation-prices transient + clear the * `aieo_dmm` wp_cache group + truncate AIEO_Recommendations_Cache. * Costs one DELETE on `wp_options` (with an indexed LIKE on * option_name) per upgrade — negligible compared to the OOM risk. * * Hooked on `upgrader_process_complete` with `$hook_extra['type']` * filter so the purge only fires for core upgrades; plugin and * theme upgrades use AIEO's own version-bump path (the * `aieo_check_for_plugin_update` above) instead. * * @param WP_Upgrader $upgrader_object * @param array $hook_extra */ function aieo_purge_caches_on_core_upgrade($upgrader_object, $hook_extra) { if (!is_array($hook_extra)) { return; } $type = isset($hook_extra['type']) ? (string) $hook_extra['type'] : ''; if ($type !== 'core') { return; } aieo_purge_all_persistent_caches('upgrader_process_complete:core'); } add_action('upgrader_process_complete', 'aieo_purge_caches_on_core_upgrade', 10, 2); /** * One-shot retroactive purge. Catches sites where WP core has ALREADY * been auto-upgraded BEFORE this fix landed in the plugin — i.e. the * upgrader_process_complete hook above can't help because it fires at * upgrade time, not at plugin-update time. * * Idempotent — guarded by `aieo_post_core_upgrade_purge_v1`. The * plugin's own version-bump path triggers this on the first * page load after 5.1 → next version lands (and on any subsequent * version bump, since the option is per-version). */ function aieo_retroactive_post_core_upgrade_purge() { $flag_key = 'aieo_post_core_upgrade_purge_v1'; if (get_option($flag_key) === 'done') { return; } aieo_purge_all_persistent_caches('retroactive_one_shot'); update_option($flag_key, 'done', false); } add_action('plugins_loaded', 'aieo_retroactive_post_core_upgrade_purge', 11); /** * Implementation shared by the upgrader hook + the retroactive * one-shot. Deletes by option_name prefix on `wp_options` (no * intermediate get_option calls — direct DB query keeps the purge * O(rows) rather than O(rows × deserialise-cost)). * * Scope: * - `_transient_aieo_*` + `_transient_timeout_aieo_*` * - `_transient_wc_var_prices_*` + matching timeouts (WC's own * variation-prices cache; touched by AIEO_DMM_Main_Price_Filter + * AIEO_DMM_Role_Pricing via their `woocommerce_get_variation_prices_hash` * filters, so corrupted blobs there transitively land in AIEO's * code path) * - object cache group `aieo_dmm` (if `wp_cache_flush_group` is available) * - `wp_aieo_recommendations_cache` table TRUNCATE (if present) * * @param string $reason Free-text breadcrumb for the error log. */ function aieo_purge_all_persistent_caches($reason = '') { global $wpdb; // 1. Transients — direct DELETE keyed on option_name. Both the // blob row and its matching `_timeout_` row. if (isset($wpdb) && is_object($wpdb)) { $wpdb->query( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- literal prefix; no user input; bypasses cache by design. "DELETE FROM {$wpdb->options} WHERE option_name LIKE '\\_transient\\_aieo\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_aieo\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\__aieo\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\__aieo\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_wc\\_var\\_prices\\_%' ESCAPE '\\\\' OR option_name LIKE '\\_transient\\_timeout\\_wc\\_var\\_prices\\_%' ESCAPE '\\\\'" ); } // 2. Object cache group used by AIEO_DMM cached lookups // (product-needs / ratings / brand-vitals etc.). if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group('aieo_dmm'); wp_cache_flush_group('aieo_core_brand_vitals'); wp_cache_flush_group('aieo_ratings'); } elseif (function_exists('wp_cache_flush')) { // Older WP without group-scoped flush. Defensive full flush; // happens at most once per WP core upgrade, so acceptable // tradeoff against a hard OOM on the next request. wp_cache_flush(); } // 3. Recommendations cache table — full truncate when present. // The table stores serialised reco DTOs that could include // WC_Product references; same unserialise-landmine risk. if (isset($wpdb) && is_object($wpdb)) { $table = $wpdb->prefix . 'aieo_recommendations_cache'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- literal table name; SHOW TABLES guard above prevents fatal on installs that don't ship the table. if ((string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- literal table name verified by SHOW TABLES. $wpdb->query("TRUNCATE TABLE {$table}"); } } // 4. Bump any internal version counters the chat / brand-vitals // layers use to invalidate their in-process memoisation on // the NEXT request. if (class_exists('AIEO_Core_Brand_Vitals') && method_exists('AIEO_Core_Brand_Vitals', 'bump_version')) { try { AIEO_Core_Brand_Vitals::instance()->bump_version(); } catch (Throwable $e) {} } if (defined('WP_DEBUG') && WP_DEBUG && function_exists('aieo_debug_log')) { aieo_debug_log('AIEO cache purge fired: ' . $reason); } } /** * One-shot migration: copy legacy `om_ppe_*` options into their AIEO * counterparts so the runtime stops reading both. Without this, a * legacy record with `show_sku:false` / `show_barcode:false` shadows * the AIEO defaults forever, and the module never renders even after * the user toggles it on from the Product Display dashboard (which * writes only `{enabled:true}`). * * Merge rule: AIEO wins where it has a value, legacy fills gaps. The * `enabled` key is explicitly carried over from AIEO so the toggle * the user just flipped isn't reverted to the legacy disabled state. * After migration, the legacy option is deleted and a done-flag is * set so this never runs again. * * Idempotent — guarded by `aieo_om_ppe_legacy_migration_v1`. */ function aieo_migrate_om_ppe_legacy_options() { if (get_option('aieo_om_ppe_legacy_migration_v1') === 'done') { return; } $pairs = array( 'aieo_dmm_product_meta_settings' => 'om_ppe_product-meta', 'aieo_dmm_payment_logos_settings' => 'om_ppe_payment-logos', 'aieo_dmm_trust_badges_settings' => 'om_ppe_trust-badges', 'aieo_dmm_product_labels_settings'=> 'om_ppe_product-labels', 'aieo_dmm_product_video_settings' => 'om_ppe_product-video', 'aieo_dmm_badges_settings' => 'om_ppe_badges', 'aieo_dmm_add_to_cart_text_settings' => 'om_ppe_add-to-cart-text', ); foreach ($pairs as $new_key => $legacy_key) { $legacy = get_option($legacy_key, false); if (!is_array($legacy)) { continue; } $new = get_option($new_key, array()); if (!is_array($new)) { $new = array(); } // Two cases: // (a) AIEO key has never been written → seed it from legacy // so users who had OM PPE configured don't lose their // labels/colours/custom_class. // (b) AIEO key has been written (even just `{enabled:true}` // from the dashboard toggle) → AIEO is now the source // of truth; legacy is stale. Carrying legacy's boolean // OPT-IN flags (show_sku/show_barcode) forward would // silently shadow the AIEO defaults and the module // would never render even though the user just enabled // it. So in case (b) we simply delete the legacy and // leave AIEO alone — defaults from get_settings() take // over for missing keys. if (empty($new)) { update_option($new_key, $legacy, false); } delete_option($legacy_key); } update_option('aieo_om_ppe_legacy_migration_v1', 'done', false); } add_action('plugins_loaded', 'aieo_migrate_om_ppe_legacy_options', 12); // Load text domain on init for normal page loads (WordPress 4.6+ auto-loads, but this is a fallback) // Note: For AJAX requests, text domain is loaded at the top of this file before any classes load add_action( 'init', function() { // phpcs:ignore PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound -- Fallback for non-WordPress.org hosted translations load_plugin_textdomain( 'ai-eshop-optimizer', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); }, 0 ); function aieo_initialize_aieo_recommendations() { new aieo_display_recommendations(); } add_action('plugins_loaded', 'aieo_initialize_aieo_recommendations'); /** * AIEO Table Rate Shipping — admin fallback registration. * * The runtime plugin owns this filter when present (see * ai-eshop-optimizer-runtime/ai-eshop-optimizer-runtime.php:158). Admin * registers it as a fallback so the storefront still gets the * `aieo_table_rate` method type — and therefore real rates for every zone * instance with method_id=aieo_table_rate — when an operator deactivates * the runtime plugin or the runtime is filtered out by an external loader. * * The class file `class-aieo-shipping-table-rate.php` lives in runtime's * includes/ but the mu-plugins autoloader is file-based and resolves it * regardless of which plugin is "active" in wp_options — so no class file * needs to be duplicated into admin. Only this filter registration needs * the gated fallback. * * The COD / free-shipping `AIEO_RUNTIME_OWNS_AJAX` gating pattern: runtime * defines the constant at file-load; admin skips this registration when * the constant exists so we never get double registration. */ add_action('plugins_loaded', function () { if (defined('AIEO_RUNTIME_OWNS_AJAX')) { return; } add_filter('woocommerce_shipping_methods', static function ($methods) { $methods['aieo_table_rate'] = 'AIEO_Shipping_Table_Rate'; return $methods; }); }); /** * AIEO Social Login — frontend display fallback registration. * * The OAuth backend (AIEO_SSO_Rest + providers) and the admin settings tab * (AIEO_DMM_Social_Login) live in core. The customer-facing button SURFACES * (login / register / checkout / my-account / newsletter), the * `aieo/sso-buttons-block`, and the My-Account "Connected accounts" tab are * OWNED by the runtime plugin when present — it defines * AIEO_RUNTIME_OWNS_SSO_DISPLAY. When the runtime is absent, core renders the * SAME surfaces from its own bundled copies so SSO works fully standalone * (the proven AIEO_RUNTIME_OWNS_* gated-fallback pattern, as with table-rate * shipping above). * * Wired here (not in AIEO_DMM_Social_Login) because that module is skipped on * frontend requests for perf — but the buttons must render on the frontend. */ add_action('plugins_loaded', function () { if (defined('AIEO_RUNTIME_OWNS_SSO_DISPLAY')) { return; } // runtime renders the SSO frontend $base = AIEO_DIR . 'includes/social-login/'; // Shared provider/intent config (also dual-bundled in the runtime). The // autoloader prefers the runtime copy; when runtime is absent we require // core's so the display has its config source. if (!class_exists('AIEO_SSO_Buttons_Data') && file_exists($base . 'class-aieo-sso-buttons-data.php')) { require_once $base . 'class-aieo-sso-buttons-data.php'; } foreach (array( 'class-aieo-sso-buttons.php', 'class-aieo-sso-surfaces.php', 'class-aieo-sso-block.php', 'class-aieo-sso-account-tab.php', ) as $f) { if (file_exists($base . $f)) { require_once $base . $f; } } if (class_exists('AIEO_SSO_Surfaces')) { AIEO_SSO_Surfaces::register(); } if (class_exists('AIEO_SSO_Account_Tab')) { AIEO_SSO_Account_Tab::register(); } }, 11); /** * AJAX handler to dismiss rotating banner */ function aieo_dismiss_banner_ajax_handler() { // Verify nonce check_ajax_referer('aieo_dismiss_banner', 'nonce'); // Get current user ID $user_id = get_current_user_id(); if (!$user_id) { wp_send_json_error('User not logged in'); return; } // Save dismissal preference update_user_meta($user_id, 'aieo_banner_dismissed', '1'); wp_send_json_success('Banner dismissed'); } add_action('wp_ajax_aieo_dismiss_banner', 'aieo_dismiss_banner_ajax_handler'); // Plugins-page row: direct "Settings" link to the AIEO admin page. add_filter('plugin_action_links_' . plugin_basename(__FILE__), function ($links) { $url = admin_url('admin.php?page=ai-eshop-optimizer'); $settings_link = '' . esc_html__('Settings', 'ai-eshop-optimizer') . ''; array_unshift($links, $settings_link); return $links; });