get_cache_key( $post_id ); $cached = get_transient( $cache_key ); if ( false === $cached ) { return false; } // Verify the content hash still matches (content has not changed). if ( isset( $cached['content_hash'] ) ) { $current_hash = $this->get_content_hash( $post_id ); if ( $cached['content_hash'] !== $current_hash ) { // Content has changed; invalidate this cache entry. $this->invalidate( $post_id ); return false; } } return isset( $cached['schema'] ) ? $cached['schema'] : false; } /** * Cache schema data for a post. * * Stores the schema along with a content hash for later validation. * Uses the TTL configured in plugin settings, or the default of 1 week. * * @param int $post_id The WordPress post ID. * @param array $schema The schema data to cache. * @return bool True on success, false on failure. */ public function cache_schema( $post_id, $schema ) { $cache_key = $this->get_cache_key( $post_id ); $data = array( 'schema' => $schema, 'content_hash' => $this->get_content_hash( $post_id ), 'timestamp' => time(), ); $ttl = $this->get_ttl(); return set_transient( $cache_key, $data, $ttl ); } /** * Invalidate cached schema for a specific post. * * Also purges any third-party full-page cache plugin's copy of this * post, so a schema change is visible on the next real page load * instead of being served from a stale HTML snapshot. Every schema * write path in the plugin (AI generate, manual editor, auto-apply, * both delete paths) already calls this method, so it is the one * chokepoint that guarantees coverage without editing each caller. * * @param int $post_id The WordPress post ID. * @return bool True on success, false on failure. */ public function invalidate( $post_id ) { $cache_key = $this->get_cache_key( $post_id ); $result = delete_transient( $cache_key ); $this->purge_third_party_cache( $post_id ); return $result; } /** * Purge this post from whichever third-party full-page cache plugin * is active, if any. * * Our own transient cache (above) only controls what our renderer * reads before falling back to the database; it has no effect on a * separate full-page HTML cache (WP Rocket, LiteSpeed Cache, WP Super * Cache, W3 Total Cache) that some sites also run, which caches the * entire rendered page and has no way to know our schema data just * changed unless explicitly told. Each check is guarded by * function_exists()/class_exists() so this is a no-op on sites that * don't have that particular plugin installed. * * @param int $post_id The WordPress post ID. * @return void */ private function purge_third_party_cache( $post_id ) { // WP Rocket. if ( function_exists( 'rocket_clean_post' ) ) { rocket_clean_post( $post_id ); } // LiteSpeed Cache. if ( has_action( 'litespeed_purge_post' ) || class_exists( 'LiteSpeed_Cache_API' ) ) { do_action( 'litespeed_purge_post', $post_id ); } // WP Super Cache. if ( function_exists( 'wp_cache_post_change' ) ) { wp_cache_post_change( $post_id ); } // W3 Total Cache. if ( function_exists( 'w3tc_flush_post' ) ) { w3tc_flush_post( $post_id ); } } /** * Invalidate all cached schemas. * * Deletes all transients that match the Schema AI cache prefix. * This is useful after bulk operations or plugin setting changes. * * @return int Number of cache entries deleted. */ public function invalidate_all() { global $wpdb; // Build the LIKE pattern for cache transients. $prefix = '_transient_' . self::CACHE_PREFIX; $pattern = $wpdb->esc_like( $prefix ) . '%'; // Get all matching transient names. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Required for custom table; no WP API alternative exists. $transients = $wpdb->get_col( $wpdb->prepare( 'SELECT option_name FROM ' . $wpdb->options . ' WHERE option_name LIKE %s', $pattern ) ); $count = 0; foreach ( $transients as $transient_name ) { // Strip the '_transient_' prefix to get the transient key. $key = str_replace( '_transient_', '', $transient_name ); if ( delete_transient( $key ) ) { $count++; } } return $count; } /** * Check if a cached schema exists for a post. * * @param int $post_id The WordPress post ID. * @return bool True if cache exists and is valid. */ public function has_cache( $post_id ) { return false !== $this->get_cached_schema( $post_id ); } /** * Get the cache key for a post. * * The key includes the post ID and a content hash so that changes * to post content naturally invalidate the cache. * * @param int $post_id The WordPress post ID. * @return string The transient cache key. */ private function get_cache_key( $post_id ) { $hash = $this->get_content_hash( $post_id ); return self::CACHE_PREFIX . $post_id . '_' . $hash; } /** * Generate a hash of the post content. * * The hash covers the post title, content, excerpt, and modified date * so that any meaningful change invalidates the cache. * * @param int $post_id The WordPress post ID. * @return string MD5 hash of the content. */ private function get_content_hash( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return md5( (string) $post_id ); } $data = implode( '|', array( $post->post_title, $post->post_content, $post->post_excerpt, $post->post_modified, ) ); return md5( $data ); } /** * Get the configured cache TTL. * * Reads from the schema_ai_settings option. Falls back to the default * TTL of 604800 seconds (1 week). * * @return int TTL in seconds. */ private function get_ttl() { $settings = get_option( 'schema_ai_settings', array() ); if ( isset( $settings['cache_duration'] ) && is_numeric( $settings['cache_duration'] ) ) { return (int) $settings['cache_duration']; } return self::DEFAULT_TTL; } }