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: Rooster One Plugin URI: https://rooster.systems/one/ Description: The unified AI commerce & content suite โ€” one plugin does the work of a whole stack. Anthropic/Voyage AI: messaging, recommendations, attribution, loyalty, merchandising. Version: 11.1.13 Requires at least: 6.7 Tested up to: 7.1 Requires PHP: 7.4 WC tested up to: 11.1 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); /** * ๐Ÿšจ AND THE SAME REDIRECT FOR THE PHP-FORMAT CATALOGUE, which is the one * WordPress actually prefers now. * * `load_textdomain_mofile` above only ever sees the .mo path. Since 6.5 core * resolves translations through load_textdomain() โ†’ an ordered list of * candidate files with the .l10n.php one FIRST, each passed through a * different filter, `load_translation_file` (wp-includes/l10n.php). Filtering * only the .mo hook therefore leaves the modern path unguarded: an incomplete * wp.org pack at wp-content/languages/plugins/ai-eshop-optimizer-el.l10n.php * loads first, succeeds, and core returns before the .mo is ever considered. * The bundled full catalogue is never read and the whole plugin falls back to * English on a fully translated locale. * * Symptom that led here (sophiasamara.com, WordPress 7.1, Greek): the magic-link * modal rendered "Sign in with email" while the button beside it read * ฮฃฮฅฮฮ”ฮ•ฮฃฮ— ฮœฮ• EMAIL. Both are ours and both are on the same page, but the button * was a HARDCODED Greek literal at the time and the modal was a __() call. One * page proving that PHP translation was not resolving at all. * * โš ๏ธ That also makes this a prerequisite: the hardcoded Greek has since been * replaced with a proper __(), so on a site where the catalogue does not load, * that button would REGRESS to English without this filter. * * Same rule as above: only redirect a load from the global languages dir, and * only when we actually ship a file of that name, so other locales keep their * wp.org packs. */ add_filter('load_translation_file', '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' ); } /** * RE-LOAD THE TEXT DOMAIN ONCE WORDPRESS KNOWS WHO IS READING. * * THE BUG THIS FIXES. Several classes call __() from a settings-defaults array * built in their constructor, and those constructors run on `plugins_loaded`, * before WordPress has resolved the logged-in user. The first such call makes * WordPress load this plugin's translations just-in-time using * `determine_locale()` as it stands at that moment, which is the SITE's * language, and cache it for the rest of the request. Everything afterwards โ€” * the admin menu, the tab strips, the action labels โ€” then comes back in the * site's language whatever the reader's own account is set to. On a Greek shop * an English-locale developer got a fully Greek admin and it looked like a * translation problem rather than a load-order one. * * WHY HERE AND NOT AT EVERY CALL SITE. There are eight such constructor paths, * four of them in runtime checkout code, and a ninth can be added tomorrow by * someone who has never heard of this. Fixing them one at a time treats the * symptom and leaves the trap armed. This corrects the outcome for all of them * and for anything added later: by `init` the current user IS resolved, so if * the language WordPress would choose now differs from the one already loaded, * the loaded one was chosen too early and is simply wrong. Drop it and load the * right one. * * โš ๏ธ FIRST THING ON `init`, and that is not a detail. By `init` the user IS * resolved, so `determine_locale()` is finally right; the only wrong language * is the one loaded before it. Anything that reads a translation earlier in * `init` than this gets the stale one and can latch it โ€” which is exactly what * happened at priority 1: a settings memo was populated during an `init:0` * callback and held Greek for the rest of the request even after the domain was * corrected a moment later. Run before every other `init` callback so there is * no window. * * Costs nothing in the normal case, which is a single string comparison and no * reload, because on a single-language site the two agree. */ // Remember which language was actually loaded. `get_locale()` cannot answer // this: it reports the SITE's language, which is precisely the wrong one in the // case being corrected. The .mo filename carries the truth. $GLOBALS['aieo_loaded_locale'] = ''; add_filter( 'load_textdomain_mofile', function ( $mofile, $domain ) { if ( 'ai-eshop-optimizer' !== $domain ) { return $mofile; } // Strip the DOMAIN PREFIX, do not pattern-match the locale. A locale can // itself contain a hyphen (`ca-valencia`) and the domain contains two, so // "everything after a hyphen" captured `eshop-optimizer-en_GB` and never // equalled `en_GB`. The comparison then always failed, which meant a // reload of the .mo on every single request including single-language // sites, replacing a correctness bug with a slow one. $base = basename( (string) $mofile ); $base = preg_replace( '/\.(mo|l10n\.php)$/', '', $base ); if ( 0 === strpos( $base, 'ai-eshop-optimizer-' ) ) { $GLOBALS['aieo_loaded_locale'] = substr( $base, strlen( 'ai-eshop-optimizer-' ) ); } return $mofile; }, 99, 2 ); /** * Load the BUNDLED catalogue for $locale by explicit path. * * ๐Ÿšจ NEVER load_plugin_textdomain() FOR THIS. Measured on roosterp 2026-08-01: * with Loco Translate active it is filtered to Loco's own directory, which does * not exist for this plugin. The call still returns true while * is_textdomain_loaded() comes back false, and every __() falls through to the * English msgid. An explicit path cannot be redirected. WordPress still prefers * the sibling .l10n.php automatically, so this keeps the fast loader. * * @param string $locale * @return bool True when a catalogue was loaded. */ function aieo_load_textdomain_by_path( $locale ) { $locale = (string) $locale; if ( '' === $locale || ! function_exists( 'load_textdomain' ) ) { return false; } $dir = plugin_dir_path( __FILE__ ) . 'languages/'; $tries = array( $locale ); if ( false !== strpos( $locale, '_' ) ) { // el_GR โ†’ el. The bundled catalogues are language-only. $tries[] = substr( $locale, 0, strpos( $locale, '_' ) ); } foreach ( $tries as $try ) { $mofile = $dir . 'ai-eshop-optimizer-' . $try . '.mo'; if ( is_readable( $mofile ) && load_textdomain( 'ai-eshop-optimizer', $mofile, $try ) ) { return true; } } return false; } /** * ๐Ÿšจ A LOCALE SWITCH AFTER `init` LEFT THE CATALOGUE BEHIND โ€” THE EMAIL BUG. * * The `init` hook below re-syncs the catalogue ONCE, when WordPress finally * knows who is reading. That is right for a page render and useless for an * email, because WooCommerce sends customer mail inside * wc_switch_to_site_locale() โ†’ switch_to_locale(), which happens LONG after * `init`. Nothing here listened for that, so the catalogue stayed on whatever * language `init` settled on. * * REPORTED SYMPTOM: a Greek order email whose withdrawal BUTTON read ยซPress * here to withdrawยป in English, on a shop whose admin had already been switched * to Greek. The page was Greek because `init` had corrected it; the email was * English because the switch that produced the email came later and no one was * listening. The string was translated the whole time. * * WordPress does re-load registered textdomains on a switch, but it resolves * the path through the textdomain registry โ€” the same redirection documented in * aieo_load_textdomain_by_path(). So core's reload is exactly the one that * cannot be relied on here, and we load by path ourselves. * * This matters most for the ONE string the statute is specific about. Law * 5317/2026 asks for ยซฮ ฮฑฯ„ฮฎฯƒฯ„ฮต ฮตฮดฯŽ ฮณฮนฮฑ ฯ…ฯ€ฮฑฮฝฮฑฯ‡ฯŽฯฮทฯƒฮทยป; an English button on a * Greek email is not "equally clear wording", it is a compliance defect. */ add_action( 'change_locale', function ( $locale ) { if ( ! function_exists( 'unload_textdomain' ) ) { return; } $locale = (string) $locale; if ( '' === $locale || $locale === (string) $GLOBALS['aieo_loaded_locale'] ) { return; } if ( is_textdomain_loaded( 'ai-eshop-optimizer' ) ) { unload_textdomain( 'ai-eshop-optimizer' ); } // Unlike the `init` pass we do NOT bail when nothing is loaded: a switch is // an explicit request for a specific language, and just-in-time loading // would resolve the path through the registry we cannot trust. if ( ! aieo_load_textdomain_by_path( $locale ) ) { // Nothing bundled for this locale (en_US, or a language we do not // ship). Unloaded is CORRECT โ€” __() returns the English msgid, which is // the source text. Clear the record so the next comparison is honest. $GLOBALS['aieo_loaded_locale'] = ''; } }, -PHP_INT_MAX ); add_action( 'init', function () { if ( ! function_exists( 'determine_locale' ) || ! function_exists( 'unload_textdomain' ) ) { return; } // Nothing loaded yet is the healthy path: the first __() after this point // loads it, and by now the locale is settled. if ( ! is_textdomain_loaded( 'ai-eshop-optimizer' ) ) { return; } $wanted = determine_locale(); $loaded = (string) $GLOBALS['aieo_loaded_locale']; // An unrecorded load, or one that already matches, needs nothing. On a // single-language site these always agree and this costs one comparison. if ( '' === $loaded || $wanted === $loaded ) { return; } unload_textdomain( 'ai-eshop-optimizer' ); // ๐Ÿšจ RELOAD BY PATH. Do NOT use load_plugin_textdomain() here โ€” see // aieo_load_textdomain_by_path(), which holds the measurement and the // reason. Loading by path is why this hook stopped "fixing" a wrong-language // admin by emptying the catalogue: it looked correct from the direction it // was written for (an English reader stopped seeing Greek) and was silently // wrong in the mirror image, a GREEK reader also getting English on a plugin // whose Greek catalogue is 12,800 strings deep. if ( aieo_load_textdomain_by_path( $wanted ) ) { return; } // Nothing bundled for this locale โ€” en_US, or a language we do not ship. // Fall back to the normal loader, which is correct for a wp.org-installed // pack living in WP_LANG_DIR. // 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' ); }, -PHP_INT_MAX ); /** * VERSIONING FOLLOWS WOOCOMMERCE, from 11.0 onward (operator ruling, 2026-07-31). * * While WooCommerce is on 11.0 we ship 11.0, then 11.0.1, 11.0.2 โ€ฆ; when they * move to 11.1 we go to 11.1.0 and start again. The point is that an operator * can read our version and know which WooCommerce it was built against, which * matters for a plugin that lives this close to Woo's order, product and * checkout internals. * * Do NOT bump this to chase a Woo release we have not actually tested against. * The number is a claim about compatibility, not a marketing counter. * * Two things key on it, so a bump is never cosmetic: aieo_check_for_plugin_update() * refreshes the stored procedures and prunes removed files, and the capability * registry + welcome search index transients are fingerprinted on it (so a bump * invalidates them for free, and a mid-version edit does not). */ if (!defined('AIEO_VERSION')) { define('AIEO_VERSION', '11.1.13'); } // 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); } // Guarded: the runtime plugin loads FIRST (WP sorts active_plugins and // "-runtime" sorts before "/"), and it pre-seeds AIEO_FILE/AIEO_DIR/AIEO_URI // (pointing at THIS plugin's path, same values) so its bundled classes work // even when the core fatals. Unguarded re-defines here warned on every // request under PHP 8.5 and become fatal on PHP 9. if (!defined('AIEO_FILE')) { define('AIEO_FILE', __FILE__); } if (!defined('AIEO_BASE')) { define('AIEO_BASE', trailingslashit(plugin_basename(AIEO_FILE))); } if (!defined('AIEO_DIR')) { define('AIEO_DIR', plugin_dir_path(__FILE__)); } if (!defined('AIEO_URI')) { 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 reach the plugin's admin surfaces. * * OUR OWN CAPABILITY, NOT A BORROWED ONE (2026-07-31). This was 'edit_pages', * chosen because WooCommerce's shop_manager role happens to include it โ€” which * is true of a stock install, and is why shop managers have always had access. * But it is a coincidence we were relying on, not a rule: * * - Sites customise roles. femme-fatale.gr and beautynet.gr both run a role * manager, and a shop_manager stripped of `edit_pages` would silently lose * the whole plugin โ€” every menu, every tab, every AJAX endpoint โ€” with no * error to explain it. * - "Can edit a page" is not the question we mean to ask. The question is * "does this person run the shop", and the answer for a WooCommerce install * is `manage_woocommerce`. * * So the constant now names a capability we own, and the filter below grants it * to whoever holds EITHER of the two real capabilities. Every one of the ~84 * `current_user_can(AIEO_CAPABILITY)` checks and every `add_menu_page()` gate * across the three plugins picks this up with no call-site change, because they * all read the constant. * * WIDENING, NEVER NARROWING: nobody who could reach the plugin yesterday loses * access, and shop managers are guaranteed it rather than getting it by accident. * Granting a capability of our own also means we hand out nothing else โ€” adding * `edit_pages` to shop_manager to solve this would have let them edit the site's * Pages, which is not ours to give. * * The one deliberate exception the operator named stays where it is: plugin-usage * / activation control is gated separately and is NOT widened here. */ define('AIEO_CAPABILITY', 'aieo_access'); /** * Grant AIEO_CAPABILITY to shop managers and editors alike. * * Reads the already-resolved $allcaps map rather than calling current_user_can(), * which would recurse through this same filter on every capability test in the * request. Two array lookups, no queries. */ add_filter('user_has_cap', function ($allcaps) { if (empty($allcaps[AIEO_CAPABILITY]) && (!empty($allcaps['manage_woocommerce']) || !empty($allcaps['edit_pages'])) ) { $allcaps[AIEO_CAPABILITY] = true; } return $allcaps; }, 10, 1); // 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'; // Is the front-end runtime installed, and does it match core? ONE detector, // three consumers (the Install status line, the attribution funnel's // runtime_active, and the version-mismatch warning). Loaded here, before the // signup and notifier classes that report it, because all three of those put // the same answer on a payload or a screen. Static methods only, no hooks of // its own beyond a cache flush โ€” costs nothing on a request that never asks. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-runtime-presence.php'; // Which SITE the cached entitlement was issued for, so a copied database stops // opening every Pro gate on a second domain. Dual copy with the Pro plugin. // Required explicitly rather than left to the autoloader: a host with an // unwritable mu-plugins/ has no autoloader, and a licence check that disappears // on exactly those hosts is not a licence check. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-entitlement-signature.php'; require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-entitlement-binding.php'; // Runtime-before-core update ordering: one line on core's update row when the // runtime is behind, and a nudge that puts the runtime's own update on the // screen first. WARNS, never blocks โ€” the class docblock says why. Admin-only // hooks, so a storefront request pays one is_admin() check. if ( is_admin() ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-update-order.php'; AIEO_Update_Order::register(); } // Home โ†’ Getting set up (plan ยง5): what kind of shop this is, as a stated // prior, and the staged self-detecting checklist. Static, hook-free classes; // the DMM module that renders them registers the admin-post handlers. Loaded // here rather than inside the module because the pin strip and the welcome // handlers read the seeded-pin state on pages that do not boot the module. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-shop-shape.php'; // Who is looking at the screen: the SECOND input to relevance(), read by the // pill bar on every admin page, so it loads beside the shape and not inside a // module (docs/infographics/PLAN-2026-09-03-PERSONAS-DETAILED.md). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-personas.php'; require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-setup-checklist.php'; // The set-up wizard as one component, rendered on Getting set up and, through // two AJAX endpoints registered at load, inside the welcome modal. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-wizard.php'; // Home โ†’ Overlaps: the replaced-plugins journey and the one `kept` gate that // SEO โ†’ General and the checklist consult before nudging (plan ยง5.4). require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-overlap.php'; // The Install status line (plan ยง5.7): Pro, the Pro plugin and the front-end // runtime as three rows, rendered in exactly two places. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-install-status.php'; // The question a locked screen answers, with this shop's own numbers in it // (plan ยง5.8, Tier 2): the floor for every Pro paywall, local and free. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-pro-question.php'; // Which reports rendered zero rows when opened (plan ยง5.12): the instrument // behind "the report is empty", kept locally, read by Getting set up. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-dark-screens.php'; // The last real figure each report showed while entitled, dated: the lapsed // state of a locked screen (plan ยง5.8), written by report pages, read by the // paywall. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-remembered-figures.php'; // Shape + module switches + overlap decisions as one block of JSON (plan // ยง5.11.4): the three re-detectable things an agency sets the same way twice. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-shop-preset.php'; if (is_admin()) { // The one bounded notice the checklist may raise: the EU withdrawal // button, on a trading EU shop where it is off, dismissable forever. AIEO_Setup_Checklist::init(); } // Low-balance AI-credit email alerts (daily cron poll of the Rooster Portal + // operator-chosen billing email/threshold). Self-contained. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-credit-notifier.php'; AIEO_Credit_Notifier::init(); // Self-serve Rooster account signup (email โ†’ double opt-in โ†’ auto-connect โ†’ // 14-day Pro trial). Optional by construction: registers admin handlers and a // dismissible notice only; no request leaves the site until the operator // explicitly submits the form. Core plugin only. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-rooster-signup.php'; // Shared transport to rooster.systems for operator-initiated submissions // (feedback today, forms next). Owns enrolment, request signing and the retry // queue so the callers own only their payload shape. Registers hooks and one // REST route; nothing reaches the network until an operator presses Send. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-portal-client.php'; AIEO_Portal_Client::init(); // Send-feedback modal behind the top-bar icon. Admin-only, opt-in by // construction: opening it costs no request, submitting IS the consent event. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-feedback.php'; AIEO_Feedback::init(); // The wordpress.org review ask. Earned (7 days AND real use), dismissible per // user, Dashboard and Plugins only, and never shown beside the signup notice. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-review-prompt.php'; AIEO_Review_Prompt::init(); // Bundled surveys. Definitions ship in the release (includes/dmm/data/forms.php) // so their strings are translatable and a question can never reach an install // that lacks the feature it asks about. Audience rules are evaluated locally. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-forms.php'; AIEO_Forms::init(); // Operator-authored forms: the `aieo_form` CPT holds field definitions, two // owned tables hold entries, and AIEO_Form_Entries::record() is the only way in. // NO shortcode and NO public endpoint โ€” caller code renders and records, so // nothing here is reachable by an anonymous visitor. The substrate for a proper // form builder later; the storage shape is what has to be right now, because // entries are the expensive thing to migrate. require_once plugin_dir_path(__FILE__) . '/includes/forms/class-aieo-form-cpt.php'; require_once plugin_dir_path(__FILE__) . '/includes/forms/class-aieo-form-entries.php'; require_once plugin_dir_path(__FILE__) . '/includes/forms/class-aieo-form-entries-ui.php'; AIEO_Form_CPT::register(); AIEO_Form_Entries_UI::register(); // Canonical AI-credit attribution source slugs (X-OM-Source). Loaded early so // every AI caller can tag itself. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-ai-sources.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'; // Armed at load, not on an init hook: a stored-procedure CALL can be issued from // anything (cron, CLI, an mu-plugin, another plugin), so the guard has to be in // place before the first query we could possibly be blamed for. Idempotent, and // one client-side flag check per query. if (function_exists('aieo_install_sproc_drain_guard')) { aieo_install_sproc_drain_guard(); } /** * 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'; // What this install runs on: WooCommerce present, multisite, a host asset // optimiser like Breeze. Loaded BEFORE AIEO_Asset_Min because that class asks // it whether to stand its URL rewrite down. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-host-environment.php'; if (is_admin()) { // Notices only. Nothing on the storefront path. AIEO_Host_Environment::init(); } // Stored-procedure install state: verification, the resume cron, and the // notice that says a routine could not be created. Loaded unconditionally // because the resume runs on cron, where is_admin() is false. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-procedure-installer.php'; AIEO_Procedure_Installer::init(); // First install of the two PAID downloads, driven by WordPress's own // Plugin_Upgrader against a Portal-issued package URL. Admin only: there is // deliberately no cron and no admin_init path into it, nothing auto-installs, // and it renders nothing at all until a licence key exists. if (is_admin()) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-one-installer.php'; AIEO_One_Installer::init(); add_action('admin_notices', array('AIEO_One_Installer', 'render_result')); } // 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(); // "Duplicate" for the plugin's own post types. Registered centrally rather than // from one module so every list screen can draw the link, and so the per-type // exclusion map (what must NOT be copied) has a single home. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-post-duplicator.php'; AIEO_Post_Duplicator::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'; // WooCommerce native reviews as a first-class rating source. Most shops run // WC reviews as their default (often only) rating signal, so this mirrors // them into wp_aieo_ratings permanently โ€” backfill + full comment lifecycle. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-ratings-wc-bridge.php'; // A variation with no gallery of its own shows the product's, rather than // emptying the gallery on screen. Dual copy with the runtime; guarded so // whichever plugin boots first registers it once. if ( ! class_exists( 'AIEO_Variation_Gallery_Fallback', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-variation-gallery-fallback.php'; AIEO_Variation_Gallery_Fallback::register(); } // Storefront half of the product gallery layout, the Rooster One lightbox // and auto-open-on-arrival. Dual copy with the runtime; guarded so whichever // plugin boots first registers it once. The DMM modules only hold its admin. if ( ! class_exists( 'AIEO_Gallery_Lightbox_Frontend', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-gallery-lightbox-frontend.php'; } AIEO_Gallery_Lightbox_Frontend::register(); // One build of get_available_variations() per product per request. WooCommerce // calls it once per page; a theme that resolves per-term swatch data by // scanning the variation list calls it once per SWATCH, which is O(N^2): // measured at 42.4s of a 46.9s Rey page render. Nothing between two calls in // one request can change the answer, so the second one is free. Guarded rather // than dual-copied: the memo has no renderer and belongs wherever core is. if ( ! class_exists( 'AIEO_Variation_Payload', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-variation-payload.php'; AIEO_Variation_Payload::register(); } // Themes that ship their own variation swatches hook the same WooCommerce seam // we do, at a higher priority, so ours is built and then discarded. The adapter // registry knows where each host sits and how to stand it down. Inert until the // operator picks a design source, so an unconfigured site renders as it does // today. Core only; the dual-copied renderer reaches it via class_exists. if ( ! class_exists( 'AIEO_Theme_Adapters', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/dmm/swatches/class-aieo-swatch-type-detector.php'; require_once plugin_dir_path(__FILE__) . '/includes/theme-adapters/class-aieo-theme-adapters.php'; AIEO_Theme_Adapters::register(); } // `[aieo_product_swatches]`, so the picker can be placed in Elementor, Bricks or // any builder that has no block loop to read a product from. Same renderer and // same cache as the block; only the product resolution differs. if ( ! class_exists( 'AIEO_Swatch_Shortcode', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/dmm/swatches/class-aieo-swatch-shortcode.php'; AIEO_Swatch_Shortcode::register(); } // 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/header-actions` block โ€” extensible header icon row (menu/navigator burger // now; account/stores later). Sits inside .ff-header__actions; the menu icon opens // the Navigator via the theme's data-aieo-navigator-trigger handler. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-header-actions-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'; // Attribution CLIENT โ€” the enqueue+localize half, dual-shipped so the runtime // can serve it on a runtime-only install (where this file never loads and cart // adds were previously recorded nowhere at all). // // ๐Ÿšจ THE class_exists GUARD IS NOT OPTIONAL. This is a shared, byte-identical // file: an unguarded require_once would load CORE's copy on every paired // install, because require_once bypasses the autoloader entirely. __FILE__ // inside the class would then resolve to core's folder and core's asset would // be served even when the runtime is present โ€” the exact inverse of "when the // runtime is present, the runtime rules". Guarded, the runtime's copy wins // whenever the autoloader has already provided it. if (!class_exists('AIEO_Attribution_Client') && file_exists(plugin_dir_path(__FILE__) . '/includes/class-aieo-attribution-client.php')) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-attribution-client.php'; } if (class_exists('AIEO_Attribution_Client')) { AIEO_Attribution_Client::register(); } // A/B arm assignment for recommendation experiments. Loaded next to // attribution because it is read on the same paths: the arm is decided while a // page renders and stamped onto the reco rows that page produces. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-reco-experiment.php'; require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-reco-experiment-report.php'; // One display vocabulary for every promotional surface โ€” popups, custom slots, // recommendation rails and promo regions โ€” plus the A/B seam that lets an arm // override any of its attributes. Both are DUAL-BUNDLED (class_exists-guarded, // byte-identical in the runtime plugin), because the OM whitelist can drop this // plugin from a front-end request and a display gate is nothing but hooks. // // Loaded here rather than lazily so admin screens can reach the vocabulary; the // storefront path self-heals through AIEO_Popup_Display::ensure_conditions(). if ( ! class_exists( 'AIEO_Display_Conditions' ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-display-conditions.php'; } if ( ! class_exists( 'AIEO_Display_Experiment' ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-display-experiment.php'; } // THE EXTERNAL INSTIGATOR: what sent this visitor โ€” a web push, a newsletter, a // collaborator's link. Also dual-bundled, because the event logger that stamps // it onto a row runs on the runtime-only whitelist. It stores the raw tuple and // NEVER a resolved identity, so the contributor rules that name a person stay // retroactive exactly as they are on the order plane. if ( ! class_exists( 'AIEO_Display_Origin' ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-display-origin.php'; } // The shared CONTROL for those conditions. Core-only and NOT dual-bundled: it is // all admin markup, and the runtime plugin exists to serve a visitor's page // without carrying the admin half. Same split as AIEO_Popup_CPT beside // AIEO_Popup_Display. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-display-conditions-ui.php'; // The WRITE side and the INSPECTION side of the display plane: one create / // update entry point that dispatches to whichever of the four stores owns a // surface, and an enumeration of what a site actually has. Core-only for the // same reason โ€” nothing here runs on a storefront request. // // It exists for automation. The conditions are one vocabulary now, but the // CONTAINERS are still a CPT, an option row, an ordering entry and two DB // tables, so a campaign generator would otherwise have to know all four. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-display-registry.php'; // What a split test actually did. Core-only for the same reason, and separate // from AIEO_Reco_Experiment_Report because every assumption that one makes โ€” // one experiment, two fixed arm names, product-card outcomes โ€” is wrong for a // display surface. It SHARES that class's statistics rather than copying them. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-display-experiment-report.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(); } // Shared attribution-block renderer (styled table + channel vocabulary) used // by BOTH the order-email origin block and the Gravity notification block. // Bundled identically in the runtime plugin (checkout-whitelist delegate) โ€” // class_exists-guarded so whichever copy loads first wins. if ( ! class_exists( 'AIEO_Attribution_Block' ) ) { require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-attribution-block.php'; } // Order origin + attribution block in the staff (admin) order emails โ€” replaces // the unreliable PixelYourSite origin line (marketplace vs Site + channel/campaign). // Also bundled in the runtime plugin: optimised sites trim THIS plugin off // checkout requests, where synchronous gateways (cheque/COD/bacs) send the // staff new-order email โ€” the runtime delegate renders the block there. if ( ! class_exists( 'AIEO_Order_Origin_Email' ) ) { 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(); } // Google Ads generation (ยง20) โ€” compose a campaign from what the shop already // knows, create it PAUSED, and let a human decide whether it ever spends. The // classes load unconditionally because the composer is also reachable from the // Claude abilities, not only from the admin tab. require_once plugin_dir_path( __FILE__ ) . '/includes/ads/class-aieo-ads-plan.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/ads/class-aieo-ads-composer.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/ads/class-aieo-ads-google-driver.php'; // The shared presentation vocabulary and the control layer. // // `AIEO_Variants` is the one variant dictionary โ€” the names a badge, a button // or a row action may carry, and what the retired ones normalise to. It loads // unconditionally and early because the report definitions validate against it // at LOAD time, and a definition can be read by a CLI script that never boots // the admin bundle. // // `AIEO_Controls` is the markup contract those variants paint: an admin panel // is a huge form, a survey is a form, and they are the same controls. It is // admin-only in use today; the storefront questionnaire renderer (Wave 4 of // docs/PLAN-2026-08-30-CUSTOMER-SURVEYS.md) is what makes it a runtime-owned // surface, and at that point it needs its copy in the runtime plugin. require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-variants.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-controls.php'; // Gravity Forms choice-field distributions โ€” turns every checkbox/select answer // into an audience breakdown that ad copy and targeting can be grounded in. require_once plugin_dir_path( __FILE__ ) . '/includes/class-aieo-form-insights.php'; // Questionnaires as a capability rather than a hand-wired form. // // The blueprint declares a question's SEMANTIC on four axes and never its // widget; the two adapters normalise Gravity and native answers into the SAME // answer store keyed by question code; the register remembers what an answer // means across every form the shop will ever have. Loaded unconditionally // because the event engine, the audience resolver and the reports all ask about // questionnaires, not only the admin screens. require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-blueprint.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-overlay.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-choice-register.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-value-stage.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-mapping.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-source.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-source-gravity.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-source-native.php'; // The storefront half: where a questionnaire appears, who may answer it, and // what asking one costs a workflow. // // ๐Ÿšจ THIS IS WHAT RETIRES THE THEME FILE. `roosterx-ff/inc/post-purchase-survey.php` // renders the reference shop's survey from the THEME, hard-wired to one hook at // one priority. A survey is a capability of the shop, not of its theme, and a // shop that changes theme should not lose three years of feedback collection. // Both render on `woocommerce_before_thankyou`, so the theme file MUST be // deleted in the same deploy as this or every customer gets two surveys on one // page โ€” see ยงC of docs/DEPLOY-CUSTOMER-SURVEYS-TO-APOLLO.md. require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-placement.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-renderer.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-ask.php'; require_once plugin_dir_path( __FILE__ ) . '/includes/surveys/class-aieo-survey-health.php'; AIEO_Survey_Overlay::register(); AIEO_Survey_Placement::register(); AIEO_Survey_Renderer::register(); // A new submission changes the numbers, so drop the cached report rather than // letting the merchant read a distribution that is up to 15 minutes stale. add_action( 'gform_after_submission', function ( $entry, $form ) { if ( class_exists( 'AIEO_Form_Insights' ) && is_array( $form ) && ! empty( $form['id'] ) ) { AIEO_Form_Insights::flush( (int) $form['id'] ); } unset( $entry ); }, 20, 2 ); // 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'; // Guarded: the Ads tab is optional, and a partial deploy that is missing it // must degrade to "no Ads tab" rather than fataling the whole admin. $aieo_ads_module = plugin_dir_path( __FILE__ ) . '/includes/dmm/modules/class-aieo-dmm-ads.php'; if ( file_exists( $aieo_ads_module ) ) { require_once $aieo_ads_module; } 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() ); } if ( class_exists( 'AIEO_DMM_Ads' ) ) { $dmm->register_module( new AIEO_DMM_Ads() ); } // 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 ); // Identity substrate (Customer Intelligence Core, Phase A) โ€” same self-heal // net: wp_aieo_subjects + wp_aieo_session_identity install on the next admin // load after a file upload, no reactivation / version bump needed. add_action( 'admin_init', static function () { $subjects_schema = AIEO_DIR . 'includes/dmm/sql/subjects-schema.php'; if ( file_exists( $subjects_schema ) ) { require_once $subjects_schema; if ( function_exists( 'aieo_subjects_maybe_install' ) ) { aieo_subjects_maybe_install(); } } }, 5 ); // Social (site-owned apps, master plan R3โ€ฒ) local schema self-heal. add_action( 'admin_init', static function () { $social_schema = AIEO_DIR . 'includes/dmm/sql/social-schema.php'; if ( file_exists( $social_schema ) ) { require_once $social_schema; if ( function_exists( 'aieo_social_install_schema' ) && get_option( 'aieo_social_schema_version' ) !== ( defined( 'AIEO_SOCIAL_SCHEMA_VERSION' ) ? AIEO_SOCIAL_SCHEMA_VERSION : '1' ) ) { aieo_social_install_schema(); update_option( 'aieo_social_schema_version', defined( 'AIEO_SOCIAL_SCHEMA_VERSION' ) ? AIEO_SOCIAL_SCHEMA_VERSION : '1', false ); } } }, 5 ); // Stripped-text read model (wp_aieo_content_text) self-heal โ€” without // this net the table materializes only when the data-prep cron stage // first runs. class_exists loads the class where the mu autoloader is // installed; elsewhere the load-on-touch net inside the scheduler stage // still covers it. add_action( 'admin_init', static function () { if ( class_exists( 'AIEO_Content_Text' ) ) { AIEO_Content_Text::maybe_install(); } }, 5 ); } // โ”€โ”€ Social (site-owned) runtime bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Loaded on EVERY request (not just admin): the OAuth callback + publish paths // are REST/cron, not admin. Each require is file_exists-guarded so a partial // deploy can never fatal. The shop owns its platform apps + tokens locally // (R3โ€ฒ); nothing here is portal-brokered. add_action( 'plugins_loaded', static function () { foreach ( array( 'includes/social/class-aieo-social-store.php', 'includes/social/connectors/class-aieo-social-connector-x.php', 'includes/social/connectors/class-aieo-social-connector-linkedin.php', 'includes/social/connectors/class-aieo-social-connector-tiktok.php', 'includes/social/class-aieo-social-oauth.php', 'includes/social/drivers/class-aieo-social-driver-base.php', 'includes/social/drivers/class-aieo-social-driver-facebook.php', 'includes/social/drivers/class-aieo-social-driver-instagram.php', 'includes/social/drivers/class-aieo-social-driver-x.php', 'includes/social/drivers/class-aieo-social-driver-linkedin.php', 'includes/social/drivers/class-aieo-social-driver-tiktok.php', 'includes/social/class-aieo-social-ai-writer.php', 'includes/social/class-aieo-social-video.php', 'includes/social/class-aieo-social-video-metabox.php', 'includes/social/class-aieo-social-metabox.php', 'includes/social/class-aieo-social-jobs.php', 'includes/social/class-aieo-social-broadcast.php', 'includes/social/class-aieo-social-outcome-reporter.php', 'includes/social/class-aieo-social-boost.php', 'includes/class-aieo-product-affinity.php', // Before the boost slate and the SEO pillars, both of which read it. 'includes/class-aieo-basket-role.php', 'includes/class-aieo-boost-candidates.php', 'includes/class-aieo-campaign-forecasts.php', ) as $rel ) { $abs = AIEO_DIR . $rel; if ( file_exists( $abs ) ) { require_once $abs; } } if ( class_exists( 'AIEO_Social_Jobs' ) ) { AIEO_Social_Jobs::init(); } if ( class_exists( 'AIEO_Social_Outcome_Reporter' ) ) { AIEO_Social_Outcome_Reporter::init(); } if ( class_exists( 'AIEO_Boost_Candidates' ) ) { AIEO_Boost_Candidates::init(); } // Behaviour-informed SEO opportunity grid. Sits next to the boost scorer // because the boost scorer READS its table (the seo_opportunity component). // It shipped for a long time with a rebuild() nobody scheduled โ€” see // AIEO_SEO_Opportunity_Scorer::init() for why that mattered. Self-gated on // seo.enabled; scheduling happens on `init`. // Explicit require rather than trusting the mu-plugin autoloader: this is // the one class whose absence was invisible for months, so its wiring does // not get to depend on a loader that may not be installed everywhere. $aieo_opp_file = AIEO_DIR . 'includes/seo/class-aieo-seo-opportunity-scorer.php'; if ( file_exists( $aieo_opp_file ) ) { require_once $aieo_opp_file; } if ( class_exists( 'AIEO_SEO_Opportunity_Scorer' ) ) { AIEO_SEO_Opportunity_Scorer::init(); } if ( class_exists( 'AIEO_Campaign_Forecasts' ) ) { AIEO_Campaign_Forecasts::init(); } if ( class_exists( 'AIEO_Social_OAuth' ) ) { add_action( 'rest_api_init', array( 'AIEO_Social_OAuth', 'register_routes' ) ); } if ( class_exists( 'AIEO_Social_Metabox' ) ) { AIEO_Social_Metabox::register(); } if ( class_exists( 'AIEO_Social_Video_Metabox' ) ) { AIEO_Social_Video_Metabox::register(); } // Register the site-owned platform drivers built on the Facebook pattern. add_filter( 'aieo_social_platforms', static function ( $drivers ) { foreach ( array( 'instagram' => 'AIEO_Social_Driver_Instagram', 'x' => 'AIEO_Social_Driver_X', 'linkedin' => 'AIEO_Social_Driver_Linkedin', 'tiktok' => 'AIEO_Social_Driver_Tiktok', ) as $code => $class ) { if ( class_exists( $class ) ) { $drivers[ $code ] = $class; } } return $drivers; } ); }, 9 ); // Signal registry โ€” the declarative catalogue of EVERY metric this plugin // computes (table, producer, cron, consumers) plus cheap live health. Read it // before building any new metric: this exists because a scorer that already // shipped got rebuilt from scratch by someone who had no way to discover it. // Admin/CLI only: it is a diagnostic catalogue, not a runtime dependency, and // the front end must not pay to load it. Everything that needs it outside those // contexts (the Claude ability, the opportunity-scorer cron) lazy-requires it. // file_exists-guarded like every other require in this file: an UNGUARDED // require_once here white-screens all of wp-admin AND every WP-CLI command on // any build where the file is missing (a .distignore slip, a half-finished // rsync, the runtime/core plugin split where only one copy carries it). A // diagnostic catalogue must never be able to take the site down; the three // callers that need it outside this path already lazy-require it defensively. if ( is_admin() || ( defined( 'WP_CLI' ) && WP_CLI ) ) { $aieo_registry_file = plugin_dir_path( __FILE__ ) . 'includes/class-aieo-signal-registry.php'; if ( file_exists( $aieo_registry_file ) ) { require_once $aieo_registry_file; } } // 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(); } // Goal tracker (site-profiles Phase 3): server-side conversion events (form // submits, newsletter signups, LearnDash enrol/complete, registrations) into // the session event stream โ€” the content analog of add-to-cart. Consent-safe // by construction (records only alongside the consented session cookie). // Capability shim (products are content, content is products): 60+ AIEO // handlers historically gate on manage_woocommerce, which NO user holds on // sites without WooCommerce (quhoma, alfavitari, portals) - every rebuild/ // export button dies with Permission denied there. Grant it to // administrators when WooCommerce is absent; zero change on shops. add_filter('user_has_cap', function ($allcaps) { if (!empty($allcaps['manage_options']) && empty($allcaps['manage_woocommerce']) && !class_exists('WooCommerce')) { $allcaps['manage_woocommerce'] = true; } return $allcaps; }); // Canonical landing/campaign stats โ€” parses landing_path + utm_* out of the // session statics and keeps the durable daily rollup. Registers only the // aieo_archive_run hook here; the heavy lifting runs inside the crons. if ( file_exists( AIEO_DIR . 'includes/class-aieo-landing-stats.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-landing-stats.php'; if ( class_exists( 'AIEO_Landing_Stats' ) ) { AIEO_Landing_Stats::register(); } } // Goal collector โ€” byte-identical dual-copy (ยง21 Gap 1). The require is // class_exists-guarded (runtime's copy may already be loaded via the autoloader) // and the file itself carries a co-load return guard. // // ๐Ÿšจ REGISTER LATE, AND DO NOT MOVE THIS BACK TO TOP LEVEL. // // This used to be a top-level `! defined( 'AIEO_RUNTIME_OWNS_GOALS' )`, which is // a guard that CANNOT FAIL. `active_plugins` is stored [ai-eshop-optimizer, // ai-eshop-optimizer-pro, โ€ฆ-runtime], so core is included first and no // AIEO_RUNTIME_OWNS_* constant exists yet. Identical finding, and identical // remedy, to the consent gate at :1299 (measured 2026-08-08), the AJAX shipping // methods at :3870 and the SSO display at :3905. // // It looked correct because two accidents covered for it: the autoloader hands // core the RUNTIME's copy of the class, and both plugins add the identical // static callable at the identical priority, which WordPress dedupes by callback // id. Change either side to a closure, an instance method or another priority // and the goal tracker silently registers TWICE. On a conversion signal, double // counting is the kind of bug that is believed for months. // // โš ๏ธ AND THE CONSTANT IS THE RIGHT SOURCE, not a presence test. An earlier fix // here asked AIEO_Runtime_Presence::activated() instead, which is subtly worse: // the runtime can be active and still DECLINE to own the front end. It already // does exactly that at its own file scope when the version floor is unmet // (ai-eshop-optimizer-runtime.php:147 returns after defining // AIEO_RUNTIME_VERSION but before the OWNS constants), and it now does it again // when unlicensed. A presence test reads true in both cases, core stands back, // the stood-down runtime never registers, and the signal lands NOWHERE. Reading // the constant on plugins_loaded asks the only question that matters โ€” "did the // runtime actually claim this?" โ€” and is correct for every present and future // stand-down reason without core reproducing any of them. // // init@20 still happens, because init fires after plugins_loaded. register() // keeps its static-flag guard and the callable stays a plain static pair, so the // dedupe remains a second net rather than the mechanism. if ( ! class_exists( 'AIEO_Goal_Tracker' ) && file_exists( AIEO_DIR . 'includes/class-aieo-goal-tracker.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-goal-tracker.php'; } add_action( 'plugins_loaded', static function () { if ( defined( 'AIEO_RUNTIME_OWNS_GOALS' ) ) { return; // the runtime claimed it and registered its own copy } if ( class_exists( 'AIEO_Goal_Tracker' ) ) { add_action( 'init', array( 'AIEO_Goal_Tracker', 'register' ), 20 ); } }, 11 ); // On-site search demand collector (semantic engine Sub-plan 9) โ€” core ?s= // provider + FiboSearch provider (live hook, standalone-endpoint shim, // one-time 30-day stats backfill) into wp_aieo_search_demand_daily. The // mu-plugin autoloader resolves includes/-root classes; the guarded require // mirrors the Goal Tracker block above for installs where it is stale. if ( ! class_exists( 'AIEO_Search_Signal' ) && file_exists( AIEO_DIR . 'includes/class-aieo-search-signal.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-search-signal.php'; } if ( class_exists( 'AIEO_Search_Signal' ) ) { add_action( 'init', array( 'AIEO_Search_Signal', 'bootstrap' ), 20 ); } // 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'; // The matching substrate (AIEO_Match_Text), the attribute-axis resolver and // the duplicate-products arc (groups / merge / WC-feature check) used to be // required here as "core rather than Pro" utilities. The 2026-08-20 operator // ruling moved them, with the catalogue importer and the exchanger, to the // Pro plugin's engines classmap (ai-eshop-optimizer-pro/includes/engines/): // they concentrate the Greek commercial-text matching IP and are consumed // only by Pro features. Free code reaches them through class_exists() โ€” the // Pro loader registers at its main-file scope, so the classes resolve at // every hook priority whenever Pro is installed, and the module shells show // an install pointer when it is not. // The capability registry โ€” what this install exposes and what each part // costs. Loaded unconditionally because WP-CLI, the assistant and the Home // counter all read it, and a lazily-required registry is one that is missing // exactly when support needs it. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-capability-registry.php'; // Which of those capabilities are documented on rooster.systems, and as of which // plugin version. Sits beside the registry deliberately: the pair is what turns // "we shipped a change" into "โ€ฆand these manual pages now describe the old // behaviour". Reads a committed JSON index, so it costs nothing until asked. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-docs-registry.php'; // Which of those capabilities a shop actually USES. Gated: the free build must // not phone home (wp.org guidelines), so collection needs Pro plus a recorded // consent โ€” one check, in AIEO_Action_Telemetry::is_collecting(). The class // loads unconditionally because the SCRIPT it configures ships in both builds // and the gate has to be answerable either way. require_once plugin_dir_path(__FILE__) . '/includes/dmm/class-aieo-action-telemetry.php'; AIEO_Action_Telemetry::init(); // 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. // // DUAL-BUNDLED since 2026-08-08. The class is byte-identical in the runtime // plugin, which serves the banner when both are installed so a storefront // request can be answered by the lighter plugin alone, and so the banner // survives this plugin being trimmed off the front end entirely. // // class_exists guards the require because require_once keys on the PATH, not // the class: the runtime's copy lives at a different path, so without this a // second declaration of the same class would load. if ( ! class_exists( 'AIEO_Consent', false ) ) { require_once plugin_dir_path(__FILE__) . '/includes/consent/class-aieo-consent.php'; } // ๐Ÿšจ REGISTER LATE, AND DO NOT MOVE THIS BACK TO TOP LEVEL. // // Not `if ( ! defined( 'AIEO_RUNTIME_OWNS_CONSENT' ) )` โ€” that guard reads as // obvious and is always false here. Measured 2026-08-08: active_plugins is // stored [ai-eshop-optimizer, ai-eshop-optimizer-pro, โ€ฆ-runtime], so THIS // plugin loads first and the runtime has not defined its constant yet. Both // copies would then register: two banners, two Consent Mode blocks, a doubled // audit row. // // plugins_loaded@99 runs after the runtime's own registration at @10, and // register() is idempotent, so the runtime wins when it is present and this // call covers a standalone install. Order is settled at run time, which is the // only place it is actually known. add_action( 'plugins_loaded', static function () { if ( class_exists( 'AIEO_Consent' ) ) { AIEO_Consent::instance()->register(); } }, 99 ); // Checkout-integrity guards + abandoned-cart live capture. All three are DUAL // files โ€” byte-identical in both plugins at the same path, same class name, // declaration self-guarded with class_exists(..., false) โ€” so whichever plugin // loads first wins and ::instance() being a singleton means the hooks register // exactly once even with both plugins active. // // Deliberately NOT gated on AIEO_RUNTIME_OWNS_AJAX. An earlier version was, and // that made construction ORDER-DEPENDENT: whichever bootstrap ran first built // the object, and if that was this one while the constant was already defined, // the constructor returned early and the hooks were never registered by either // plugin. The singleton plus the in-file guard is the correct mechanism, and it // is the same one class-aieo-runtime-birthday.php has always used. add_action('plugins_loaded', static function () { $aieo_dual = array( 'AIEO_Runtime_Payment_Fee_Guard' => 'includes/class-aieo-runtime-payment-fee-guard.php', 'AIEO_Runtime_Stock_Hold_Guard' => 'includes/class-aieo-runtime-stock-hold-guard.php', 'AIEO_Runtime_Carts_Live_Capture' => 'includes/class-aieo-runtime-carts-live-capture.php', ); foreach ($aieo_dual as $aieo_cls => $aieo_rel) { if (!class_exists($aieo_cls)) { $aieo_file = plugin_dir_path(__FILE__) . $aieo_rel; if (file_exists($aieo_file)) { require_once $aieo_file; } } if (class_exists($aieo_cls)) { $aieo_cls::instance(); } } }, 20); // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // SIGN-IN SECURITY โ€” two-factor authentication, the private sign-in address and // the failed-sign-in lockout. Settings live on Set Up โ†’ Security; ENFORCEMENT // lives here, because all three have to run on wp-login.php, and wp-login.php // loads plugins but never loads a DMM module. // // โš ๏ธ REGISTERED AT plugins_loaded@1, EARLIER THAN ANYTHING ELSE IN THIS FILE. // AIEO_Login_URL::route() hooks `wp_loaded` and must have its filters in place // before WordPress generates a single login URL; the lockout gate must be on // `authenticate` before any other plugin's. Moving this later does not fail // loudly โ€” it fails as a password-reset email pointing at a door that now // refuses it. // // Each class self-gates on AIEO_Login_Security::feature_active(), so registering // the hooks costs a settings read and nothing else on a shop that uses none of // this. Nothing here runs when AIEO_LOGIN_SECURITY_BYPASS is defined in // wp-config.php, which is the documented way back in. // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ add_action( 'plugins_loaded', static function () { $aieo_ls_dir = plugin_dir_path( __FILE__ ) . 'includes/login-security/'; foreach ( array( 'class-aieo-login-security.php', // settings + the shared role list 'class-aieo-qr.php', // enrolment QR, drawn locally 'class-aieo-totp.php', // RFC 6238 'class-aieo-two-factor.php', // the challenge at sign-in 'class-aieo-login-url.php', // the private address 'class-aieo-login-lockout.php', // repeated-failure throttle ) as $aieo_ls_file ) { $aieo_ls_path = $aieo_ls_dir . $aieo_ls_file; if ( file_exists( $aieo_ls_path ) ) { require_once $aieo_ls_path; } } if ( ! class_exists( 'AIEO_Login_Security' ) ) { return; // Partial install; leave sign-in exactly as WordPress ships it. } if ( class_exists( 'AIEO_Two_Factor' ) ) { AIEO_Two_Factor::register(); } if ( class_exists( 'AIEO_Login_URL' ) ) { AIEO_Login_URL::register(); } // The lockout needs its log table; the schema file is required here rather // than relying on the DMM bootstrap, which does not run on wp-login.php. if ( class_exists( 'AIEO_Login_Lockout' ) ) { $aieo_ls_schema = plugin_dir_path( __FILE__ ) . 'includes/dmm/sql/login-security-schema.php'; if ( file_exists( $aieo_ls_schema ) ) { require_once $aieo_ls_schema; } if ( function_exists( 'aieo_login_attempts_table' ) ) { AIEO_Login_Lockout::register(); } } }, 1 ); // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // SITE HARDENING (Tools & Settings -> Security -> "Security Tightening"). // // Same shape as the block above and for the same reason: hiding the version, // refusing feeds, restraining XML-RPC and sending response headers all happen // on requests that never load a DMM module. The screen only reads and writes // the settings row. // // โš  PRIORITY 1 IS NOT DECORATION. The file-editor defence filters // map_meta_cap, and it has to be in place before anything asks whether the // current user can edit_plugins -- the admin menu is built out of those // answers. Registering later leaves the editors in the menu for that request. // // Every setting is off until an operator switches it on, so on a shop that // uses none of this the cost is one autoloaded option read. // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ add_action( 'plugins_loaded', static function () { $aieo_sh_path = plugin_dir_path( __FILE__ ) . 'includes/security-hardening/class-aieo-security-hardening.php'; if ( ! file_exists( $aieo_sh_path ) ) { return; // Partial install; leave the site exactly as WordPress ships it. } require_once $aieo_sh_path; if ( class_exists( 'AIEO_Security_Hardening' ) ) { AIEO_Security_Hardening::register(); } }, 1 ); // 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(); // Audience-vocabulary migration โ€” rewrites SAVED audience rule trees after the // segment rename ('lapsed' โ†’ 'overdue') and the deletion of the 'dormant' // segment. This MUST be wired: nothing validates a segment slug at compile time // (AIEO_Audience_Rules::compile() turns any slug into a perfectly valid // `segments_json LIKE '%"x"%'`), so a saved rule naming a dead segment stays // syntactically fine and silently starts matching ZERO customers the moment the // assignment sproc stops writing that slug โ€” 13,883 โ†’ 0 for 'dormant', 1,872 โ†’ 0 // for 'lapsed' on the reference bed. The workflow gated on such an audience does // not error; it just stops sending. Same shape as the wc-compat migration above: // version-gated self-heal plus `wp aieo migrate audience-vocabulary [--dry-run]`. require_once plugin_dir_path(__FILE__) . '/includes/migrations/class-aieo-audience-vocabulary-migration.php'; AIEO_Audience_Vocabulary_Migration::bootstrap(); // Un-bake English wording frozen into settings before the defaults were // deferred to render time. A code fix alone cannot repair this: the render-time // fill only fills BLANKS, on purpose, so a site that had already saved its // settings keeps rendering English and looks exactly as unfixed as before. That // is why this defect has been closed more than once and kept returning. Resets // a stored value only when it is byte-identical to a default this plugin // shipped, so an operator's own wording is never touched. See the class header // for why the allow-list is curated and must never be generated. require_once plugin_dir_path(__FILE__) . '/includes/migrations/class-aieo-baked-i18n-repair.php'; add_action('admin_init', array('AIEO_Baked_I18n_Repair', 'maybe_run'), 40); // 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'; } // The Ctrl/Cmd+K palette lives in assets/js/aieo-admin-navigator.js, enqueued by // AIEO_DMM::enqueue_admin_assets(). AIEO_Command_Palette was its predecessor and // had already disabled itself โ€” init() executed a bare `return;` before // registering a single hook, so that it could not render a second competing // overlay. Class and both its JS files removed in 11.0. // 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(); } // Identity substrate resolver (Customer Intelligence Core, Phase A) โ€” // registers the hourly Action-Scheduler tick that drains the sessionโ†’subject // queue (wp_aieo_session_identity โ†’ wp_aieo_subjects) and the wp_login // identification rung. No-op when the tables/queue are empty. if ( class_exists( 'AIEO_Subject_Resolver' ) ) { AIEO_Subject_Resolver::bootstrap(); } // Behavioural embedding (Customer Intelligence Core, ยง15) โ€” the interaction // aggregation job (raw firehose โ†’ wp_aieo_interaction_summary), the per-subject // vector builder (interaction-weighted aggregation of item Voyage vectors โ†’ // wp_aieo_chat_embeddings post_type='user'), and the serving layer // (recommendations / lookalikes / instigators + digest personalisation). Each // AS job is a cheap no-op until the substrate has data. // Explicit requires so the behavioural layer loads even on installs without // the mu-plugin autoloader (#27) โ€” otherwise class_exists() below is false and // the AS jobs silently never schedule. foreach ( array( 'class-aieo-interaction-summary.php', 'class-aieo-behavioural-vector.php', 'class-aieo-behavioural-reco.php' ) as $aieo_bh_file ) { $aieo_bh_path = AIEO_DIR . 'includes/' . $aieo_bh_file; if ( file_exists( $aieo_bh_path ) ) { require_once $aieo_bh_path; } } if ( class_exists( 'AIEO_Interaction_Summary' ) ) { AIEO_Interaction_Summary::bootstrap(); } if ( class_exists( 'AIEO_Behavioural_Vector' ) ) { AIEO_Behavioural_Vector::bootstrap(); } if ( class_exists( 'AIEO_Behavioural_Reco' ) ) { AIEO_Behavioural_Reco::bootstrap(); } // Operator intelligence surfaces (ยง17 profile, ยง19 audiences) + the manual // campaign/workflow engine. Explicit requires so they load without the // mu-autoloader; the runtime tick fires triggers + walks steps (INERT until // the channel engines are flipped live). foreach ( array( 'class-aieo-i18n.php', 'class-aieo-campaign-i18n.php', 'class-aieo-coupon-minter.php', 'class-aieo-merge-tags.php', 'class-aieo-customer-profile.php', 'class-aieo-customer-purchases.php', 'class-aieo-audience-rules.php', 'class-aieo-contact-discovery.php', 'class-aieo-order-rules.php', 'class-aieo-workflows.php', 'class-aieo-workflow-runtime.php', 'class-aieo-workflow-stats.php', 'class-aieo-workflow-incrementality.php', 'class-aieo-send-time.php', 'class-aieo-workflow-ab.php', 'class-aieo-order-touchpoints.php', 'class-aieo-marketing-stats.php' ) as $aieo_wf_file ) { $aieo_wf_path = AIEO_DIR . 'includes/' . $aieo_wf_file; if ( file_exists( $aieo_wf_path ) ) { require_once $aieo_wf_path; } } // Registered here rather than from the DMM module: the batch handler has to // exist in the Action Scheduler / cron request too, where admin modules are // not loaded, or a queued conversion run would never advance. if ( class_exists( 'AIEO_Contact_Discovery' ) ) { AIEO_Contact_Discovery::register_hooks(); } // Native-first coupon minter (ยง12) โ€” registers order-redemption tracking + // the daily cleanup of expired-unredeemed coupons. Mints nothing on its own. // (Also registers the shared AIEO_Offer_Link apply-link consumer.) if ( class_exists( 'AIEO_Coupon_Minter' ) ) { AIEO_Coupon_Minter::bootstrap(); } // Birthday capture (ยง21 Gap 2) โ€” shared byte-identical class; boot the core // copy so a core-only site (no runtime) also registers the checkout field + // syncs the value. When runtime is active it boots the same singleton first; // instance() is idempotent so the field registers once. Reads aieo_dmm_birthday. // Explicit require (not just file_exists) so it loads even without the mu // autoloader (#27), same idiom as the behavioural layer above. if ( ! class_exists( 'AIEO_Runtime_Birthday' ) && file_exists( AIEO_DIR . 'includes/class-aieo-runtime-birthday.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-runtime-birthday.php'; } if ( class_exists( 'AIEO_Runtime_Birthday' ) ) { AIEO_Runtime_Birthday::instance(); } // Unified contact-consent service (ยงVII.18) โ€” single source of truth for marketing // consent (email suppression + Newsletter Plugin), shared by the checkout opt-in and // the My-Account "Email & marketing preferences" toggle. Static utility; just load it. if ( ! class_exists( 'AIEO_Contact_Consent' ) && file_exists( AIEO_DIR . 'includes/class-aieo-contact-consent.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-contact-consent.php'; } // Checkout marketing opt-in checkbox (ยงVII.18) โ€” shared byte-identical class; boot the // core copy so a core-only site also registers the Store-API field. Idempotent singleton // (reads aieo_dmm_marketing_optin; zero overhead when disabled). if ( ! class_exists( 'AIEO_Runtime_Marketing_Optin' ) && file_exists( AIEO_DIR . 'includes/class-aieo-runtime-marketing-optin.php' ) ) { require_once AIEO_DIR . 'includes/class-aieo-runtime-marketing-optin.php'; } if ( class_exists( 'AIEO_Runtime_Marketing_Optin' ) ) { AIEO_Runtime_Marketing_Optin::instance(); } // AutomateWoo decommission/migration jobs (ยง12b) โ€” birthday migration, AW // coupon ledger import, opt-out import. Registers AS/WP-CLI/AJAX entry points // only; NOTHING runs until an operator triggers it. $aieo_awdec_path = AIEO_DIR . 'includes/class-aieo-aw-decommission.php'; if ( ! class_exists( 'AIEO_AW_Decommission' ) && file_exists( $aieo_awdec_path ) ) { require_once $aieo_awdec_path; } if ( class_exists( 'AIEO_AW_Decommission' ) ) { AIEO_AW_Decommission::bootstrap(); } if ( class_exists( 'AIEO_Workflow_Runtime' ) ) { AIEO_Workflow_Runtime::bootstrap(); } // ยงIV.3 lifetime "ever bought X" rollup โ€” nightly TRUNCATE+INSERT of // wp_aieo_customer_purchases from the order snapshots, feeding the Audience // Builder's ever_purchased_* fields. Registers only its nightly AS job; sends // nothing and rebuilds nothing until the schedule fires (or an operator runs it). if ( class_exists( 'AIEO_Customer_Purchases' ) ) { AIEO_Customer_Purchases::bootstrap(); } // Per-workflow OUTCOME rollup (campaign funnel: sentโ†’clickedโ†’redeemedโ†’revenue) โ€” // reconciles the dispatch + coupon-grant ledgers (+ optional last-click order_stats) // into wp_aieo_workflow_stats. Registers a 6-hourly AS rebuild; feeds the Campaigns // builder funnel + the AI workflow-performance ability. Reads ledgers only; sends nothing. if ( class_exists( 'AIEO_Workflow_Stats' ) ) { AIEO_Workflow_Stats::bootstrap(); } // Unified "All Marketing Performance" rollup โ€” reconciles workflows + push triggers + // broadcasts + paid ad campaigns into wp_aieo_marketing_stats (owned + paid, one surface) // for the campaigns card + the aieo/marketing-performance chat ability. Reads only. if ( class_exists( 'AIEO_Marketing_Stats' ) ) { AIEO_Marketing_Stats::bootstrap(); } // ยง13 efficiency bandit โ€” nightly Thompson-sampling variant reweight of // wp_aieo_push_templates.weight + the conversion-join reward signal + the // msg_stats rollup. Registers only its nightly AS job + schema self-heal; // reweights nothing until a pool has โ‰ฅ2 active variants with data. $aieo_bandit_path = AIEO_DIR . 'includes/class-aieo-bandit.php'; if ( file_exists( $aieo_bandit_path ) ) { require_once $aieo_bandit_path; if ( class_exists( 'AIEO_Bandit' ) ) { AIEO_Bandit::bootstrap(); } } // ยง19.8 AI auto-suggestion โ€” nightly opportunity scanner โ†’ the proposals inbox // (T0 suggest-only). Registers the nightly AS scan; proposes nothing until it // runs, and accepting a proposal only creates a DRAFT workflow. $aieo_proposal_path = AIEO_DIR . 'includes/class-aieo-proposal-engine.php'; if ( file_exists( $aieo_proposal_path ) ) { require_once $aieo_proposal_path; if ( class_exists( 'AIEO_Proposal_Engine' ) ) { AIEO_Proposal_Engine::bootstrap(); } } // Email channel (Customer Intelligence Core, Phase B) โ€” INERT by default: // aieo_email_engine_status defaults to 'paused' and the sender re-checks it, // so NOTHING sends until an operator explicitly flips it live. Load the // classes, ensure the paused default exists, and register the dormant digest // (its recurring tick is only scheduled while the engine is live). $aieo_email_dir = AIEO_DIR . 'includes/dmm/email/'; foreach ( array( 'class-aieo-email-engine.php', 'class-aieo-email-sender.php', 'class-aieo-email-channel.php', 'class-aieo-email-tracking.php', 'class-aieo-email-unsubscribe.php', 'class-aieo-newsletter-digest.php', 'class-aieo-channel-cascade.php' ) as $aieo_email_file ) { if ( file_exists( $aieo_email_dir . $aieo_email_file ) ) { require_once $aieo_email_dir . $aieo_email_file; } } if ( class_exists( 'AIEO_Email_Engine' ) ) { AIEO_Email_Engine::ensure_default(); } // ยงVII.5 โ€” email open/click measurement (adds opened_at; serves the pixel + click redirect). if ( class_exists( 'AIEO_Email_Tracking' ) ) { AIEO_Email_Tracking::bootstrap(); } // ยงVII.14 โ€” one-click unsubscribe endpoint (footer link + List-Unsubscribe headers on every send). if ( class_exists( 'AIEO_Email_Unsubscribe' ) ) { AIEO_Email_Unsubscribe::bootstrap(); } if ( class_exists( 'AIEO_Newsletter_Digest' ) ) { AIEO_Newsletter_Digest::bootstrap(); } // Channel cascade (ยง11) โ€” push-first, email-fallback primitive. Registers only // its fallback AS handler; runs nothing until a trigger calls dispatch(). if ( class_exists( 'AIEO_Channel_Cascade' ) ) { AIEO_Channel_Cascade::bootstrap(); } // Embedding backfill (Customer Intelligence Core, Phase C) โ€” operator- // initiated, metered posts/pages embedding via Action Scheduler + a cost // preview. Registers the AS tick + admin AJAX; idle until an operator runs it. $aieo_embed_backfill = AIEO_DIR . 'includes/chat/class-aieo-embedding-backfill.php'; if ( file_exists( $aieo_embed_backfill ) ) { require_once $aieo_embed_backfill; if ( class_exists( 'AIEO_Embedding_Backfill' ) ) { AIEO_Embedding_Backfill::bootstrap(); } } // Semantic onboarding backfill โ€” same AIEO_Batch_Runner engine driving // index-drain โ†’ bounded data-plane push. Idle until an operator starts // it from SEO โ†’ Internal Linking โ†’ Settings; no-ops without the pro engine. $aieo_sem_backfill = AIEO_DIR . 'includes/class-aieo-semantic-backfill.php'; if ( file_exists( $aieo_sem_backfill ) ) { require_once $aieo_sem_backfill; if ( class_exists( 'AIEO_Semantic_Backfill' ) ) { AIEO_Semantic_Backfill::bootstrap(); } } // CLI driver for the same runner โ€” `wp aieo semantic onboard` spawns one // fresh PHP process per chunk (the 5k-catalogue OOM fix). The file guards // on WP_CLI itself and registers its own command. if ( defined( 'WP_CLI' ) && WP_CLI ) { $aieo_sem_backfill_cli = AIEO_DIR . 'includes/class-aieo-semantic-backfill-cli.php'; if ( file_exists( $aieo_sem_backfill_cli ) ) { require_once $aieo_sem_backfill_cli; } } // Sub-plan 9 fusion: behavioral percentiles for the semantic consumers // (related ordering, graph node sizing). Pure read API โ€” no hooks; the // co-occurrence rollup rides the aggregate-cron chain, not init. $aieo_sem_fusion = AIEO_DIR . 'includes/class-aieo-semantic-fusion.php'; if ( file_exists( $aieo_sem_fusion ) ) { require_once $aieo_sem_fusion; } // The storefront's reader for semantic related content. Required here, not // left to an autoloader, because MOST INSTALLS ARE CORE-ONLY: there is no // Pro, and the shared mu-plugin autoloader is a fleet convenience that a // wp.org install does not have. It is a pure reader โ€” one bounded SELECT // against a table it checks for first โ€” so on a site that has never had the // engine it answers "no data", the rail renders nothing, and nothing warns // or fatals. Loading it costs one require of a small file. // The two relation policies the reader consults. Loaded BEFORE it, and // unconditionally for the same reason: they are pure readers over settings // and terms, both ship OFF, and on a site that has never enabled either one // the reader's first gate returns before it touches them. foreach ( array( 'class-aieo-semantic-audience.php', 'class-aieo-semantic-temporality.php' ) as $aieo_sem_policy ) { $aieo_sem_policy_path = AIEO_DIR . 'includes/' . $aieo_sem_policy; if ( file_exists( $aieo_sem_policy_path ) ) { require_once $aieo_sem_policy_path; } } $aieo_sem_source = AIEO_DIR . 'includes/class-aieo-semantic-reco-source.php'; if ( file_exists( $aieo_sem_source ) ) { require_once $aieo_sem_source; } // ...and the placement that gives its cards somewhere to land when the // theme's template has not placed the block itself. Front-end only: on a // core-only install its first gate finds no data and it does nothing. $aieo_sem_place = AIEO_DIR . 'includes/class-aieo-semantic-related-placement.php'; if ( file_exists( $aieo_sem_place ) && ! is_admin() ) { require_once $aieo_sem_place; AIEO_Semantic_Related_Placement::bootstrap(); } // design_source = 'theme': hand the precomputed ids to the THEME's own // related block instead of rendering a rail of ours. Loaded unconditionally // (the admin needs supported() for the settings notice); engage() is // front-end only and returns immediately unless the mode is selected. // class_exists guard, not just require_once: require_once dedupes by PATH, // so if a runtime copy of this file ever appeared the runtime-first // autoloader would define the class from there and this require would // fatal with "Cannot declare class". Same pattern as AIEO_Session_Tracker // above. (There is deliberately NO runtime copy โ€” the runtime registers // nothing on the related-products surface and does not know design_source.) $aieo_theme_native = AIEO_DIR . 'includes/class-aieo-theme-native-recos.php'; if ( file_exists( $aieo_theme_native ) && ! class_exists( 'AIEO_Theme_Native_Recos' ) ) { require_once $aieo_theme_native; } if ( class_exists( 'AIEO_Theme_Native_Recos' ) ) { AIEO_Theme_Native_Recos::bootstrap(); } // The rail everywhere the product bundle does not reach: articles, the // shortcode, and a hand-placed block. Moved here from Pro โ€” Pro is a // back-end plugin, and this was the last front-end thing it did. register() // must run in the admin too (the block editor needs the block registered). $aieo_sem_display = AIEO_DIR . 'includes/class-aieo-semantic-related-display.php'; if ( file_exists( $aieo_sem_display ) ) { require_once $aieo_sem_display; AIEO_Semantic_Related_Display::register(); if ( ! is_admin() ) { AIEO_Semantic_Related_Display::bootstrap(); } } // Sub-plan 8 graph cache + block ship in Rooster One Pro (tiered // split); the free side carries only the recompute-queue dispatch seam. // 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(); $installed = array(); // Files newly placed into mu-plugins/ this run. $refused = array(); // name => installed version we declined to downgrade. foreach ($files as $name => $opts) { $src = $src_dir . $name; $dst = $dst_dir . '/' . $name; if (!file_exists($src)) { continue; } $is_new = !file_exists($dst); // 1. Copy the bundled AIEO version into mu-plugins/ (idempotent โ€” // skip when the installed file is already byte-identical). // // Content comparison, not mtime. mtime is unreliable across // unzip/rsync/scp deploys, and trusting it is how the fleet drifted // into two live versions of the AS core file โ€” 15 of 36 sites spent // months running one with a known duplicate-minting bug. The bundled // copy is the source of truth; a hand-edit on a site is drift, not // something to preserve. $needs_copy = $is_new || md5_file($src) !== md5_file($dst); // 1a. DIRECTION GUARD โ€” md5 difference does not say WHICH WAY. // // "Different, so overwrite" is right for drift and wrong for a // downgrade, and it cannot tell them apart. Any route that puts an // older plugin on a site whose mu-plugins are newer โ€” a core // rollback, a stale package, a restored backup, an operator // hotfixing the AS core ahead of the release that carries it โ€” // silently reverts a NEWER Action Scheduler core to an older one, // reintroducing whatever it fixed. The fleet has already lost // months to two live versions of this exact file. // // This matters far more on co-installed hosting. These files change // Action Scheduler behaviour (query limits, retention, recurring-job // dedup) for EVERY consumer on the site, not just us: on a // Cloudways app that means WooCommerce, Omnisend and anything else // scheduling work. Silently downgrading the component all of them // run is not ours to do. // // Version headers, not mtime, and only when BOTH sides declare one. // An undeclared version is treated as "older", preserving today's // behaviour for the two bundled files that carry no Version header. if ($needs_copy && !$is_new && function_exists('get_file_data')) { $src_v = get_file_data($src, array('Version' => 'Version')); $dst_v = get_file_data($dst, array('Version' => 'Version')); $src_v = isset($src_v['Version']) ? trim((string) $src_v['Version']) : ''; $dst_v = isset($dst_v['Version']) ? trim((string) $dst_v['Version']) : ''; if ('' !== $src_v && '' !== $dst_v && version_compare($dst_v, $src_v, '>')) { $refused[$name] = $dst_v; $needs_copy = false; } } if ($needs_copy) { if (!@copy($src, $dst)) { $failed[] = $name; continue; } if ($is_new) { $installed[] = $name; } } // 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 ); } // TELL THE OPERATOR WE PUT FILES IN mu-plugins/. // // A must-use plugin cannot be seen on the Plugins screen and cannot be // deactivated from wp-admin, so installing one silently means the site now // runs code its administrator has no way to find. That is defensible for // our own fleet and not defensible on somebody else's hosting, where these // files change Action Scheduler's behaviour for every plugin on the site. // // First install only โ€” recorded in an option, not re-announced on every // upgrade re-sync. A notice that reappears forever is one that gets // dismissed without reading. if (!empty($installed) && !get_option('aieo_mu_install_announced')) { update_option('aieo_mu_install_announced', 1, false); set_transient('aieo_mu_install_notice', array_values($installed), WEEK_IN_SECONDS); } // A refused downgrade is worth saying out loud: it means the site is // running something newer than this release ships, which is either a // deliberate hotfix (fine, and we just protected it) or a rollback nobody // meant to do (needs looking at). Either way, silence would be wrong. if (!empty($refused)) { set_transient('aieo_mu_downgrade_refused', $refused, WEEK_IN_SECONDS); } } // ----------------------------------------------------------------------------- // MU-plugin disclosure notices // ----------------------------------------------------------------------------- // Two bounded, dismissible notices, both about files the operator cannot see on // the Plugins screen. Deliberately NOT part of the general notice surface: they // are shown once, they name the exact paths, and they state the removal path, // because "how do I get rid of this" must be answerable without a support // ticket. See docs/PARTNER-CONDUCT.md โ€” we install nothing we will not name. add_action('admin_notices', 'aieo_render_mu_plugin_notices'); function aieo_render_mu_plugin_notices() { if (!current_user_can('activate_plugins')) { return; } $dir = defined('WPMU_PLUGIN_DIR') ? WPMU_PLUGIN_DIR : WP_CONTENT_DIR . '/mu-plugins'; $installed = get_transient('aieo_mu_install_notice'); if (is_array($installed) && !empty($installed)) { echo '

' . esc_html__('Rooster One installed some must-use plugins', 'ai-eshop-optimizer') . '

' . esc_html__('These files keep Action Scheduler (the background-job queue WooCommerce and many other plugins share) from running away on small servers, and they load before everything else. Must-use plugins do not appear on your Plugins screen, so they are listed here:', 'ai-eshop-optimizer') . '

' . esc_html($dir) . '

' . esc_html__('To remove them, delete those files. Nothing in Rooster One stops working without them; Action Scheduler simply returns to its own defaults. They are put back if you deactivate and reactivate Rooster One.', 'ai-eshop-optimizer') . '

'; delete_transient('aieo_mu_install_notice'); } $refused = get_transient('aieo_mu_downgrade_refused'); if (is_array($refused) && !empty($refused)) { echo '

' . esc_html__('Rooster One left a newer must-use plugin alone', 'ai-eshop-optimizer') . '

' . esc_html__('This site has newer versions of these files than this release of Rooster One carries, so they were not overwritten. If somebody applied a fix here on purpose, that fix is safe. If you did not expect this, the plugin may have been rolled back to an older version:', 'ai-eshop-optimizer') . '

'; delete_transient('aieo_mu_downgrade_refused'); } } // ----------------------------------------------------------------------------- // MU-plugin self-heal on upgrade // ----------------------------------------------------------------------------- // The installer above is an ACTIVATION hook, and a plugin update does not // re-activate. So for years a bundled mu-plugin fix only reached a site if // someone happened to deactivate/reactivate there โ€” which is exactly how the // fleet ended up running two different versions of the AS core file, 15 sites // of them carrying a duplicate-minting bug that had been fixed months earlier. // // Re-sync whenever the plugin version changes. The gate is a single autoloaded // option compare (free โ€” it rides the autoload bundle), so the normal request // pays nothing; the filesystem work happens once per upgrade, and only in a // context that can act on a failure notice. add_action('plugins_loaded', 'aieo_maybe_sync_bundled_mu_plugins', 1); function aieo_maybe_sync_bundled_mu_plugins() { if (get_option('aieo_mu_plugins_synced_version') === AIEO_VERSION) { return; } // Never do filesystem work on a storefront request. if (!is_admin() && !(defined('WP_CLI') && WP_CLI) && !wp_doing_cron()) { return; } aieo_install_bundled_mu_plugins(); update_option('aieo_mu_plugins_synced_version', AIEO_VERSION, true); } // Surface the install error (if any) as an admin notice on the next page load. add_action('admin_notices', function () { $msg = get_transient('aieo_mu_install_error'); if (!$msg || !current_user_can('manage_options')) { return; } printf( '

AIEO: %s

', esc_html($msg) ); }); // Inside your plugin's main file or in a file that gets loaded during plugin activation // Register a hook to execute your table creation function during plugin activation register_activation_hook(__FILE__, 'aieo_create_temp_tables_activation_hook'); // Define the activation hook function function aieo_create_temp_tables_activation_hook() { global $wpdb; // The table creators live in the lazily-loaded SQL layer. aieo_load_sql_layer(); // Enable error reporting for debugging $wpdb->show_errors(); aieo_debug_log('AIEO: Starting table creation activation hook'); // Load WordPress database manipulation functions require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); // CRITICAL FIX: Set options for backward compatibility with stored procedures // that still read these options aieo_set_default_options(); aieo_debug_log('AIEO: Options set'); // CRITICAL FIX: Call table creation functions WITHOUT parameters // Table names are now constructed internally like chat tables (using $wpdb->prefix directly) // This eliminates dependency on options and prevents race conditions aieo_debug_log('AIEO: Creating temp_orders table'); aieo_create_temp_orders_table(); aieo_debug_log('AIEO: Creating temp_store_orders table'); aieo_create_temp_store_orders_table(); aieo_debug_log('AIEO: Creating temp_products table'); aieo_create_temp_products_table(); aieo_debug_log('AIEO: Creating core_product_attributes table'); aieo_create_core_product_attributes_table(); aieo_debug_log('AIEO: Creating core_product_category_hierarchy table'); aieo_create_core_product_category_hierarchy_table(); aieo_debug_log('AIEO: Creating core_category_brand_popularity table'); aieo_create_core_category_brand_popularity_table(); aieo_debug_log('AIEO: Creating core_product_vitals table'); aieo_create_core_product_vitals_table(); aieo_debug_log('AIEO: Creating core_variation_vitals table'); aieo_create_core_variation_vitals_table(); aieo_debug_log('AIEO: Creating core_reciprocal_products table'); aieo_create_core_reciprocal_products_table(); aieo_debug_log('AIEO: Creating core_brand_vitals table'); aieo_create_core_brand_vitals_table(); // Create AI Chat tables aieo_debug_log('AIEO: Creating chat_embeddings table'); aieo_create_chat_embeddings_table(); aieo_debug_log('AIEO: Creating chat_history table'); aieo_create_chat_history_table(); // Create Session Tracker tables aieo_debug_log('AIEO: Creating sessions table'); aieo_create_sessions_table(); aieo_debug_log('AIEO: Creating session archive tables'); aieo_create_session_archive_tables(); // Session-analytics pipeline tables (journeys + product metrics). aieo_debug_log('AIEO: Creating session_journeys table'); aieo_create_session_journeys_table(); // Widen an existing table to the current definition (ENUM + object_type). // CREATE TABLE IF NOT EXISTS is a no-op against one that already exists. aieo_migrate_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, then CHECK, then be resumable โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // This used to be 25 bare calls in a row. Three things were wrong with // that, and all three are invisible on a fast server: // // 1. No verification. A `CREATE PROCEDURE` refused because the database // user lacks CREATE ROUTINE returns without anything noticing. The // plugin activates, and the only symptom is that reports reading // those routines are empty forever. That is one of the causes of our // single most common support shape, and no amount of looking at the // report reveals it. // 2. No resumption. If activation ran out of time at call 14, calls 15 // to 25 simply never happened and nothing retried. On a 1 GB droplet // that is a likely outcome, not an exotic one. // 3. No record. Nothing anywhere knew what was supposed to exist, so // "did this install correctly" was unanswerable after the fact. // // AIEO_Procedure_Installer holds the same 25 installers in the same order // and adds those three things. On a healthy host the behaviour is // unchanged: everything is created in one pass, well inside the budget. // The catalogue is the single list now, so a routine added in future is // added once rather than in a create block, a drop block and a mental // model of what activation does. require_once plugin_dir_path(__FILE__) . '/includes/class-aieo-procedure-installer.php'; AIEO_Procedure_Installer::install(AIEO_Procedure_Installer::ACTIVATION_BUDGET_SECS); } 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. BOTH real callers now pass false: the // version-bump upgrade path (aieo_check_for_plugin_update) and the // deactivation wrapper (aieo_deactivate). Neither a release nor a // deactivate may wipe the materialized working tables or live session // data โ€” see the aieo_deactivate() docblock for why that matters. // // The teardown below is therefore reached by nothing today. It is kept, // not deleted, because a future uninstall.php is the one context where a // full teardown IS correct โ€” that is the only thing that should ever pass // true here. // 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 โ€” which is // every caller that exists today. Procedures/functions above are // refreshed; every data table, including the retention-gated ones below, // is 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); } /** * Deactivation entry point. * * WHY THIS WRAPPER EXISTS. WordPress fires deactivation callbacks as * `do_action( "deactivate_{$plugin}", $network_deactivating )`. Hooking * aieo_drop_stored_procedures() DIRECTLY therefore handed its $drop_tables * parameter whatever WordPress passed, not the function's own default โ€” false * on an ordinary single-site deactivate, which is the only reason deactivating * has not been destroying data, but TRUE on a multisite network-deactivate. * Down that path it dropped all twelve working tables (core_*_vitals, * core_product_attributes, the temp_* set, ai_models) plus, per their * retention options, live sessions and the session archive. * * That is the failure this project already hit once: the derived tables ARE * the storefront's read substrate, and rebuilding them is a 15-25 minute * data-prep run, so dropping them takes the shop down until the next pipeline * pass. It was fixed for the version-bump path by passing false explicitly * (see aieo_check_for_plugin_update); this closes the remaining door and, more * importantly, makes the protection INTENTIONAL rather than a side effect of * which argument WordPress happens to pass. * * Deactivation refreshes stored procedures and functions only โ€” those are * recreated on activation and cost nothing โ€” and drops no table at all. */ function aieo_deactivate() { aieo_drop_stored_procedures(false); } register_deactivation_hook(__FILE__, 'aieo_deactivate'); 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); /** * Delete files a release REMOVED, on installs that still have them on disk. * * WHY THIS IS NEEDED AT ALL. WordPress's own updater deletes the plugin folder * and unpacks the new one, so a removed file genuinely disappears โ€” and the * staged deploy this project uses (extract to a staging dir, then swap) behaves * the same way. But a plugin updated by extracting a tarball OVER the existing * directory, or by rsync without --delete, keeps every file the new version * dropped. Those installs are the ones this exists for. * * WHY A LEFTOVER FILE MATTERS. A stale file is inert only for as long as nothing * loads it. includes/dmm/marketplace/class-moi-order-mapper.php declared * `class AIEO_MOI_Order_Mapper` โ€” the SAME class name as the live mapper it was * superseded by, and a snapshot four months out of date. Nothing required it, so * it sat there harmlessly. The moment anyone adds a directory autoloader or a * glob require under includes/, it either fatals on redeclare or silently * shadows the real class depending on which loads first. That is a landmine * whose trigger is a future, unrelated, entirely reasonable change. * * Paths are relative to the plugin root, checked against realpath so a path * cannot escape it. Missing files are the normal case and are skipped silently. * * WHEN IT RUNS. Both on a version bump (aieo_check_for_plugin_update) and on * activation. Activation matters on its own: an install already carrying this * version โ€” one deployed by rsync without --delete, or one where the operator * simply toggles the plugin โ€” never trips the version gate, so the version-bump * call alone would leave the stale files there forever. The function is * idempotent and costs a dozen realpath() calls, so running it twice is free. * * @return int Files actually deleted. */ /** * Calendar-period bounds, expressed in UTC, derived from THIS SITE's WordPress * timezone. * * WHY THIS EXISTS. Storage is GMT everywhere (see the timestamp convention), but * a calendar boundary is not a storage question โ€” "this month" on a shop's report * means the month as the shop experiences it. Those are different things and * conflating them is what SQL NOW()/CURDATE() did: they follow the SERVER clock. * * That breaks the moment one server hosts more than one site. A London box * running a UK shop and an Athens shop cannot serve both from its own clock โ€” * and UTC is not the answer either, because London is BST (UTC+1) for half the * year and Athens is EEST (UTC+3). The only correct source is each site's own * WordPress timezone, which is what wp_timezone() returns. * * DST is handled by construction: the boundary is built as a local wall-clock * time inside the site's timezone and THEN converted, so the offset applied is * the one actually in force on that date, not a constant. * * @param string $period 'current' (this month) or 'last' (the previous month). * @return array{start:string,end:string} 'Y-m-d H:i:s' in UTC. `end` is * exclusive โ€” compare with `<`. */ function aieo_site_month_bounds_utc($period = 'current') { $tz = function_exists('wp_timezone') ? wp_timezone() : new DateTimeZone('UTC'); $utc = new DateTimeZone('UTC'); // Local wall-clock midnight on the first of this month, in the site's zone. $this_month = new DateTimeImmutable('now', $tz); $this_month = $this_month->setDate( (int) $this_month->format('Y'), (int) $this_month->format('n'), 1 )->setTime(0, 0, 0); if ('last' === $period) { $start = $this_month->modify('-1 month'); $end = $this_month; } else { $start = $this_month; $end = $this_month->modify('+1 month'); } return array( 'start' => $start->setTimezone($utc)->format('Y-m-d H:i:s'), 'end' => $end->setTimezone($utc)->format('Y-m-d H:i:s'), ); } function aieo_prune_removed_files() { // Superseded files, with the version that dropped them. Add to this list in // the same commit that deletes a file, or installs updated in place keep it // forever. $removed = array( // 9.5 โ€” the whole includes/dmm/marketplace/ tree is a 13-June snapshot // superseded by includes/dmm/marketplace-order-importer/includes/. // Every class name in it collides with its live counterpart. 'includes/dmm/marketplace/class-moi-order-mapper.php', 'includes/dmm/marketplace/class-moi-inbound-log.php', 'includes/dmm/marketplace/class-moi-order-meta-box.php', 'includes/dmm/marketplace/class-moi-poller.php', 'includes/dmm/marketplace/class-moi-provider-registry.php', 'includes/dmm/marketplace/class-moi-rest-api.php', 'includes/dmm/marketplace/class-moi-schema.php', 'includes/dmm/marketplace/class-moi-vendor-manager.php', 'includes/dmm/marketplace/class-moi-voucher-store.php', 'includes/dmm/marketplace/interface-moi-provider.php', 'includes/dmm/marketplace/providers/shopflix/class-moi-provider-shopflix.php', 'includes/dmm/marketplace/providers/skroutz/class-moi-provider-skroutz.php', // 10.3 โ€” an orphaned 27-July copy of AIEO_DMM_Social_Login. The live // class is includes/dmm/modules/class-aieo-dmm-social-login.php, which // is what AIEO_DMM::load_modules() names in its $files map; nothing // ever required this one. // // IT WAS NOT MERELY DEAD, IT WAS ARMED. wp-content/mu-plugins/aieo-autoload.php // maps every AIEO_DMM_* class to `dmm/modules/` first and `dmm/` as the // FALLBACK, so the two identical declarations were kept apart by search // order alone. Anything that reached the root copy first โ€” a require by // path, a reordering of that fallback list, a class_exists() race โ€” // gets a fatal redeclare, or silently the older class. // // It also polluted the section-anchor harvest: five ids were collected // from markup that is never emitted, which is one of the three shapes // of duplicate Ctrl+K row fixed in 10.3. 'includes/dmm/class-aieo-dmm-social-login.php', // 11.0 โ€” dead weight, proven unreferenced before removal. // // The TOC trio: aieo-page-toc.js bound to [data-aieo-toc-bar], which // only page-toc-bar.php emitted, and that partial had zero callers // since the Analytics page swapped its own TOC for the shared menu bar. // The palette pair: AIEO_Command_Palette::init() returned before // registering any hook, superseded by assets/js/aieo-admin-navigator.js. // The images: two copies of the previous Oxford Metadata logo and a // 285KB screenshot, referenced from nothing but a session transcript. 'assets/pages/_partials/page-toc-bar.php', 'assets/js/aieo-page-toc.js', 'assets/js/aieo-page-toc.min.js', 'assets/js/aieo-command-palette.js', 'assets/js/aieo-command-palette.min.js', 'includes/admin/class-aieo-command-palette.php', 'assets/images/OMLogo.webp', 'assets/images/OMLogo-300.webp', 'assets/images/maps-drill-in.png', // 11.0 โ€” MOVED to examples/, not deleted. AIEO_Session_Correlation_Helper // is reference material for examples/session-correlation-examples.php, // which the operator keeps for testing. It must NOT be reachable at // runtime, and while it sat in includes/ the only thing keeping it out of // the autoloader was a filename that did not match the convention โ€” an // accident, and accidents get tidied up. One was: it got "corrected" to // class-aieo-โ€ฆ during this very release and had to be put back. The // autoloader only ever searches under /includes/, so living in // examples/ makes the guarantee structural rather than incidental. 'includes/class-session-correlation-helper.php', // 11.0 โ€” dead code, each one adversarially verified unreachable before // deletion (nothing requires it, no autoloader path resolves to it, no // glob reaches it, no enqueue names it, no manifest lists it). // // Two were near-misses worth recording, because a filename search alone // would have deleted the wrong file: // * assets/css/admin-panel.css sits one letter from the LIVE // aieo-admin-panel.css, which five call sites enqueue. // * assets/pages/erp/erp-database.php shares a basename with the live // assets/pages/dmm/erp-database.php; render_template() hardcodes the // dmm/ directory, so the erp/ copy can never be reached. // And lazy-recommendations.js was superseded by the -v7 file that // class-eshop-optimizer.php actually enqueues as `aieo-lazy-recommendations`. 'includes/aieo-dependency-manager.php', 'includes/js/ai-eshop-optimizer-googleGA4.js', 'includes/sql/sprocs.php.bak-deferred', 'includes/sql/analytics-jobs-procs.php.bak-deferred', 'assets/pages/erp/erp-database.php', 'assets/css/admin-panel.css', 'assets/css/admin-panel.min.css', 'assets/css/aieo-command-palette.css', 'assets/css/aieo-command-palette.min.css', 'assets/js/aieo-address-autocomplete-blocks.js', 'assets/js/aieo-address-autocomplete-blocks.min.js', 'assets/js/aieo-swatches-iapi.js', 'assets/js/aieo-swatches-iapi.min.js', 'assets/js/chat-admin.js', 'assets/js/chat-admin.min.js', 'assets/js/lazy-recommendations.js', 'assets/js/lazy-recommendations.min.js', ); // Directories that exist only to hold the files above. Removed after the // files, deepest first, and ONLY when genuinely empty โ€” rmdir() refuses a // non-empty directory, so an install that has something of its own in there // (a local edit, an editor backup) keeps it rather than losing it silently. $removed_dirs = array( 'assets/pages/_partials', 'assets/pages/erp', 'includes/dmm/marketplace/providers/shopflix', 'includes/dmm/marketplace/providers/skroutz', 'includes/dmm/marketplace/providers', 'includes/dmm/marketplace', ); $root = realpath(plugin_dir_path(__FILE__)); $deleted = 0; if ($root === false) { return 0; } foreach ($removed as $rel) { $path = realpath($root . '/' . $rel); // realpath() returns false when the file is already gone โ€” the normal // case on a cleanly-updated install. if ($path === false || strpos($path, $root) !== 0 || !is_file($path)) { continue; } if (@unlink($path)) { $deleted++; if (function_exists('aieo_debug_log')) { aieo_debug_log('AIEO: pruned superseded file ' . $rel); } } } foreach ($removed_dirs as $rel) { $path = realpath($root . '/' . $rel); if ($path === false || strpos($path, $root) !== 0 || !is_dir($path)) { continue; } // @rmdir() fails harmlessly on a non-empty directory; no need to scan. if (@rmdir($path) && function_exists('aieo_debug_log')) { aieo_debug_log('AIEO: pruned empty directory ' . $rel); } } return $deleted; } register_activation_hook(__FILE__, 'aieo_prune_removed_files'); /** * 10.1 โ€” turn the web-push opt-in banner off, once, on every existing install. * * The banner defaulted to ON. Shops that had never opened the push screens * still got a permission prompt interrupting first-time visitors, and had to * work out where it had come from. Asking a stranger for push permission is a * decision a shop makes deliberately; it is not something to inherit from an * update. * * WHY THIS OVERWRITES A STORED `true` AS WELL. The settings form pre-ticked * the box, so a stored `banner_enabled => true` was never evidence that * anybody chose it โ€” saving a background colour saved the banner along with * it. There is no way, after the fact, to tell a deliberate tick from an * inherited one, and the cost of guessing wrong points one way: a shop that * wants the banner re-ticks a checkbox, whereas a shop that does not want it * is interrupting its customers until it notices. * * Runs ONCE, guarded by its own flag rather than by the version number, so a * shop that switches the banner back on is never overruled by a later upgrade. * * @return void */ function aieo_migrate_push_banner_default() { if (get_option('aieo_push_banner_optout_migrated')) { return; } $banner = get_option('aieo_push_banner_settings', array()); if (!is_array($banner)) { $banner = array(); } if (!empty($banner['banner_enabled']) || !isset($banner['banner_enabled'])) { $banner['banner_enabled'] = false; // autoload=false to match how the settings form stores it; a differing // flag here would flip the row's autoload on every save. update_option('aieo_push_banner_settings', $banner, false); } add_option('aieo_push_banner_optout_migrated', '1', '', 'no'); } 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'); } // Delete files this version removed, on installs that still carry them. aieo_prune_removed_files(); // 10.1: stop showing the push opt-in banner nobody switched on. aieo_migrate_push_banner_default(); // Theme Compatibility 'archive_cards' surface: unset now means the // THEME's shop/category cards stay untouched (the wishlist heart, // rating stars and NEW badge no longer inject โ€” operator ruling // 2026-08-28, defaults are the theme's own presentation layer). // Every install UPGRADING through here has been showing those // extras, so stamp 'aieo' once to keep what the site visibly had; // only fresh installs get the new polite default. This runs ONLY on // a real version bump โ€” the fresh-install path above returns before // reaching it โ€” and only when nobody has chosen yet. aieo_stamp_archive_cards_back_compat(); // 2FA: record when enforcement started on shops that already have it // on, so the grace period has a shop-wide date to count from instead // of restarting for each account at its own first sign-in. aieo_stamp_2fa_enforced_since(); // 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 back-compat stamp for the Theme Compatibility 'archive_cards' * surface (2026-08-28). Unset means the theme's shop/category cards stay * untouched, but every install upgrading from an earlier version has been * showing the wishlist heart, the rating stars and the NEW badge on them โ€” * so a real upgrade stamps 'aieo' once (only when nobody has chosen yet) * and the site keeps what it visibly had. Fresh installs never call this: * the updater's no-stored-version path returns first, and the option stays * absent, which reads as the polite theme default. * * Autoload 'yes': the option is read on every front-end request by the * wishlist/ratings/brand-discounts archive gates. * * @since 11.1.4 * @return void */ function aieo_stamp_archive_cards_back_compat() { if (false === get_option('aieo_dmm_archive_cards_design_source', false)) { add_option('aieo_dmm_archive_cards_design_source', 'aieo', '', 'yes'); } } /** * One-time stamp of when two-factor enforcement started (2026-09-04). * * The 2FA grace period used to be anchored per account, at the first sign-in * that account happened to make after the setting went on. That is not a * deadline: a shop could switch 2FA on, tell its staff they had a week, and * still have an administrator six weeks later whose first sign-in politely * granted them a fresh week. It is now counted from a shop-wide date held in * `aieo_login_security['2fa']['enabled_at']` โ€” which shops that switched 2FA on * before this release do not have. * * Stamped as NOW rather than reconstructed from the old per-account rows. The * earliest of those rows is a genuine lower bound on when enforcement began, * and using it would be more faithful โ€” but on a shop that switched 2FA on more * than `grace_days` ago it would put every un-enrolled account past its deadline * the instant the plugin updated, handing the interstitial to people who were * told in writing that they had a week from their first sign-in. Stamping now * can only ever LENGTHEN somebody's remaining window, never shorten it, which is * the right way round for a security control that is already in force. * * The per-account rows are then removed: nothing writes that key any more, and a * meta key with no writer and a meaning that has changed under it is exactly the * kind of thing that gets misread two releases later. Deliberately NOT touched * on shops where 2FA is switched off โ€” there is no enforcement date to record. * * @since 11.1.12 * @return void */ function aieo_stamp_2fa_enforced_since() { // Raw option read, and the literal key rather than the class constant: this // runs at plugins_loaded and must not depend on the login-security classes // having been loaded. A class_exists() guard that came back false would skip // the migration permanently, because the version option is bumped either way. $settings = get_option('aieo_login_security', array()); if (!is_array($settings) || empty($settings['2fa']['enabled'])) { return; } if (!empty($settings['2fa']['enabled_at'])) { return; } $settings['2fa']['enabled_at'] = time(); update_option('aieo_login_security', $settings, false); global $wpdb; $ids = $wpdb->get_col( $wpdb->prepare("SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", 'aieo_2fa_first_prompt') ); foreach ((array) $ids as $id) { // delete_user_meta() rather than a single DELETE, so the user-meta cache // is invalidated with it. The row count is bounded by the protected // roles, not by the customer list. delete_user_meta((int) $id, 'aieo_2fa_first_prompt'); } } // Also on admin_init, not only on a version bump. Without a date to count from // the grace period fails open โ€” grace_anchor() returns 0, nobody's window ever // expires, and 2FA quietly stops being enforced on exactly the shops that had // it on. A shop that takes these files without a version bump (an scp deploy, a // re-upload of the same version) would sit in that state indefinitely. The // function is idempotent and returns on its first option read once stamped. add_action('admin_init', 'aieo_stamp_2fa_enforced_since'); /** * 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_v2`. 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). * * BUMPED v1 โ†’ v2 (2026-07-27). Every existing install had already stamped v1 * 'done', but the reco-cache leg of the purge was a no-op the whole time โ€” it * guarded on a table name that has never existed (see * aieo_purge_all_persistent_caches step 3). Leaving the key at v1 would mean * the corrected purge never actually runs anywhere. The bump makes it fire * exactly once per install, which is the whole point of the retroactive * one-shot. Cost is one truncate of a TTL-bearing derived cache that * repopulates lazily on the next product render. */ function aieo_retroactive_post_core_upgrade_purge() { $flag_key = 'aieo_post_core_upgrade_purge_v2'; 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) * - the recommendations cache, via AIEO_Recommendations_Cache::delete_all() * โ€” which clears `wp_aieo_product_recommendations` AND its derived * `wp_aieo_reco_reverse_refs` map together * * @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. The table stores // serialised reco DTOs that could include WC_Product references; same // unserialise-landmine risk as the transients above. // // THIS NEVER RAN UNTIL NOW. It used to build the table name inline as // `{$prefix}aieo_recommendations_cache` and guard on SHOW TABLES. No such // table has ever existed โ€” the cache table is `aieo_product_recommendations` // (AIEO_Recommendations_Cache::$table_name) โ€” so the guard never matched and // the purge silently did nothing on every install, for as long as this code // has shipped. // // Delegating to delete_all() rather than correcting the string, because a // bare TRUNCATE of the cache would be INCOHERENT: wp_aieo_reco_reverse_refs // is derived entirely from the cache table, so emptying one without the // other leaves a reverse map pointing at rows that no longer exist. // delete_all() truncates both, and also flushes the object-cache group and // no-ops safely when the table is absent. if (class_exists('AIEO_Recommendations_Cache') && method_exists('AIEO_Recommendations_Cache', 'delete_all')) { try { AIEO_Recommendations_Cache::delete_all(); } catch (Throwable $e) { if (function_exists('aieo_debug_log')) { aieo_debug_log('AIEO: reco cache purge failed (' . $reason . '): ' . $e->getMessage()); } } } // 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 FILES ARE NOW DUAL-BUNDLED, and the note that used to sit here โ€” * "the autoloader is file-based and resolves it regardless of which plugin is * active, so no class file needs to be duplicated into admin" โ€” was WRONG. * * mu-plugins/aieo-autoload.php only adds the runtime's includes/ to its search * path when the runtime is ACTIVE, and deliberately so: a deactivated runtime * keeps its files on disk, and loading its classes would shadow core's copies * with code written for a bootstrap that never ran. The consequence was that * core registered `aieo_table_rate` pointing at a class nothing could load. * WooCommerce then dropped the method from every zone, the storefront offered * NO shipping options at all, and COD vanished with them because it is gated on * having a usable rate. Silent: no error, no warning, just an empty * "ฮ•ฯ€ฮนฮปฮฟฮณฮญฯ‚ ฮ‘ฯ€ฮฟฯƒฯ„ฮฟฮปฮฎฯ‚" and a checkout nobody can complete. * * Measured on roosterp 2026-08-04 with the runtime deactivated: * aieo_table_rate registered: YES -> AIEO_Shipping_Table_Rate * class resolvable : NO * * So the five TRS classes are byte-identical copies in BOTH plugins, the same * contract the pre-commit parity hook already enforces for the rest of the * shared set. When the runtime is active its copies win โ€” the autoloader puts * its includes/ first, which keeps the lighter runtime bootstrap on the hot * path exactly as before. Core's copies only ever load when the runtime is not * there to serve them. * * 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; }); // The Store API side, which the runtime registers at its own bootstrap. // Without it the block checkout gets the method but not the rate data it // needs, so registering the filter alone would fix the classic cart and // leave the block checkout โ€” the one this store actually uses โ€” still // empty. Guarded the same way as every other optional runtime delegate. if (class_exists('AIEO_Runtime_TRS_Store_API') || file_exists(AIEO_DIR . 'includes/class-aieo-runtime-trs-store-api.php')) { AIEO_Runtime_TRS_Store_API::register(); } }); /** * 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); /** * reCAPTCHA v3 gating for the login / registration / lost-password surfaces. * * Deliberately NOT gated on AIEO_RUNTIME_OWNS_SSO_DISPLAY the way the SSO * button block above is: this is an *enforcement* concern, not a display * one. It has to run even on a site where the social buttons are switched * off entirely, and on wp-login.php where no button ever renders. * * OWNERSHIP โ€” two different questions, two different answers: * * Where the code LIVES: core. This is the canonical implementation; the * settings schema and the admin card live here too, * and the runtime's copy is generated from this file. * Which copy RUNS: the runtime's, whenever the runtime is present. * The runtime plugin exists so a visitor's page load * is served without dragging in the admin plugin's * surface area, so it rules core on the storefront โ€” * the same AIEO_RUNTIME_OWNS_* pattern used by the * SSO buttons, ratings and table-rate shipping. * * Core's copy is therefore the fallback: it runs on a standalone install with * no runtime plugin. Both latch on one global, so the handoff below can never * double-bind the hooks. */ add_action('plugins_loaded', function () { // Runtime present โ†’ it rules. Hand the whole feature over and stop. if (class_exists('AIEO_Runtime_SSO_Captcha')) { AIEO_Runtime_SSO_Captcha::register(); return; } $base = AIEO_DIR . 'includes/social-login/'; // The captcha reads its config from the shared settings blob, which is // dual-bundled; require core's copy only if nothing has loaded one yet. 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'; } if (file_exists($base . 'class-aieo-sso-captcha.php')) { require_once $base . 'class-aieo-sso-captcha.php'; } if (class_exists('AIEO_SSO_Captcha')) { AIEO_SSO_Captcha::register(); } }, 11); /** * Per-form captcha gate โ€” on its OWN hook, deliberately. * * It cannot live inside the closure above: that one RETURNS EARLY whenever the * runtime plugin is active (it hands off to AIEO_Runtime_SSO_Captcha and bails), * and the runtime's matching closure is itself guarded by the flag that hand-off * sets. A registration placed in either body is therefore unreachable on exactly * the installs that matter โ€” silently, with the gate reporting itself enabled. * * Autoloaded, idempotent, and a no-op unless a provider is listed in * AIEO_Form_Registry::ENFORCED_PROVIDERS, so a site running none of the * supported form plugins pays one class_exists. */ add_action('plugins_loaded', static function () { if (class_exists('AIEO_Form_Captcha')) { AIEO_Form_Captcha::register(); } }, 12); /** * Withdrawal right (ฮดฮนฮบฮฑฮฏฯ‰ฮผฮฑ ฯ…ฯ€ฮฑฮฝฮฑฯ‡ฯŽฯฮทฯƒฮทฯ‚) โ€” Law 5317/2026. * * Ships DISABLED; the operator turns it on once the courtesy window and the * customer-facing texts are configured. Every surface re-checks * AIEO_Withdrawal::is_enabled(), so loading the classes costs nothing while * it is off. */ add_action('plugins_loaded', function () { $base = AIEO_DIR . 'includes/withdrawal/'; foreach (array( 'class-aieo-withdrawal.php', 'class-aieo-withdrawal-account.php', 'class-aieo-withdrawal-guest.php', 'class-aieo-withdrawal-emails.php', // The grounds on which a declaration may be refused. THIS LIST IS THE // ONLY WAY anything in includes/withdrawal/ loads: the autoloader maps // AIEO_Withdrawal_* to the `else` branch, which searches includes/ and // never includes/withdrawal/. A class added to that folder without a // line here does not fail loudly โ€” it simply never exists, and every // class_exists() guard around it goes quiet. 'class-aieo-withdrawal-rejection-reasons.php', // Core only โ€” issuing a return voucher needs the courier drivers, // which the runtime plugin does not carry. The customer's download // button checks for this class and simply does not render without // it, which is correct: a runtime-only site could never have issued // a voucher to download. 'class-aieo-withdrawal-returns.php', ) as $f) { if (file_exists($base . $f)) { require_once $base . $f; } } if (class_exists('AIEO_Withdrawal_Account')) { AIEO_Withdrawal_Account::register(); } if (class_exists('AIEO_Withdrawal_Guest')) { AIEO_Withdrawal_Guest::register(); } if (class_exists('AIEO_Withdrawal_Emails')) { AIEO_Withdrawal_Emails::register(); } }, 11); /** * Login-system adapters โ€” make the SSO buttons and the captcha work on themes * that ship their own login/registration UI (tagDiv's Newspaper, etc.). * * Those themes fire none of the four canonical form hooks and, in tagDiv's * case, create accounts via wp_create_user() which never triggers * registration_errors โ€” so without this the buttons are invisible AND the * registration surface is ungated, on every such site. * * Same runtime-rules-core handoff as the captcha above. */ add_action('plugins_loaded', function () { if (class_exists('AIEO_Runtime_Login_Adapters')) { AIEO_Runtime_Login_Adapters::register(); return; } $base = AIEO_DIR . 'includes/social-login/'; if (!class_exists('AIEO_SSO_Buttons') && file_exists($base . 'class-aieo-sso-buttons.php')) { require_once $base . 'class-aieo-sso-buttons.php'; } if (file_exists($base . 'class-aieo-login-adapters.php')) { require_once $base . 'class-aieo-login-adapters.php'; } if (class_exists('AIEO_Login_Adapters')) { AIEO_Login_Adapters::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; });