$opts Plugin settings. */ public static function init( array $opts ): void { add_action( self::HOOK, array( __CLASS__, 'run' ) ); add_action( self::NIGHTLY, array( __CLASS__, 'nightly' ) ); if ( empty( $opts['auto_update'] ) ) { add_action( 'init', array( __CLASS__, 'unschedule_all' ), 30 ); return; } add_action( 'woocommerce_before_product_object_save', array( __CLASS__, 'on_product_save' ), 10, 1 ); add_action( 'transition_post_status', array( __CLASS__, 'on_status' ), 10, 3 ); add_action( 'init', array( __CLASS__, 'ensure_nightly' ), 30 ); } /** * WooCommerce product or variation is about to be saved: mark only if a visible property changes. * * @param WC_Product $product Product being saved. */ public static function on_product_save( $product ): void { if ( ! $product instanceof WC_Product ) { return; } $changes = $product->get_changes(); if ( ! $product->get_id() ) { if ( 'publish' === $product->get_status() ) { self::mark( 'product' ); } return; } if ( array_intersect( array_keys( $changes ), self::RELEVANT ) ) { self::mark( isset( $changes['stock_status'] ) ? 'stock' : 'product' ); } } /** * Pages and posts: published, updated while published, unpublished or trashed. * Products only when their publication status changes (edits come through on_product_save). * * @param string $new New status. * @param string $old Old status. * @param WP_Post $post Post. */ public static function on_status( $new, $old, $post ): void { if ( ! $post instanceof WP_Post || ( 'publish' !== $new && 'publish' !== $old ) ) { return; } if ( in_array( $post->post_type, array( 'page', 'post' ), true ) ) { self::mark( 'trash' === $new ? 'trash' : 'content' ); } elseif ( 'product' === $post->post_type && $new !== $old ) { self::mark( 'trash' === $new ? 'trash' : 'product' ); } } /** * Mark the files as outdated and (re)schedule the bundled run. * * @param string $reason product | stock | content | trash. */ public static function mark( string $reason ): void { $now = time(); $state = self::state(); if ( empty( $state['dirty_since'] ) ) { $state['dirty_since'] = $now; } $state['reason'] = $reason; $state['last_change'] = $now; $due = min( $now + self::DEBOUNCE, (int) $state['dirty_since'] + self::MAX_WAIT ); if ( self::is_pro() ) { $due = max( $due, (int) ( $state['last_run'] ?? 0 ) + self::PRO_MIN_GAP ); } $state['due'] = $due; update_option( self::OPTION, $state, false ); $next = wp_next_scheduled( self::HOOK ); if ( $next && abs( $next - $due ) > 30 ) { wp_unschedule_event( $next, self::HOOK ); $next = false; } if ( ! $next ) { wp_schedule_single_event( $due, self::HOOK ); } } /** * Bundled run (WP-Cron). */ public static function run(): void { $state = self::state(); if ( empty( $state['dirty_since'] ) ) { return; } if ( self::is_pro() && time() < (int) ( $state['last_run'] ?? 0 ) + self::PRO_MIN_GAP ) { wp_schedule_single_event( (int) $state['last_run'] + self::PRO_MIN_GAP, self::HOOK ); return; } self::generate( (string) ( $state['reason'] ?? 'product' ) ); } /** * Nightly run: catches ranking shifts from new orders and anything no save event reports. */ public static function nightly(): void { $state = self::state(); if ( self::is_pro() && time() < (int) ( $state['last_run'] ?? 0 ) + self::PRO_MIN_GAP ) { return; } self::generate( 'nightly' ); } /** * Generate and record the outcome. * * @param string $reason Why this run happened. */ private static function generate( string $reason ): void { $state = self::state(); $state['dirty_since'] = 0; // Changes during the run mark the files again. $state['due'] = 0; $state['last_run'] = time(); $state['last_reason'] = $reason; update_option( self::OPTION, $state, false ); $ok = ( new LLMSTXT_Generator() )->generate_and_save(); $state = self::state(); $state['last_ok'] = $ok; $state['last_error'] = $ok ? '' : (string) get_option( 'llmstxt_last_error', '' ); update_option( self::OPTION, $state, false ); } /** * Schedule the nightly run at a fixed time between 01:00 and 04:00 site time, spread per site * so that not every shop contacts the generator at the same minute. */ public static function ensure_nightly(): void { if ( wp_next_scheduled( self::NIGHTLY ) ) { return; } $minute = 60 + ( absint( crc32( (string) home_url() ) ) % 180 ); $start = new DateTime( 'tomorrow', wp_timezone() ); $start->modify( '+' . $minute . ' minutes' ); wp_schedule_event( $start->getTimestamp(), 'daily', self::NIGHTLY ); } /** * Remove all scheduled automatic runs (auto-update switched off, deactivation). */ public static function unschedule_all(): void { if ( wp_next_scheduled( self::NIGHTLY ) ) { wp_clear_scheduled_hook( self::NIGHTLY ); } if ( wp_next_scheduled( self::HOOK ) ) { wp_clear_scheduled_hook( self::HOOK ); } } /** * Status for the settings page. * * @return array{last_run:int,last_reason:string,last_ok:bool,last_error:string,next:int,nightly:int} */ public static function status(): array { $s = self::state(); return array( 'last_run' => (int) ( $s['last_run'] ?? 0 ), 'last_reason' => (string) ( $s['last_reason'] ?? '' ), 'last_ok' => ! empty( $s['last_ok'] ), 'last_error' => (string) ( $s['last_error'] ?? '' ), 'next' => (int) wp_next_scheduled( self::HOOK ), 'nightly' => (int) wp_next_scheduled( self::NIGHTLY ), ); } /** * Stored state. * * @return array */ private static function state(): array { $s = get_option( self::OPTION, array() ); return is_array( $s ) ? $s : array(); } /** * Pro mode active? */ private static function is_pro(): bool { $opts = get_option( 'llmstxt_settings', array() ); return 'pro' === ( $opts['mode'] ?? 'free' ) && ! empty( $opts['pro_key'] ); } }