license = $license; $this->register_default_fixes(); /** * Fires when the fixer module boots, after the default fixes are * registered. Add-ons may register additional fixes here or via the * `arwc_fixes` filter. * * @param ARWC_Fixer $fixer The fixer instance. */ do_action( 'arwc_fixer_init', $this ); } /** * Register the built-in fixes and expose the `arwc_fixes` filter. * * @return void */ protected function register_default_fixes() { // The OpenRouter client is always constructed (its constructor makes no // calls). Whether AI can actually run is decided by ai_locked_for(): AI is // available when a backend is configured — the AgenticTrack proxy (no // per-site key needed) or a merchant-supplied OpenRouter key. $this->ai = new ARWC_OpenRouter(); $defaults = array( new ARWC_Fix_Schema(), new ARWC_Fix_Content(), new ARWC_Fix_Attributes(), new ARWC_Fix_Identifiers(), new ARWC_Fix_Description_AI( $this->ai ), new ARWC_Fix_Attributes_AI( $this->ai ), ); /** * Filter the list of registered fixes. * * @param ARWC_Fix[] $defaults Default fix instances. * @param ARWC_Fixer $fixer The fixer instance. */ $fixes = apply_filters( 'arwc_fixes', $defaults, $this ); foreach ( $fixes as $fix ) { if ( $fix instanceof ARWC_Fix ) { $this->fixes[ $fix->get_id() ] = $fix; } } } /** * All registered fixes. * * @return ARWC_Fix[] */ public function get_fixes() { return $this->fixes; } /** * Get a single fix by id. * * @param string $id Fix id. * @return ARWC_Fix|null */ public function get_fix( $id ) { return isset( $this->fixes[ $id ] ) ? $this->fixes[ $id ] : null; } /** * The OpenRouter client. * * @return ARWC_OpenRouter|null */ public function ai() { return $this->ai; } /** * Daily allowance of AI fixes for FREE (non-Pro) users. Pro is unlimited. * Filterable; 0 makes AI strictly Pro-only. * * @return int */ public function free_ai_cap() { /** * Filter the number of free AI fixes a non-Pro store gets per day. * * @param int $cap Daily free AI-fix allowance (default 3). 0 = Pro-only. */ return max( 0, (int) apply_filters( 'arwc_free_ai_daily_cap', 3 ) ); } /** * How many free AI fixes the (non-Pro) store has used today. * * @return int */ public function free_ai_used() { $usage = get_option( self::FREE_AI_OPTION ); if ( ! is_array( $usage ) || ! isset( $usage['date'], $usage['count'] ) || (string) $usage['date'] !== gmdate( 'Ymd' ) ) { return 0; } return (int) $usage['count']; } /** * Remaining free AI fixes for today. * * @return int */ public function free_ai_remaining() { return max( 0, $this->free_ai_cap() - $this->free_ai_used() ); } /** * Record one free AI fix used today (rolls over at UTC midnight). * * @return void */ protected function bump_free_ai() { $today = gmdate( 'Ymd' ); $usage = get_option( self::FREE_AI_OPTION ); if ( ! is_array( $usage ) || ! isset( $usage['date'] ) || (string) $usage['date'] !== $today ) { $usage = array( 'date' => $today, 'count' => 0, ); } $usage['count'] = (int) $usage['count'] + 1; update_option( self::FREE_AI_OPTION, $usage, false ); } /** * Whether an AI (Pro) fix is currently locked / cannot run. * * Freemium model: Pro is unlimited; free (non-Pro) stores get a small daily * taste (see free_ai_cap()). A fix is locked when there is no AI backend at all, * when a site forces Pro-only via `arwc_ai_requires_pro`, or when a free store * has used its daily allowance. Pro users get AI with no key of their own — the * AgenticTrack proxy supplies it (and independently enforces its own limits). * * @param ARWC_Fix $fix Fix to evaluate. * @return bool */ public function ai_locked_for( ARWC_Fix $fix ) { if ( ! $fix->is_pro() ) { return false; } // No backend at all (no proxy + no key) → nothing can run. if ( ! ( $this->ai instanceof ARWC_OpenRouter && $this->ai->is_configured() ) ) { return true; } // Pro → unlimited. if ( $this->license->is_pro() ) { return false; } /** * Filter to force AI strictly Pro-only. Default false — free stores now get * a small daily allowance (free_ai_cap). Set true to lock AI to Pro entirely. * * @param bool $requires_pro Whether AI requires the paid Pro license. */ if ( apply_filters( 'arwc_ai_requires_pro', false ) ) { return true; } // Free tier: unlocked while today's allowance remains. return $this->free_ai_remaining() <= 0; } /** * A ready-to-show note for a locked AI fix, tailored to WHY it's locked. * * @return string */ public function ai_lock_note() { if ( $this->license->is_pro() ) { return __( 'AI is temporarily unavailable — please try again shortly.', 'agentictrack-agent-readiness-for-woocommerce' ); } if ( apply_filters( 'arwc_ai_requires_pro', false ) ) { return __( 'AI fixes are a Pro feature. Upgrade to unlock.', 'agentictrack-agent-readiness-for-woocommerce' ); } $cap = $this->free_ai_cap(); if ( $cap > 0 && $this->free_ai_remaining() <= 0 ) { return sprintf( /* translators: %d: daily free AI-fix allowance. */ _n( 'You’ve used your %d free AI fix for today. Upgrade to Pro for unlimited, or come back tomorrow.', 'You’ve used your %d free AI fixes for today. Upgrade to Pro for unlimited, or come back tomorrow.', $cap, 'agentictrack-agent-readiness-for-woocommerce' ), $cap ); } return __( 'AI fixes are unavailable right now.', 'agentictrack-agent-readiness-for-woocommerce' ); } /** * Whether the current user/tier may perform fixes. * * Capability gate only. Pro-only fixes are filtered out separately by tier, * so free users may still apply free fixes. Requests reaching apply()/undo() * additionally pass nonce verification at the request layer. * * @return bool */ public function can_fix() { return current_user_can( 'manage_woocommerce' ); } /** * Build the preview payload for one product across all applicable fixes. * * Pro fixes are included as locked placeholders when Pro is inactive (so the * UI can show an upsell), but carry no proposals and cannot be applied. * * @param int $product_id Product ID. * @return array|WP_Error */ public function preview_product( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return new WP_Error( 'arwc_no_product', __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $snapshots = $this->get_snapshots( $product ); $groups = array(); foreach ( $this->fixes as $id => $fix ) { $locked = $this->ai_locked_for( $fix ); $proposals = $locked ? array() : $fix->preview( $product ); $has_undo = isset( $snapshots[ $id ] ); // Skip free fixes that have nothing to do — UNLESS this fix was already // applied (a snapshot exists), in which case keep the group so its Undo // control stays reachable even though there is nothing left to propose. // Locked Pro fixes are always kept so the merchant sees what Pro unlocks. if ( empty( $proposals ) && ! $locked && ! $has_undo ) { continue; } $group = array( 'id' => $id, 'label' => $fix->get_label(), 'description' => $fix->get_description(), 'is_pro' => $fix->is_pro(), 'locked' => $locked, 'proposals' => $proposals, 'can_undo' => $has_undo, ); if ( $locked && $fix->is_pro() ) { $group['lock_note'] = $this->ai_lock_note(); } $groups[] = $group; } return array( 'product_id' => $product->get_id(), 'name' => $product->get_name(), 'groups' => $groups, 'upgrade' => $this->license->upgrade_url(), 'is_pro' => $this->license->is_pro(), 'free_ai_cap' => $this->free_ai_cap(), 'free_ai_remaining' => $this->free_ai_remaining(), 'ai_env_note' => ( $this->ai instanceof ARWC_OpenRouter ) ? $this->ai->env_note() : '', ); } /** * Generate an AI proposal for review, WITHOUT writing anything. * * Only fixes that implement generate() (the AI ones) support this. It runs the * model and returns the proposed value for the merchant to review/edit before a * later apply persists it — keeping the "approve before saved" promise while * letting the merchant see the proposal first. * * @param string $fix_id Fix id. * @param int $product_id Product ID. * @return array|WP_Error Proposal payload, or WP_Error. */ public function generate_fix( $fix_id, $product_id ) { $fix = $this->get_fix( $fix_id ); if ( ! $fix instanceof ARWC_Fix ) { return new WP_Error( 'arwc_no_fix', __( 'Unknown fix.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } if ( $this->ai_locked_for( $fix ) ) { return new WP_Error( 'arwc_ai_locked', __( 'This AI fix is not available right now.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } if ( ! method_exists( $fix, 'generate' ) ) { return new WP_Error( 'arwc_no_generate', __( 'This fix has no preview to generate.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return new WP_Error( 'arwc_no_product', __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $result = $fix->generate( $product ); // Meter free-tier usage: a successful generate is one AI fix used. Pro is // unlimited and never metered. if ( ! is_wp_error( $result ) && $fix->is_pro() && ! $this->license->is_pro() ) { $this->bump_free_ai(); if ( is_array( $result ) ) { $result['free_ai_remaining'] = $this->free_ai_remaining(); $result['free_ai_cap'] = $this->free_ai_cap(); } } return $result; } /** * Apply a single fix to a product, snapshotting first for undo. * * @param string $fix_id Fix id. * @param int $product_id Product ID. * @param array $input Sanitized merchant input keyed by field. * @return array|WP_Error On success: [ 'changes' => [...], 'can_undo' => bool ]. */ public function apply_fix( $fix_id, $product_id, array $input ) { $fix = $this->get_fix( $fix_id ); if ( ! $fix instanceof ARWC_Fix ) { return new WP_Error( 'arwc_no_fix', __( 'Unknown fix.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Hard gate: AI fixes only run when an AI backend is available (proxy or a // merchant key), or — if arwc_ai_requires_pro is on — when Pro is active. if ( $this->ai_locked_for( $fix ) ) { return new WP_Error( 'arwc_ai_locked', __( 'This AI fix is not available right now.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return new WP_Error( 'arwc_no_product', __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Snapshot the fields this fix may touch BEFORE writing anything. $snapshot = $this->capture_snapshot( $fix_id, $product ); $result = $fix->apply( $product, $input ); if ( is_wp_error( $result ) ) { return $result; } if ( ! $result instanceof ARWC_Fix_Result ) { return new WP_Error( 'arwc_fix_failed', __( 'The fix did not return a valid result.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } // Only persist a snapshot if something actually changed. if ( $result->is_changed() ) { $this->store_snapshot( $product_id, $fix_id, $snapshot ); } return array( 'changes' => $result->get_changes(), 'changed' => $result->is_changed(), 'can_undo' => $result->is_changed(), ); } /** * Apply every SAFE fix to one product in a single pass: free (never Pro/AI), * unlocked, automatic, and requiring no merchant input (schema short-description * + attribute normalisation). This is the per-product unit the store-wide * "fix all products" runner calls once per product. * * Mirrors the JS bulk-eligibility rule exactly (auto && !needs_input && * !generate) so the two bulk paths stay consistent. Every change still goes * through apply_fix(), so each is individually snapshotted and undoable. * * @param int $product_id Product ID. * @return array{applied:int}|WP_Error Count of fixes that changed the product. */ public function apply_safe_fixes( $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return new WP_Error( 'arwc_no_product', __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $applied = 0; foreach ( $this->fixes as $id => $fix ) { // Only free, unlocked fixes are ever bulk-applied. if ( $fix->is_pro() || $this->ai_locked_for( $fix ) ) { continue; } $proposals = $fix->preview( $product ); if ( empty( $proposals ) || ! is_array( $proposals ) ) { continue; } $eligible = false; foreach ( $proposals as $proposal ) { if ( ! empty( $proposal['auto'] ) && empty( $proposal['needs_input'] ) && empty( $proposal['generate'] ) ) { $eligible = true; break; } } if ( ! $eligible ) { continue; } // apply_fix() re-fetches the product, so sequential applies each see the // previous save. No merchant input for these auto fixes. $result = $this->apply_fix( $id, $product_id, array() ); if ( ! is_wp_error( $result ) && ! empty( $result['changed'] ) ) { $applied++; } } return array( 'applied' => $applied ); } /** * Undo a previously applied fix by restoring its snapshot. * * @param string $fix_id Fix id. * @param int $product_id Product ID. * @return array|WP_Error On success: [ 'restored' => true ]. */ public function undo_fix( $fix_id, $product_id ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return new WP_Error( 'arwc_no_product', __( 'Product not found.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $snapshots = $this->get_snapshots( $product ); if ( ! isset( $snapshots[ $fix_id ] ) ) { return new WP_Error( 'arwc_no_snapshot', __( 'Nothing to undo for this fix.', 'agentictrack-agent-readiness-for-woocommerce' ) ); } $this->restore_snapshot( $product, $snapshots[ $fix_id ] ); // Drop the consumed snapshot. unset( $snapshots[ $fix_id ] ); if ( empty( $snapshots ) ) { $product->delete_meta_data( self::META_SNAPSHOT ); } else { $product->update_meta_data( self::META_SNAPSHOT, $snapshots ); } $product->save(); return array( 'restored' => true ); } /** * Capture the current value of every field a fix could write, so it can be * restored verbatim on undo. The snapshot is a flat field => value map; the * field ids match what restore_snapshot() understands. * * @param string $fix_id Fix id. * @param WC_Product $product Product. * @return array */ protected function capture_snapshot( $fix_id, WC_Product $product ) { switch ( $fix_id ) { case 'identifiers': return array( 'sku' => (string) $product->get_sku(), 'gtin' => is_callable( array( $product, 'get_global_unique_id' ) ) ? (string) $product->get_global_unique_id() : null, 'gtin_meta' => (string) $product->get_meta( ARWC_Fix_Identifiers::GTIN_META_FALLBACK, true ), ); case 'schema': // Schema can now also set price and assign a category, so snapshot // those too for a clean, complete undo. return array( 'short_description' => (string) $product->get_short_description(), 'description' => (string) $product->get_description(), 'regular_price' => (string) $product->get_regular_price(), 'category_ids' => array_map( 'intval', (array) $product->get_category_ids() ), ); case 'content': case 'description_ai': return array( 'short_description' => (string) $product->get_short_description(), 'description' => (string) $product->get_description(), ); case 'attributes': case 'attributes_ai': return array( 'attributes' => $this->serialize_attributes( $product ), ); default: return array(); } } /** * Restore a snapshot's fields onto a product via CRUD. * * @param WC_Product $product Product. * @param array $snapshot Snapshot data. * @return void */ protected function restore_snapshot( WC_Product $product, array $snapshot ) { if ( array_key_exists( 'sku', $snapshot ) ) { try { $product->set_sku( (string) $snapshot['sku'] ); } catch ( Exception $e ) { // If the original SKU somehow collides now, leave current value. unset( $e ); } } if ( array_key_exists( 'gtin', $snapshot ) && null !== $snapshot['gtin'] && is_callable( array( $product, 'set_global_unique_id' ) ) ) { $product->set_global_unique_id( (string) $snapshot['gtin'] ); } if ( array_key_exists( 'gtin_meta', $snapshot ) ) { $product->update_meta_data( ARWC_Fix_Identifiers::GTIN_META_FALLBACK, (string) $snapshot['gtin_meta'] ); } if ( array_key_exists( 'short_description', $snapshot ) ) { $product->set_short_description( (string) $snapshot['short_description'] ); } if ( array_key_exists( 'description', $snapshot ) ) { $product->set_description( (string) $snapshot['description'] ); } if ( array_key_exists( 'regular_price', $snapshot ) ) { $product->set_regular_price( (string) $snapshot['regular_price'] ); } if ( array_key_exists( 'category_ids', $snapshot ) ) { $product->set_category_ids( array_map( 'intval', (array) $snapshot['category_ids'] ) ); } if ( array_key_exists( 'attributes', $snapshot ) ) { $product->set_attributes( $this->unserialize_attributes( (array) $snapshot['attributes'] ) ); } } /** * Flatten product attributes into a plain, storable structure. * * @param WC_Product $product Product. * @return array> */ protected function serialize_attributes( WC_Product $product ) { $out = array(); foreach ( $product->get_attributes() as $attribute ) { if ( ! $attribute instanceof WC_Product_Attribute ) { continue; } $out[] = array( 'id' => $attribute->get_id(), 'name' => $attribute->get_name(), 'options' => array_map( 'strval', (array) $attribute->get_options() ), 'position' => $attribute->get_position(), 'visible' => $attribute->get_visible(), 'variation' => $attribute->get_variation(), ); } return $out; } /** * Rebuild WC_Product_Attribute objects from a serialized snapshot. * * @param array> $data Serialized attributes. * @return WC_Product_Attribute[] */ protected function unserialize_attributes( array $data ) { $attributes = array(); foreach ( $data as $row ) { $attribute = new WC_Product_Attribute(); if ( ! empty( $row['id'] ) ) { $attribute->set_id( (int) $row['id'] ); } $attribute->set_name( isset( $row['name'] ) ? (string) $row['name'] : '' ); $attribute->set_options( isset( $row['options'] ) ? (array) $row['options'] : array() ); $attribute->set_position( isset( $row['position'] ) ? (int) $row['position'] : 0 ); $attribute->set_visible( ! empty( $row['visible'] ) ); $attribute->set_variation( ! empty( $row['variation'] ) ); $attributes[] = $attribute; } return $attributes; } /** * Read all stored snapshots for a product. * * @param WC_Product $product Product. * @return array> */ protected function get_snapshots( WC_Product $product ) { $snapshots = $product->get_meta( self::META_SNAPSHOT, true ); return is_array( $snapshots ) ? $snapshots : array(); } /** * Persist a snapshot for a fix id (merged with any others). * * @param int $product_id Product ID. * @param string $fix_id Fix id. * @param array $snapshot Snapshot data. * @return void */ protected function store_snapshot( $product_id, $fix_id, array $snapshot ) { $product = wc_get_product( $product_id ); if ( ! $product instanceof WC_Product ) { return; } $snapshots = $this->get_snapshots( $product ); // Preserve the earliest snapshot for a fix. A fix can be applied in several // steps (e.g. schema: short description, then price, then category); keeping // the first pre-change state means one Undo restores everything to how it was // before the first change, rather than to an intermediate state. if ( isset( $snapshots[ $fix_id ] ) ) { return; } $snapshots[ $fix_id ] = $snapshot; $product->update_meta_data( self::META_SNAPSHOT, $snapshots ); $product->save(); } }