From 42fa8304ddb811b0f725f245130f70c0f5e86a6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 04 Nov 2025 06:12:02 +0000
Subject: [PATCH] =Refactored LoginManager to be more extensible and configurable, as well as an AjaxRateLimiter

---
 inc/managers/CacheManager.php |  747 +++++++++++++++++++++++++++++++++++++++-----------------
 1 files changed, 519 insertions(+), 228 deletions(-)

diff --git a/inc/managers/CacheManager.php b/inc/managers/CacheManager.php
index e6530da..c8a38b9 100644
--- a/inc/managers/CacheManager.php
+++ b/inc/managers/CacheManager.php
@@ -2,33 +2,230 @@
 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 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 array $relationships = []; // Type => [related types]
+	private static bool $relationships_loaded = false;
 
 	/**
-	 * @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();
 		}
 	}
 
 	/**
+	 * 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
@@ -37,39 +234,10 @@
 	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));
-		}
-
-		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 wp_cache_get($cache_key, $group);
 	}
 
 	/**
@@ -84,23 +252,13 @@
 	{
 		$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);
-		}
+		// Update timestamp when setting new data
+		self::updateTimestamp($this->group);
+
+		return wp_cache_set($cache_key, $value, $group, $ttl);
 	}
 
 	/**
@@ -112,147 +270,28 @@
 	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));
-		}
-	}
-
-	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;
-			}
-		} catch (\Exception $e) {
-
-		} finally {
-			return false;
-		}
+		return wp_cache_delete($cache_key, $group);
 	}
 
 	/**
-	 * 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
-	 */
-	public function invalidate(string $key, ?string $group = null): void
-	{
-		$this->delete($key, $group);
-	}
-
-	/**
-	 * Clear all cache entries for a group
-	 * @param string $group The group to clear
+	 * Clear all cache for this group
 	 * @return bool
 	 */
-	public static function invalidateGroup(string $group): bool
+	public function clear(): bool
 	{
-		$group = jvbNoBase($group);
-
-		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
+		try {
 			if (function_exists('wp_cache_flush_group')) {
 				wp_cache_flush_group($this->group);
-				return $count;
+				self::updateTimestamp($this->group);
+				return true;
 			}
-		} 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 false;
+		} catch (\Exception $e) {
+			return false;
 		}
-
-		return $count;
 	}
 
 	/**
@@ -289,22 +328,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 +353,315 @@
 	}
 
 	/**
-	 * 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);
+	// ===== 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]
+					));
+			}
 		}
 
-		return $full_key;
+		// 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]
+					));
+			}
+		}
 	}
 
 	/**
-	 * Get transient prefix for a group
+	 * Load relationships from JVB_CONTENT and JVB_TAXONOMY
 	 */
-	private static function getTransientPrefix(string $group): string
+	private static function loadRelationships(): void
 	{
-		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
+		if (self::$relationships_loaded) {
+			return;
 		}
 
-		global $wpdb;
+		// Load post type relationships
+		if (defined('JVB_CONTENT')) {
+			foreach (JVB_CONTENT as $slug => $config) {
+				$relationships = [];
 
-		// 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";
+				// Author relationship
+				if (!($config['no_author'] ?? false)) {
+					$relationships['author'] = true;
+				}
 
-		return $wpdb->query($wpdb->prepare($sql, time()));
+				// 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 [];
 	}
 }

--
Gitblit v1.10.0