From 0113d2e9c9ff34a6ffb10707cc76d34b67a0c367 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 19 Jan 2026 16:29:41 +0000
Subject: [PATCH] =Refactored window.getTemplate into a full templating class window.jvbTemplates. Refactored CRUD.js, UploadManager.js, FormController.js, PopulateForm.js with that in mind

---
 inc/managers/CacheManager.php |  928 ++++++++++++++++++++++++++++++++++++++++++++--------------
 1 files changed, 704 insertions(+), 224 deletions(-)

diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
index e6530da..2583431 100644
--- a/inc/managers/CacheManager.php
+++ b/inc/managers/CacheManager.php
@@ -2,30 +2,296 @@
 namespace JVBase\managers;
 
 if (!defined('ABSPATH')) {
-	exit; // Exit if accessed directly
+	exit;
 }
 
+/**
+ * Manages HTTP cache timestamps and relationship-based invalidation
+ *
+ * Data caching: Use wrapper methods or wp_cache_get/set directly
+ * HTTP caching: This class manages timestamps for ETag/Last-Modified headers
+ */
 class CacheManager
 {
-	private string $prefix = 'jvb_';
+	private const CONNECTIONS_OPTION = BASE.'cache_connections';
+	private static ?array $connections_cache = null; // Cache in memory
+	private string $prefix = BASE;
 	private string $group;
 	private int $cache_ttl;
 	private static ?bool $use_object_cache = null;
+	private static array $instances = []; // Cache instances per type
+	private static array $http_timestamps = []; // Request-level memory cache
+	private static ?CacheManager $singleton = null;
 
 	/**
-	 * @param string|null $group The group name for this cache instance
-	 * @param int|null $ttl The default ttl for this instance
+	 * Private constructor - use for() factory method instead
 	 */
-	public function __construct(?string $group = null, ?int $ttl = null)
+	private function __construct(string $group, ?int $ttl = null)
 	{
-		$this->group = $group ?: 'jvb_default';
+		$this->group = jvbNoBase($group);
 		$this->cache_ttl = $ttl ?: 3600;
 
-		// Check if Redis/Memcached is available
 		if (is_null(static::$use_object_cache)) {
-			static::$use_object_cache = !is_null(wp_using_ext_object_cache());
-//			error_log((static::$use_object_cache) ? 'Using Object Cache' : 'Not using Object Cache');
+			static::$use_object_cache = wp_using_ext_object_cache();
 		}
+
+		add_action('init', [$this, 'registerHooks']);
+	}
+
+	/**
+	 * Get singleton instance (for general cache operations)
+	 * For type-specific operations, use for() or forUser() instead
+	 */
+	public static function getInstance(): self
+	{
+		if (self::$singleton === null) {
+			self::$singleton = new self('global', HOUR_IN_SECONDS);
+		}
+		return self::$singleton;
+	}
+
+	/**
+	 * Get all cache connections (public accessor)
+	 *
+	 * @return array Array of cache group connections
+	 */
+	public static function getAllConnections(): array
+	{
+		return self::getConnections();
+	}
+
+	/**
+	 * Get all registered cache groups
+	 *
+	 * @return array List of cache group names
+	 */
+	public static function getAllGroups(): array
+	{
+		$connections = self::getConnections();
+		return array_keys($connections);
+	}
+	/**
+	 * Register WordPress hooks for automatic cache invalidation
+	 * Call this once during plugin initialization
+	 */
+	public static function registerHooks(): void
+	{
+		// Post updates (all post types including core)
+		add_action('save_post', [self::class, 'onPostSave'], 10, 2);
+		add_action('delete_post', [self::class, 'onPostDelete']);
+		// Meta updates (will catch MetaManager updates)
+		add_action('updated_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+		add_action('added_post_meta', [self::class, 'onPostMetaUpdate'], 10, 4);
+		add_action('deleted_post_meta', [self::class, 'onPostMetaDelete'], 10, 4);
+		// transition_post_status?
+
+		// Term updates (all taxonomies)
+		add_action('edited_term', [self::class, 'onTermSave'], 10, 3);
+		add_action('create_term', [self::class, 'onTermSave'], 10, 3);
+		add_action('delete_term', [self::class, 'onTermDelete'], 10, 3);
+
+		// Term meta updates
+		add_action('updated_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+		add_action('added_term_meta', [self::class, 'onTermMetaUpdate'], 10, 4);
+		add_action('deleted_term_meta', [self::class, 'onTermMetaDelete'], 10, 4);
+
+		// User updates
+		add_action('profile_update', [self::class, 'onUserUpdate'], 10, 2);
+		add_action('user_register', [self::class, 'onUserUpdate'], 10, 1);
+		add_action('deleted_user', [self::class, 'onUserDelete']);
+
+		// User meta updates
+		add_action('updated_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+		add_action('added_user_meta', [self::class, 'onUserMetaUpdate'], 10, 4);
+		add_action('deleted_user_meta', [self::class, 'onUserMetaDelete'], 10, 4);
+	}
+
+	/**
+	 * 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
+	 *
+	 * @param string $type Content type to invalidate
+	 * @param string|array|null $specific_keys Optional specific key(s) to delete without flushing group
+	 * @param bool $flush_connections Whether to flush connected caches
+	 * @return void
+	 */
+	public static function invalidateAll(string $type, $specific_keys = null, bool $flush_connections = true): void
+	{
+		$type = jvbNoBase($type);
+
+		// Update HTTP timestamp
+		self::updateTimestamp($type);
+
+		// If specific keys provided, only delete those
+		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 {
+			// Flush the entire group
+			if (function_exists('wp_cache_flush_group')) {
+				wp_cache_flush_group($type);
+			} else {
+				wp_cache_flush();
+			}
+		}
+
+		// Flush connected caches
+		if ($flush_connections) {
+			self::for($type)->connections();
+		}
+
+		do_action('jvb_cache_invalidated', $type);
+	}
+
+	/**
+	 * 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
+	 *
+	 * @param string|array|null $specific_keys Optional specific key(s)
+	 * @param bool $flush_connections Whether to flush connected caches
+	 * @return self For chaining
+	 */
+	public function invalidate($specific_keys = null, bool $flush_connections = true): self
+	{
+		self::invalidateAll($this->group, $specific_keys, $flush_connections);
+		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;
 	}
 
 	/**
@@ -37,39 +303,17 @@
 	public function get(string|array $key, ?string $group = null): mixed
 	{
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			$value = wp_cache_get($cache_key, $group);
-		} else {
-			// Fallback to transients for local development
-			$value = get_transient($this->getTransientKey($cache_key, $group));
+		$value = wp_cache_get($cache_key, $group);
+
+		// Fallback to transient if no external object cache
+		if ($value === false && !wp_using_ext_object_cache()) {
+			$value = get_transient($group . '_' . $cache_key);
 		}
 
-		return (is_array($value) && array_key_exists('data', $value)) ? $value['data'] : $value;
-	}
-
-	public function getTimestamp(string|array $key, ?string $group = null): mixed
-	{
-		$group = $group ?: $this->group;
-
-		$key = $this->normalizeKey($key);
-
-		$cache_key = $this->buildKey($key);
-
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			$value = wp_cache_get($cache_key, $group);
-		} else {
-			// Fallback to transients for local development
-			$value = get_transient($this->getTransientKey($cache_key, $group));
-		}
-
-		return (is_array($value) && array_key_exists('last_modified', $value)) ? $value['last_modified'] : false;
+		return $value;
 	}
 
 	/**
@@ -84,25 +328,21 @@
 	{
 		$ttl = $ttl ?: $this->cache_ttl;
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
-		$temp = [
-			'data' => $value,
-			'last_modified' => time(),
-		];
-		$value = $temp;
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			return wp_cache_set($cache_key, $value, $group, $ttl);
-		} else {
-			// Fallback to transients
-			return set_transient($this->getTransientKey($cache_key, $group), $value, $ttl);
+		self::updateTimestamp($this->group);
+
+		// Try object cache first
+		$result = wp_cache_set($cache_key, $value, $group, $ttl);
+
+		// If no external object cache, also store in transient for persistence
+		if (!wp_using_ext_object_cache()) {
+			set_transient($group . '_' . $cache_key, $value, $ttl);
 		}
-	}
 
+		return $result;
+	}
 	/**
 	 * Delete a cached value
 	 * @param string|array $key The key to look up (auto-generates key from array of key=>values)
@@ -112,147 +352,60 @@
 	public function delete(string|array $key, ?string $group = null): bool
 	{
 		$group = $group ?: $this->group;
-
 		$key = $this->normalizeKey($key);
-
 		$cache_key = $this->buildKey($key);
 
-		// Use appropriate cache method
-		if (static::$use_object_cache) {
-			return wp_cache_delete($cache_key, $group);
-		} else {
-			return delete_transient($this->getTransientKey($cache_key, $group));
+		$result = wp_cache_delete($cache_key, $group);
+
+		// Also delete transient if no external object cache
+		if (!wp_using_ext_object_cache()) {
+			delete_transient($group . '_' . $cache_key);
 		}
+
+		return $result;
 	}
 
-	public function clear():bool
+
+	/**
+	 * Clear all cache for this group
+	 * @return bool
+	 */
+	public function clear(): bool
 	{
 		try {
-			if (static::$use_object_cache) {
-				// With Redis, this could be implemented with SCAN command
-				// but wp_cache_* doesn't expose this, so we'd need direct Redis access
-				// For now, just flush the group as a nuclear option
-				if (function_exists('wp_cache_flush_group')) {
-					wp_cache_flush_group($this->group);
-					return true;
-				}
-				return false;
-			} else {
-				// For transients, search and delete
-				global $wpdb;
-
-				$prefix = self::getTransientPrefix($this->group);
-				$sql = "SELECT option_name FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    AND option_name LIKE %s";
-
-				$keys = $wpdb->get_col($wpdb->prepare(
-					$sql,
-					'_transient_' . $prefix . '%'
-				));
-
-				foreach ($keys as $key) {
-					$transient_key = str_replace('_transient_', '', $key);
-					delete_transient($transient_key);
-				}
-				return true;
+			if (function_exists('wp_cache_flush_group')) {
+				wp_cache_flush_group($this->group);
 			}
-		} catch (\Exception $e) {
 
-		} finally {
+			// Clear transients for this group if no external object cache
+			if (!wp_using_ext_object_cache()) {
+				$this->clearGroupTransients();
+			}
+
+			self::updateTimestamp($this->group);
+			return true;
+		} catch (\Exception $e) {
 			return false;
 		}
 	}
 
 	/**
-	 * Alias for delete() for backwards compatibility
-	 * @param string $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 void
+	 * Clear all transients for this cache group
 	 */
-	public function invalidate(string $key, ?string $group = null): void
+	private function clearGroupTransients(): void
 	{
-		$this->delete($key, $group);
-	}
+		global $wpdb;
 
-	/**
-	 * Clear all cache entries for a group
-	 * @param string $group The group to clear
-	 * @return bool
-	 */
-	public static function invalidateGroup(string $group): bool
-	{
-		$group = jvbNoBase($group);
+		$pattern = '_transient_' . $this->group . '_' . $this->prefix . '%';
+		$timeout_pattern = '_transient_timeout_' . $this->group . '_' . $this->prefix . '%';
 
-		if (wp_using_ext_object_cache()) {
-			// With Redis/Memcached, use native group flush
-			if (function_exists('wp_cache_flush_group')) {
-				return wp_cache_flush_group($group);
-			} else {
-				// Fallback for older WP versions - flush everything (not ideal)
-				return wp_cache_flush();
-			}
-		} else {
-			// For transients, we need to delete them from database
-			global $wpdb;
-
-			$prefix = self::getTransientPrefix($group);
-
-			// Delete transients and their timeouts
-			$sql = "DELETE FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    OR option_name LIKE %s";
-
-			$result = $wpdb->query($wpdb->prepare(
-				$sql,
-				'_transient_' . $prefix . '%',
-				'_transient_timeout_' . $prefix . '%'
-			));
-
-			return $result !== false;
-		}
-	}
-
-	/**
-	 * Clear cache entries by pattern (only works efficiently with Redis)
-	 * @param string $pattern
-	 * @return int
-	 */
-	public function clearPattern(string $pattern): int
-	{
-		$count = 0;
-
-		if (static::$use_object_cache) {
-			// With Redis, this could be implemented with SCAN command
-			// but wp_cache_* doesn't expose this, so we'd need direct Redis access
-			// For now, just flush the group as a nuclear option
-			if (function_exists('wp_cache_flush_group')) {
-				wp_cache_flush_group($this->group);
-				return $count;
-			}
-		} else {
-			// For transients, search and delete
-			global $wpdb;
-
-			$prefix = self::getTransientPrefix($this->group);
-			$sql = "SELECT option_name FROM {$wpdb->options}
-                    WHERE option_name LIKE %s
-                    AND option_name LIKE %s";
-
-			$keys = $wpdb->get_col($wpdb->prepare(
-				$sql,
-				'_transient_' . $prefix . '%',
-				'%' . $pattern . '%'
-			));
-
-			foreach ($keys as $key) {
-				$transient_key = str_replace('_transient_', '', $key);
-				delete_transient($transient_key);
-				$count++;
-			}
-		}
-
-		return $count;
+		$wpdb->query(
+			$wpdb->prepare(
+				"DELETE FROM $wpdb->options WHERE option_name LIKE %s OR option_name LIKE %s",
+				$pattern,
+				$timeout_pattern
+			)
+		);
 	}
 
 	/**
@@ -289,22 +442,18 @@
 	{
 		$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 = [
-					'data' => $value,
-					'last_modified' => time(),
-				];
+			if ($value !== false && $value !== null) {
 				$this->set($key, $value, $ttl, $group);
 			}
 		}
 
-		return (is_array($value) && array_key_exists('data', $value)) ? $value['data']: $value;
+		return $value;
 	}
 
 	/**
@@ -318,59 +467,390 @@
 	}
 
 	/**
-	 * Get transient key for fallback mode
-	 * @param string $key
-	 * @param string $group
-	 * @return string
+	 * Get instance group name (for debugging)
 	 */
-	private function getTransientKey(string $key, string $group): string
+	public function getGroup(): string
 	{
-		// Transients have a 172 character limit
-		$full_key = $group . '_' . $key;
+		return $this->group;
+	}
 
-		if (strlen($full_key) > 160) {
-			// Use hash for long keys, but keep group prefix for clearPattern()
-			return substr($group, 0, 20) . '_' . md5($full_key);
+
+	/***************************************************************************
+	 * CONNECTIONS
+	 * Connect to other caches by instantiating and defining connection
+	 * Ex: CacheManager::for('usernames')->connectTo($type, $scope = 'all', $keyPattern)
+	 * Where: 	$type = content / taxonomy / user
+	 * 			$scope = either 'id' for specific item, or the entire group (registered post type, taxonomy, or user role)
+	 * 			$keyPattern = ??
+	***************************************************************************/
+	/**
+	 * Define a connection between cache groups
+	 * Connected caches will have their ID-based keys deleted when this cache invalidates
+	 *
+	 * @param string $type Grand overview ('post', 'taxonomy', 'user')
+	 * @param string $scope Type-specific constant, user role, or 'id'
+	 * @return self For chaining
+	 */
+	public function connectTo(string $type, string $scope = 'id'): self
+	{
+		//TODO: Handle connect to where $type === 'all'
+		$connections = self::getConnections();
+
+		if (!isset($connections[$this->group])) {
+			$connections[$this->group] = [];
 		}
 
-		return $full_key;
-	}
+		$new_connection = [
+			'parent' => $type,
+			'scope' => $scope
+		];
 
-	/**
-	 * Get transient prefix for a group
-	 */
-	private static function getTransientPrefix(string $group): string
-	{
-		return $group . '_jvb_';
-	}
-
-	/**
-	 * Check if using object cache
-	 */
-	public function isUsingObjectCache(): bool
-	{
-		return static::$use_object_cache;
-	}
-
-
-	/**
-	 * Cleanup expired transients (maintenance method for non-Redis environments)
-	 */
-	public static function cleanupExpiredTransients(): int
-	{
-		if (wp_using_ext_object_cache()) {
-			return 0; // Not needed with Redis
+		// Check if already exists
+		foreach ($connections[$this->group] as $existing) {
+			if ($existing === $new_connection) {
+				return $this;
+			}
 		}
 
-		global $wpdb;
+		$connections[$this->group][] = $new_connection;
+		update_option(self::CONNECTIONS_OPTION, $connections, false);
+		self::$connections_cache = $connections;
 
-		// Delete expired transients
-		$sql = "DELETE a, b FROM {$wpdb->options} a, {$wpdb->options} b
-                WHERE a.option_name LIKE '_transient_%'
-                AND a.option_name NOT LIKE '_transient_timeout_%'
-                AND b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
-                AND b.option_value < %d";
+		return $this;
+	}
 
-		return $wpdb->query($wpdb->prepare($sql, time()));
+	/**
+	 * Get all registered connections (cached for performance)
+	 *
+	 * @param bool $refresh Force refresh from database
+	 * @return array
+	 */
+	private static function getConnections(bool $refresh = false): array
+	{
+		if (self::$connections_cache === null || $refresh) {
+			self::$connections_cache = get_option(self::CONNECTIONS_OPTION, []);
+		}
+
+		return self::$connections_cache;
+	}
+
+	/**
+	 * Flush all caches connected to this one
+	 *
+	 * @return self For chaining
+	 */
+	public function connections(): self
+	{
+		$all_connections = self::getConnections();
+
+		foreach ($all_connections as $cache_group => $connections) {
+			foreach ($connections as $conn) {
+				if ($this->matchesConnection($conn)) {
+					$this->flushConnection($cache_group, $conn);
+				}
+			}
+		}
+
+		return $this;
+	}
+
+	/**
+	 * Check if this cache group matches a connection definition
+	 */
+	private function matchesConnection(array $connection): bool
+	{
+		$parent = $connection['parent'] ?? '';
+		$scope = $connection['scope'] ?? 'id';
+
+		// Grand overview match
+		if ($this->group === $parent) {
+			return true;
+		}
+
+		// Type-specific match
+		if ($scope !== 'id') {
+			if ($this->group === jvbNoBase($scope)) {
+				return true;
+			}
+
+			// Check constants
+			if ($parent === 'post' && defined('JVB_CONTENT')) {
+				return isset(JVB_CONTENT[$scope]) && jvbNoBase($scope) === $this->group;
+			}
+
+			if ($parent === 'taxonomy' && defined('JVB_TAXONOMY')) {
+				return isset(JVB_TAXONOMY[$scope]) && jvbNoBase($scope) === $this->group;
+			}
+		}
+
+		// ID-specific match: 'user_123' matches 'user' + 'id'
+		if ($scope === 'id' && str_starts_with($this->group, $parent . '_')) {
+			return true;
+		}
+
+		return false;
+	}
+
+	/**
+	 * Flush a connected cache group
+	 * For ID-specific connections, deletes the specific ID key
+	 * For type/overview connections, flushes entire group
+	 */
+	private function flushConnection(string $cache_group, array $connection): void
+	{
+		$scope = $connection['scope'] ?? 'id';
+
+		// ID-specific: delete specific key
+		if ($scope === 'id') {
+			$id = $this->extractIdFromGroup();
+
+			if ($id !== null) {
+				self::invalidateKeys($cache_group, $id);
+				return;
+			}
+		}
+
+		// Type/overview: flush entire group
+		self::invalidateAll($cache_group, specific_keys: null, flush_connections: false);
+	}
+
+	/**
+	 * Extract ID from group name like 'user_123' -> '123'
+	 *
+	 * @return string|null
+	 */
+	private function extractIdFromGroup(): ?string
+	{
+		if (preg_match('/^[a-z]+_(\d+)$/', $this->group, $matches)) {
+			return $matches[1];
+		}
+
+		return null;
+	}
+
+	/**
+	 * Register multiple connections at once
+	 */
+	public static function registerConnections(array $connections): void
+	{
+		$existing = self::getConnections();
+		$changed = false;
+
+		foreach ($connections as $cache_group => $configs) {
+			if (!isset($existing[$cache_group])) {
+				$existing[$cache_group] = [];
+			}
+
+			foreach ($configs as $config) {
+				$duplicate = false;
+				foreach ($existing[$cache_group] as $existing_config) {
+					if ($existing_config === $config) {
+						$duplicate = true;
+						break;
+					}
+				}
+
+				if (!$duplicate) {
+					$existing[$cache_group][] = $config;
+					$changed = true;
+				}
+			}
+		}
+
+		if ($changed) {
+			update_option(self::CONNECTIONS_OPTION, $existing, false);
+			self::$connections_cache = $existing;
+		}
+	}
+
+	/**
+	 * Handle post save/update
+	 */
+	public static function onPostSave(int $post_id, \WP_Post $post): void
+	{
+		// Skip revisions and autosaves
+		if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) {
+			return;
+		}
+
+		$post_type = jvbNoBase($post->post_type);
+
+		// Invalidate post type cache
+		self::invalidateAll($post_type);
+
+		// Invalidate specific post cache
+		self::invalidateAll($post_id);
+		// Clear WordPress core post object cache
+		clean_post_cache($post_id);
+	}
+
+	/**
+	 * Handle post deletion
+	 */
+	public static function onPostDelete(int $post_id): void
+	{
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		$post_type = jvbNoBase($post->post_type);
+
+		self::invalidateAll($post_type);
+		self::invalidateAll($post_id);
+		// Clear WordPress core post object cache
+		clean_post_cache($post_id);
+	}
+
+	/**
+	 * Handle term save/update
+	 */
+	public static function onTermSave(int $term_id, int $tt_id, string $taxonomy): void
+	{
+		// Clear WordPress core term cache
+		clean_term_cache($term_id, $taxonomy);
+		$taxonomy = jvbNoBase($taxonomy);
+
+		// Invalidate taxonomy cache
+		self::invalidateAll($taxonomy);
+
+		// Invalidate specific term cache
+		self::invalidateAll($term_id);
+	}
+
+	/**
+	 * Handle term deletion
+	 */
+	public static function onTermDelete(int $term_id, int $tt_id, string $taxonomy): void
+	{
+		// Clear WordPress core term cache
+		clean_term_cache($term_id, $taxonomy);
+		$taxonomy = jvbNoBase($taxonomy);
+
+		self::invalidateAll($taxonomy);
+		self::invalidateAll($term_id);
+	}
+
+	/**
+	 * Handle user update
+	 */
+	public static function onUserUpdate(int $user_id, ?\WP_User $old_user_data = null): void
+	{
+		// Invalidate user-specific cache
+		self::invalidateAll($user_id);
+
+		// Invalidate user role caches if roles changed
+		if ($old_user_data) {
+			$user = get_userdata($user_id);
+			if ($user && $user->roles !== $old_user_data->roles) {
+				foreach (array_merge($user->roles, $old_user_data->roles) as $role) {
+					self::invalidateAll($role);
+				}
+			}
+		}
+		// Clear WordPress core user cache
+		clean_user_cache($user_id);
+	}
+
+	/**
+	 * Handle user deletion
+	 */
+	public static function onUserDelete(int $user_id): void
+	{
+		self::invalidateAll($user_id);
+		// Clear WordPress core user cache
+		clean_user_cache($user_id);
+	}
+
+	/**
+	 * Handle post meta updates
+	 */
+	public static function onPostMetaUpdate(int $meta_id, int $post_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		self::onPostSave($post_id, $post);
+	}
+	public static function onPostMetaDelete(array $meta_ids, int $post_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$post = get_post($post_id);
+		if (!$post) {
+			return;
+		}
+
+		self::onPostSave($post_id, $post);
+	}
+
+	/**
+	 * Handle term meta updates
+	 */
+	public static function onTermMetaUpdate(int $meta_id, int $term_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$term = get_term($term_id);
+		if (!$term || is_wp_error($term)) {
+			return;
+		}
+
+		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+	}
+
+	public static function onTermMetaDelete(array $meta_ids, int $term_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$term = get_term($term_id);
+		if (!$term || is_wp_error($term)) {
+			return;
+		}
+
+		self::onTermSave($term_id, $term->term_taxonomy_id, $term->taxonomy);
+	}
+
+		/**
+	 * Handle user meta updates
+	 */
+	public static function onUserMetaUpdate(int $meta_id, int $user_id, string $meta_key, mixed $meta_value): void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$user = get_userdata($user_id);
+		if (!$user) {
+			return;
+		}
+
+		self::onUserUpdate($user_id, null);
+	}
+
+	public static function onUserMetaDelete(array $meta_ids, int $user_id, string $meta_key, mixed $meta_value):void
+	{
+		if (!str_starts_with($meta_key, BASE)) {
+			return;
+		}
+
+		$user = get_userdata($user_id);
+		if (!$user) {
+			return;
+		}
+
+		self::onUserUpdate($user_id, null);
 	}
 }

--
Gitblit v1.10.0