[related types] private static bool $relationships_loaded = false; /** * Private constructor - use for() factory method instead */ private function __construct(string $group, ?int $ttl = null) { $this->group = jvbNoBase($group); $this->cache_ttl = $ttl ?: 3600; if (is_null(static::$use_object_cache)) { static::$use_object_cache = wp_using_ext_object_cache(); } } /** * Get or create a cache manager instance for a content type * * @param string $type Content type (tattoo, style, etc.) * @param int|null $ttl Optional TTL override * @return self Fluent interface */ public static function for(string $type, ?int $ttl = null): self { $type = jvbNoBase($type); $key = $type . ($ttl ? "_{$ttl}" : ''); if (!isset(self::$instances[$key])) { self::$instances[$key] = new self($type, $ttl); } return self::$instances[$key]; } /** * Get cache manager for a specific user * Each user gets their own cache group for complete isolation * * @param int $user_id User ID * @param int|null $ttl Optional TTL * @return self */ public static function forUser(int $user_id, ?int $ttl = null): self { return self::for("user_{$user_id}", $ttl); } /** * Get HTTP cache timestamp for content type(s) * Used for ETag and Last-Modified header generation * * @param string|array $types Single type or array of types * @return int Latest timestamp (Unix time) */ public static function getTimestamp(string|array $types): int { // Multiple types - return latest if (is_array($types)) { $latest = 0; foreach ($types as $type) { $timestamp = self::getTimestamp($type); if ($timestamp > $latest) { $latest = $timestamp; } } return $latest ?: time(); } $type = jvbNoBase($types); // Check request-level cache if (isset(self::$http_timestamps[$type])) { return self::$http_timestamps[$type]; } // Load from cache (Redis or transient - wp_cache handles it) $timestamp = (int)wp_cache_get("http_ts_{$type}", 'jvb_timestamps') ?: time(); // Cache in memory for this request self::$http_timestamps[$type] = $timestamp; return $timestamp; } /** * Update HTTP cache timestamp (marks content as modified) * * @param string $type Content type * @return int The new timestamp */ public static function updateTimestamp(string $type): int { $type = jvbNoBase($type); $timestamp = time(); // Store (Redis or transient - wp_cache handles it) wp_cache_set("http_ts_{$type}", $timestamp, 'jvb_timestamps', WEEK_IN_SECONDS); // Update request cache self::$http_timestamps[$type] = $timestamp; do_action('jvb_http_timestamp_updated', $type, $timestamp); return $timestamp; } /** * Invalidate cache for a content type with automatic cascade * * @param string $type Content type to invalidate * @param mixed $context Post/Term object or array with relationship data (for cascade) * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group * @return void */ public static function invalidateAll(string $type, $context = null, $specific_keys = null): void { $type = jvbNoBase($type); // Update HTTP timestamp self::updateTimestamp($type); // If specific keys provided, only delete those (don't flush whole group) if ($specific_keys !== null) { $instance = self::for($type); if (is_array($specific_keys)) { foreach ($specific_keys as $key) { $instance->delete($key); } } else { $instance->delete($specific_keys); } } else { // No specific keys - flush the entire group if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group($type); } else { // Fallback for older WP wp_cache_flush(); } } // Cascade to related types if context provided if ($context !== null) { self::cascadeInvalidation($type, $context); } do_action('jvb_cache_invalidated', $type, $context); } /** * Invalidate only specific keys for a type (doesn't flush group or update timestamp) * Use this when you want surgical cache invalidation * * @param string $type Content type * @param string|array $keys Key(s) to delete * @return void */ public static function invalidateKeys(string $type, string|array $keys): void { $instance = self::for($type); if (is_array($keys)) { foreach ($keys as $key) { $instance->delete($key); } } else { $instance->delete($keys); } } /** * Fluent instance method to invalidate this cache type * Allows chaining: CacheManager::for('tattoo')->invalidate()->clear() * * @param mixed $context Optional context for cascade * @param string|array|null $specific_keys Optional specific key(s) * @return self For chaining */ public function invalidate($context = null, $specific_keys = null): self { self::invalidateAll($this->group, $context, $specific_keys); return $this; } /** * Get the HTTP timestamp for this instance's type * * @return int */ public function timestamp(): int { return self::getTimestamp($this->group); } /** * Update the HTTP timestamp for this instance's type * * @return self For chaining */ public function touch(): self { self::updateTimestamp($this->group); return $this; } /** * Get a value from the cache * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param string|null $group The group to get from. Defaults to current group * @return mixed */ public function get(string|array $key, ?string $group = null): mixed { $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); return wp_cache_get($cache_key, $group); } /** * Store a value in cache * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param mixed $value The Value to set * @param int|null $ttl The ttl (defaults to current set ttl) * @param string|null $group The group to add cache to (defaults to current group)) * @return bool */ public function set(string|array $key, mixed $value, ?int $ttl = null, ?string $group = null): bool { $ttl = $ttl ?: $this->cache_ttl; $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); // Update timestamp when setting new data self::updateTimestamp($this->group); return wp_cache_set($cache_key, $value, $group, $ttl); } /** * Delete a cached value * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param string|null $group The group to delete from (defaults to current group) * @return bool */ public function delete(string|array $key, ?string $group = null): bool { $group = $group ?: $this->group; $key = $this->normalizeKey($key); $cache_key = $this->buildKey($key); return wp_cache_delete($cache_key, $group); } /** * Clear all cache for this group * @return bool */ public function clear(): bool { try { if (function_exists('wp_cache_flush_group')) { wp_cache_flush_group($this->group); self::updateTimestamp($this->group); return true; } return false; } catch (\Exception $e) { return false; } } /** * Helper to generateKey from array if applicable * @param string|array $key * @return string */ private function normalizeKey(string|array $key): string { return is_array($key) ? $this->generateKey($key) : $key; } /** * Generate a cache key from parameters * @param array $params An array of key/values that differentiates this cache item from others * @return string */ public function generateKey(array $params): string { // Sort params for consistent key generation ksort($params); return md5(serialize($params)); } /** * The workhorse shorthand of CacheManager. Tests the cache, and calls the callback if nothing is found. * @param string|array $key The key to look up (auto-generates key from array of key=>values) * @param callable $callback The callback to generate the value for this key * @param int|null $ttl The time-to-live for the cache. Defaults to constructor * @param string|null $group The group to save cache to. Defaults to constructor * @return mixed */ public function remember(string|array $key, callable $callback, ?int $ttl = null, ?string $group = null): mixed { $group = $group ?: $this->group; $ttl = $ttl ?: $this->cache_ttl; $key = $this->normalizeKey($key); $value = $this->get($key, $group); if ($value === false) { $value = $callback(); if ($value !== false && $value !== null) { $this->set($key, $value, $ttl, $group); } } return $value; } /** * Build the cache key * @param string $key * @return string */ private function buildKey(string $key): string { return $this->prefix . $key; } /** * Get instance group name (for debugging) */ public function getGroup(): string { return $this->group; } // ===== RELATIONSHIP MANAGEMENT ===== /** * Register cache relationship * When $type is invalidated, these related types are also invalidated * * @param string $type Primary type * @param array $config Relationship configuration * - 'author' => bool - Invalidate user content caches * - 'taxonomies' => array - List of taxonomy types to invalidate * - 'content_types' => array - List of content types to invalidate * - 'related' => array - Generic related types to invalidate * - 'cascade' => callable - Custom cascade function */ public static function registerRelationship(string $type, array $config): void { $type = jvbNoBase($type); // Merge with existing relationships self::$relationships[$type] = array_merge( self::$relationships[$type] ?? [], $config ); // Build reverse relationships for bidirectional linking self::buildReverseRelationships($type, $config); } /** * Build reverse relationships (if A relates to B, B should know about A) * * @param string $type The type being registered * @param array $config Its relationship config */ private static function buildReverseRelationships(string $type, array $config): void { // If this type relates to taxonomies, those taxonomies should know about this type if (!empty($config['taxonomies'])) { foreach ($config['taxonomies'] as $taxonomy) { $taxonomy = jvbNoBase($taxonomy); self::$relationships[$taxonomy]['content_types'] = array_unique(array_merge( self::$relationships[$taxonomy]['content_types'] ?? [], [$type] )); } } // If this type relates to content_types, those types should know about this taxonomy if (!empty($config['content_types'])) { foreach ($config['content_types'] as $content_type) { $content_type = jvbNoBase($content_type); self::$relationships[$content_type]['related'] = array_unique(array_merge( self::$relationships[$content_type]['related'] ?? [], [$type] )); } } } /** * Load relationships from JVB_CONTENT and JVB_TAXONOMY */ private static function loadRelationships(): void { if (self::$relationships_loaded) { return; } // Load post type relationships if (defined('JVB_CONTENT')) { foreach (JVB_CONTENT as $slug => $config) { $relationships = []; // Author relationship if (!($config['no_author'] ?? false)) { $relationships['author'] = true; } // Taxonomy relationships if (!empty($config['taxonomies'])) { $relationships['taxonomies'] = array_map('jvbNoBase', $config['taxonomies']); } // Custom relationships from config if (!empty($config['cache_relationships'])) { $relationships = array_merge($relationships, $config['cache_relationships']); } if (!empty($relationships)) { self::registerRelationship($slug, $relationships); } } } // Load taxonomy relationships if (defined('JVB_TAXONOMY')) { foreach (JVB_TAXONOMY as $slug => $config) { $relationships = []; // Content type relationships if (!empty($config['for_content'])) { $relationships['content_types'] = array_map('jvbNoBase', $config['for_content']); } // Always include generic 'terms' cache $relationships['related'] = ['terms']; // Custom relationships from config if (!empty($config['cache_relationships'])) { $relationships = array_merge($relationships, $config['cache_relationships']); } if (!empty($relationships)) { self::registerRelationship($slug, $relationships); } } } self::$relationships_loaded = true; do_action('jvb_cache_relationships_loaded', self::$relationships); } /** * Get relationships for a type (for debugging) * * @param string|null $type Specific type or null for all * @return array Relationships */ public static function getRelationships(?string $type = null): array { self::loadRelationships(); if ($type !== null) { return self::$relationships[jvbNoBase($type)] ?? []; } return self::$relationships; } /** * Cascade invalidation to related types based on relationships * * @param string $type Primary type being invalidated * @param mixed $context Context with relationship data */ /** * Cascade invalidation to related types based on relationships */ private static function cascadeInvalidation(string $type, $context): void { self::loadRelationships(); $relationships = self::$relationships[$type] ?? []; if (empty($relationships)) { return; } $data = self::extractContext($context); // Author relationship - SIMPLIFIED if (!empty($relationships['author'])) { $user_ids = self::extractUserIds($data, $relationships['author']); foreach ($user_ids as $user_id) { // Single clean call - handles content, profile, everything self::invalidateAll("user_{$user_id}"); } } // Taxonomy relationships if (!empty($relationships['taxonomies']) && !empty($data['ID'])) { foreach ($relationships['taxonomies'] as $taxonomy) { $taxonomy_full = jvbCheckBase($taxonomy); $terms = wp_get_post_terms($data['ID'], $taxonomy_full, ['fields' => 'ids']); if (!empty($terms) && !is_wp_error($terms)) { self::updateTimestamp($taxonomy); wp_cache_flush_group($taxonomy); } } } // Content type relationships (for taxonomies) if (!empty($relationships['content_types'])) { foreach ($relationships['content_types'] as $content_type) { self::updateTimestamp($content_type); wp_cache_flush_group($content_type); } } // Generic related caches if (!empty($relationships['related'])) { foreach ($relationships['related'] as $related_type) { self::updateTimestamp($related_type); wp_cache_flush_group($related_type); } } // Custom cascade function if (!empty($relationships['cascade']) && is_callable($relationships['cascade'])) { call_user_func($relationships['cascade'], $type, $data); } } /** * Extract user IDs from context based on relationship config * Supports multiple authors, contributors, etc. * * @param array $data Context data * @param mixed $config Author relationship config (bool or array) * @return array User IDs to invalidate */ private static function extractUserIds(array $data, $config): array { $user_ids = []; // Simple case: 'author' => true if ($config === true) { if (!empty($data['post_author'])) { $user_ids[] = $data['post_author']; } return array_filter($user_ids); } // Advanced case: 'author' => ['post_author', 'contributors', 'linked_user'] if (is_array($config)) { foreach ($config as $field) { // Handle meta fields if (str_starts_with($field, 'meta:') && !empty($data['ID'])) { $meta_key = substr($field, 5); $value = get_post_meta($data['ID'], BASE . $meta_key, true); if (is_array($value)) { $user_ids = array_merge($user_ids, $value); } elseif ($value) { $user_ids[] = $value; } } // Handle direct data fields elseif (!empty($data[$field])) { if (is_array($data[$field])) { $user_ids = array_merge($user_ids, $data[$field]); } else { $user_ids[] = $data[$field]; } } } } // Callable: 'author' => function($data) { return [...user_ids]; } if (is_callable($config)) { $result = call_user_func($config, $data); if (is_array($result)) { $user_ids = array_merge($user_ids, $result); } elseif ($result) { $user_ids[] = $result; } } return array_unique(array_filter(array_map('intval', $user_ids))); } /** * Extract context data from various formats * Converts WP objects to arrays with relevant data * * @param mixed $context Post/Term object, array, or ID * @return array Normalized context data */ private static function extractContext($context): array { if (is_array($context)) { return $context; } if ($context instanceof \WP_Post) { return [ 'ID' => $context->ID, 'post_author' => $context->post_author, 'post_type' => $context->post_type, 'post_status' => $context->post_status, ]; } if ($context instanceof \WP_Term) { return [ 'term_id' => $context->term_id, 'taxonomy' => $context->taxonomy, 'parent' => $context->parent, ]; } if (is_numeric($context)) { $post = get_post($context); if ($post) { return self::extractContext($post); } } return []; } }