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(
'
',
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;
});