Jake Vanderwerf
2026-08-30 f8ffc3dc1748b9af63123eb281696c0c30448715
inc/meta/Meta.php
@@ -1,423 +1,521 @@
<?php
namespace JVBase\meta;
use JVBase\managers\Cache;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Term;
use WP_User;
if (!defined('ABSPATH')) {
   exit;
}
class Meta
{
class Meta {
   /**
    * @var string post, term, user, or options
    */
   protected string $type;
   protected string $objectType;
   /**
    * @var string the full slug, with BASE
    * @var string|null post type, user role, or taxonomy
    */
   protected string $slug;
   protected string $contentType;
   protected Item $item;
   protected Storage $storage;
   protected Validator $validator;
   protected Sanitizer $sanitizer;
   protected array $fields;
   protected WP_Post|WP_Term|WP_User|null $wpObject;
   protected ?string $content;
   /**
    * @var int|string <int>post, term, or user ID OR {string} option key base
    */
   protected int|string $ID;
   protected MetaTypeManager $typeManager;
   protected static array $instances = ['post' => [],'term' => [], 'user'=>[],'options'=>[]];
   protected array $defaults = ['post_thumbnail'];
   /**
    * Create Meta instance for a post
    * @var array of Registrar's defined fields, if any
    */
   public static function forPost(int $id): self
   {
      if (array_key_exists($id, self::$instances['post'])) {
         return self::$instances['post'][$id];
      }
      $new = new self($id, 'post');
      self::$instances['post'][$id] = $new;
      return $new;
   }
   protected static array $registrars = [];
   /**
    * Create Meta instance for a term
    * @var array Field values, stored in memory
    */
   public static function forTerm(int $id): self
   {
      if (array_key_exists($id, self::$instances['term'])) {
         return self::$instances['term'][$id];
      }
      $new = new self($id, 'term');
      self::$instances['term'][$id] = $new;
      return $new;
   }
   protected array $fields;
   /**
    * Create Meta instance for a user
    */
   public static function forUser(int $id): self
   {
      if (array_key_exists($id, self::$instances['user'])) {
         return self::$instances['user'][$id];
      }
      $new = new self($id, 'user');
      self::$instances['user'][$id] = $new;
      return $new;
   }
   protected ?\WC_Product $product = null;
   /**
    * Create Meta instance for options
    */
   public static function forOptions(?string $baseKey = 'ajv'): self
   {
      if (array_key_exists($baseKey, self::$instances['options'])) {
         return self::$instances['options'][$baseKey];
      }
      $new = new self($baseKey, 'options');
      self::$instances['options'][$baseKey] = $new;
      return $new;
   }
   /***************************************************************
    * Constructor
   ***************************************************************/
   protected static Validator $validator;
   protected static Sanitizer $sanitizer;
   public function __construct(int|string|null $id, string $type)
   {
      $this->validator = new Validator();
      $this->sanitizer = new Sanitizer();
      $this->typeManager = new MetaTypeManager();
      $this->type = $type;
      $this->ID = $id;
      $this->buildData($id, $type);
   }
   protected static array $coreFields = [
      'post' => ['post_title', 'post_content', 'post_excerpt', 'post_status', 'post_date', 'post_modified', 'post_thumbnail', 'post_name'],
      'term' => ['name', 'description', 'parent'],
      'user' => ['first_name', 'last_name', 'user_email', 'display_name', 'user_login'],
      'options' => []
   ];
   protected function buildData(int|string|null $id, string $type):void
   {
      $this->wpObject = match($type) {
         'post'   => get_post($id),
         'term'   => get_term($id),
         'user', 'integrations' => get_userdata($id),
         default => null
      };
      $this->slug = match($type) {
         'post'   => $this->wpObject->post_type,
         'term'   => $this->wpObject->taxonomy,
         'user'   => jvbUserRole($id),
         default => null
      };
   protected static array $instances = [
      'post'   => [],
      'term'   => [],
      'user'   => [],
      'options'=> [],
   ];
   private function __construct(string $objectType, int|string $ID) {
      $this->objectType = $objectType;
      $this->ID = $ID;
      $this->content = $this->resolveBasedType();
      $registrar = Registrar::getInstance($this->slug);
      $fields = $registrar ? $registrar->getFields() : [];
      $meta = match($type) {
         'post'   => get_post_meta($id),
         'term'   => get_term_meta($id),
         'user'   => get_user_meta($id),
         default => []
      };
      $meta = array_map(fn($value) => maybe_unserialize($value[0]), $meta);
      foreach ($fields as $fieldName => $config) {
         $fieldName = jvbNoBase($fieldName);
         if ($this->wpObject && property_exists($this->wpObject, $fieldName)) {
            $config['wp'] = true;
            $value = $this->wpObject->$fieldName;
         } else if (in_array($fieldName, $this->defaults)) {
            $config['wp'] = true;
            switch ($fieldName) {
               case 'post_thumbnail':
                  $value = get_post_thumbnail_id($this->ID);
                  break;
            }
         } else {
            $value = array_key_exists(BASE.$fieldName, $meta) ? $meta[BASE.$fieldName] : $config['default']??'';
            switch ($config['type']) {
               case 'taxonomy':
                  if (!$config['isReference']??true) {
                     $config['wp'] = true;
                     $value = implode(',', wp_get_post_terms($this->ID, jvbCheckBase($config['taxonomy']), ['fields' => 'ids']));
                  }
                  break;
               case 'selector':
                  if (array_key_exists('subtype', $config) && $config['subtype'] === 'taxonomy' && !$config['isReference']??true) {
                     $config['wp'] = true;
                     $value = implode(',',wp_get_post_terms($this->ID, jvbCheckBase($config['taxonomy']), ['fields' => 'ids']));
                  }
                  break;
            }
         }
         $this->fields[$fieldName] = new Field($fieldName, $value, $config);
      }
   }
   // ─────────────────────────────────────────────────────────────
   // Magic Methods for Fluent Access
   // ─────────────────────────────────────────────────────────────
   public function __get(string $name): mixed
   {
      return $this->get($name);
   }
   public function __set(string $name, mixed $value): void
   {
      $this->set($name, $value);
   }
   public function __isset(string $name): bool
   {
      return $this->item->hasField($name);
   }
   // ─────────────────────────────────────────────────────────────
   // Core API
   // ─────────────────────────────────────────────────────────────
   /**
    * Get a field value
    */
   public function get(string $name): mixed
   {
      // Handle repeater subfield path
      if (str_contains($name, ':')) {
         return $this->getByPath($name);
      }
      return $this->fields[$name]->get();
   }
   /**
    * Set a field value (validates & sanitizes by default)
    */
   public function set(string $name, mixed $value, $autosave = true): self
   {
      // Handle repeater subfield path (e.g., "services:2:image")
      if (str_contains($name, ':')) {
         return $this->setByPath($name, $value);
      }
      $field = $this->fields[$name]??false;
      if (!$field) {
         error_log('No config found for field: '.$name);
         return $this;
      }
      // Validate
      if (!$this->validator->validate($value, $field->config)) {
         $field->addError("Validation failed for {$name}");
         return $this;
      }
      // Sanitize
      $value = $this->sanitizer->sanitize($value, $field->config);
      $field->set($value);
      if ($autosave && $field->isDirty) {
         $this->save();
      }
      return $this;
   }
   /**
    * Get multiple fields
    */
   public function getAll(array $fields = []): array
   {
      if (empty($fields) || $fields === ['all']) {
         $fields = array_keys($this->fields);
      }
      $fields = array_filter($this->fields, function ($field) use ($fields) {
         return in_array($field, $fields);
      }, ARRAY_FILTER_USE_KEY);
      return array_map(function ($field) {
         return $field->value;
      }, $fields);
   }
   /**
    * Set multiple fields
    */
   public function setAll(array $data):bool
   {
      foreach ($data as $name => $value) {
         error_log('Setting '.$name.' with value: '.print_r($value, true));
         $this->set($name, $value, false);
      }
      return $this->save();
   }
   /**
    * Save all dirty fields to database
    */
   public function save(): bool
   {
      $dirtyFields = array_filter($this->fields, function ($field) {
         return $field->isDirty;
      });
      $defaults = array_filter($dirtyFields, function ($field) {
         return $field->isDefault;
      });
      $custom = array_filter($dirtyFields, function($field) {
         return !$field->isDefault;
      });
      $success = true;
      if (!empty($defaults)) {
         $result = false;
         switch ($this->type) {
            case 'post':
               //Deal with field exceptions, first, that cannot be set via wp_update_post
               foreach ($defaults as $fieldName => $field) {
                  switch ($fieldName) {
                     case 'post_thumbnail':
                        $result = set_post_thumbnail($this->ID, $field->value);
                        unset($defaults[$fieldName]);
                        break;
                  }
                  //If it's a taxonomy, we use wp_set_object_terms
                  if (
                     ($field->config['type'] === 'selector' && $field->config['subtype'] === 'taxonomy')
                     || $field->config['type'] === 'taxonomy') {
                     $result = wp_set_object_terms($this->ID, array_map('absint', explode(',', $field->value)), jvbCheckBase($field->config['taxonomy']));
                     unset($defaults[$fieldName]);
                  }
               }
               if (!empty($defaults)) {
                  error_log('Remaining fields: '.print_r($defaults, true));
                  $defaults = array_map(function ($field) {
                     return $field->value;
                  }, $defaults);
                  error_log('Remaining values to save: '.print_r($defaults, true));
                  $data = array_merge([
                     'post_type' => $this->slug,
                     'ID'     => $this->ID
                  ], $defaults);
                  error_log('Updating post: '.print_r($data, true));
                  $result = wp_update_post($data);
               }
               break;
            case 'term':
               $result = wp_update_term($this->ID, $this->slug, $defaults);
               break;
            case 'user':
               $data = array_merge([
                  'ID'  => $this->ID
               ], $defaults);
               $result = wp_update_user($data);
               break;
         }
         if (!$result || is_wp_error($result)) {
            $success = false;
      if ($this->content && !array_key_exists($this->content, self::$registrars)) {
         $registrar = Registrar::getInstance($this->content);
         if ($registrar) {
            self::$registrars[$this->content] = $registrar->getFields();
         }
      }
      if (!empty($custom)) {
         $function = match ($this->type) {
            'post' => 'update_post_meta',
            'term' => 'update_term_meta',
            'user' => 'update_user_meta',
            'options', 'option' => 'update_option',
            default => false,
      if ($this->objectType === 'post' && function_exists('wc_get_product') && in_array($this->content, ['product', 'product_variation'], true)) {
         $this->product = wc_get_product($this->ID) ?: null;
      }
      $this->buildData();
      if (!isset(self::$sanitizer)) {
         self::$sanitizer = new Sanitizer();
      }
      if (!isset(self::$validator)) {
         self::$validator = new Validator();
      }
   }
      protected function buildData():void
      {
         //Get all Item meta
         $this->fields = match ($this->objectType) {
            'post'   => get_post_meta($this->ID),
            'term'   => get_term_meta($this->ID),
            'user'   => get_user_meta($this->ID),
            default  => []
         };
         if (!$function) {
            error_log('[Meta]::save() Invalid type, cannot save: '.$this->type);
         $this->fields = array_combine(
            array_map('jvbNoBase', array_keys($this->fields)),
            $this->fields
         );
         //Add in core fields
         $obj = match ($this->objectType) {
            'post'   => get_post($this->ID),
            'term'   => get_term($this->ID, $this->content),
            'user'   => get_userdata($this->ID),
            default => false
         };
         if ($obj) {
            foreach (self::$coreFields[$this->objectType] as $name) {
               $value =  match ($name) {
                  'post_thumbnail'  => get_post_thumbnail_id($this->ID),
                  default  => $obj->$name??false
               };
//             if (!$value) {
//                $this->log('Could not find value for '.$name.', of '.$this->content);
//             }
               $this->fields[$name] = $value??'';
            }
         }
      }
   public static function forPost(int $ID):self
   {
      if (array_key_exists($ID, self::$instances['post'])) {
         return self::$instances['post'][$ID];
      }
      self::$instances['post'][$ID] = new self('post', $ID);
      return self::$instances['post'][$ID];
   }
   public static function forTerm(int $ID):self
   {
      if (array_key_exists($ID, self::$instances['term'])) {
         return self::$instances['term'][$ID];
      }
      self::$instances['term'][$ID] = new self('term', $ID);
      return self::$instances['term'][$ID];
   }
   public static function forUser(int $ID):self
   {
      if (array_key_exists($ID, self::$instances['user'])) {
         return self::$instances['user'][$ID];
      }
      self::$instances['user'][$ID] = new self('user', $ID);
      return self::$instances['user'][$ID];
   }
   public static function forOptions(string $base = 'default'):self
   {
      if (array_key_exists($base, self::$instances['options'])) {
         return self::$instances['options'][$base];
      }
      self::$instances['options'][$base] = new self('options', $base);
      return self::$instances['options'][$base];
   }
   protected function resolveBasedType(): ?string {
      return match ($this->objectType) {
         'post' => get_post_type($this->ID) ?: null,
         'term' => ($term = get_term($this->ID)) && !is_wp_error($term) ? $term->taxonomy : null,
         'user' => jvbUserRole($this->ID),
         default => null
      };
   }
   /***************************************************************************
    * GETTING FIELDS
   ***************************************************************************/
   public function get(string $name): mixed {
      if (array_key_exists($name, $this->fields)) {
         return $this->fields[$name];
      }
      if ($this->product && $this->isProductField($name)) {
         $value = $this->getProductField($name);
      } else if (in_array($name, static::$coreFields[$this->objectType], true)) {
         $value = $this->getCoreField($name);
      } else {
         $value = $this->getMetaField($name);
      }
      $this->fields[$name] = $value;
      return $value;
   }
   public function getAll(?array $names = null): array {
      $names = $names ?? $this->getAllFieldNames();
      $out = [];
      foreach ($names as $name) {
         $out[$name] = $this->get($name);
      }
      return $out;
   }
   protected function getCoreField(string $name): mixed {
      return match ($this->objectType) {
         'post' => $name === 'post_thumbnail'
            ? get_post_thumbnail_id($this->ID)
            : (($p = get_post($this->ID)) ? ($p->$name ?? null) : null),
         'term' => ($t = get_term($this->ID, $this->content)) && !is_wp_error($t) ? ($t->$name ?? null) : null,
         'user' => ($u = get_userdata($this->ID)) ? ($u->$name ?? null) : null,
      };
   }
   protected function getMetaField(string $name): mixed {
      $key = $this->metaKey($name);
      return match ($this->objectType) {
         'post' => get_post_meta($this->ID, $key, true),
         'term' => get_term_meta($this->ID, $key, true),
         'user' => get_user_meta($this->ID, $key, true),
         'options' => get_option($key)
      };
   }
   /***************************************************************************
    * SETTING FIELDS
   ***************************************************************************/
   public function set(string $name, mixed $value): bool {
      $field = $this->getFieldConfig($name);
      if ($field) {
         if ($field['type']??'' === 'repeater' && str_contains($name, ':')) {
            return $this->setByPath($name, $value);
         } elseif ($field['type']??'' === 'group' && str_contains($name, '|')) {
            return $this->setGroup($name, $value);
         }
         if (!self::$validator->validate($value, $field)) {
            return false;
         }
         foreach ($custom as $field) {
            $result = $function($this->ID, BASE.$field->name, $field->value);
            if (!$result) {
               error_log('Problem saving field: '.$field->name.' with value: '.print_r($field->value, true));
            }
         }
         if ($this->type === 'term' && Registrar::getInstance($this->slug)->hasFeature('is_content')) {
            update_term_meta($this->ID, BASE.'date_modified', date('Y-m-d H:i:s'));
         }
         $value = self::$sanitizer->sanitize($value, $field);
      }
      return $success;
      if ($this->product && $this->isProductField($name)) {
         return $this->setProductField($name, $value);
      }
      if (in_array($name, static::$coreFields[$this->objectType], true)) {
         return $this->setCoreField($name, $value);
      }
      return $this->setMetaField($name, $value);
   }
   /**
    * Delete a field value
    */
   public function delete(string $name): bool
   {
      $field = $this->fields[$name]??false;
      if (!$field) {
         error_log('[Meta]::delete Could not delete field '.$name.': not registered');
         return true;
      }
      if ($field->isTaxonomy()) {
         wp_set_object_terms($this->ID, [], $this->slug);
         return true;
      }
      return match ($this->type) {
         'post'   => delete_post_meta($this->ID, BASE.$name),
         'term'   => delete_term_meta($this->ID, BASE.$name),
         'user', 'integrations' => delete_user_meta($this->ID, BASE.$name),
         'options'   => delete_option(BASE.$name),
         default => false
      };
   }
   /**
    * Delete multiple field values
    */
   public function deleteAll(array $names): array
   {
   public function setAll(array $data): array {
      $core = array_filter($data, function ($name) {
         return in_array($name, self::$coreFields[$this->objectType]);
      }, ARRAY_FILTER_USE_KEY);
      $results = [];
      foreach ($names as $name) {
         $results[$name] = $this->delete($name);
      if (!empty($core)) {
         $data = array_filter($data, function ($name) {
            return !in_array($name, self::$coreFields[$this->objectType]);
         }, ARRAY_FILTER_USE_KEY);
         $results = $this->setAllCoreFields($core);
      }
      foreach ($data as $name => $value) {
         $results[$name] = $this->set($name, $value);
      }
      return $results;
   }
   /*****************************************************************
    * Repeater Access
   *****************************************************************/
   protected function setAllCoreFields(array $data):array
   {
      $results = [];
   /**
    * Get repeater accessor for fluent repeater operations
    */
   public function repeater(string $name): Repeater
      $core = array_filter($data, function ($name) {
         return in_array($name, self::$coreFields[$this->objectType]);
      }, ARRAY_FILTER_USE_KEY);
      if (count($core) !== count($data)) {
         $notCore = array_filter($data, function ($name) {
            return !in_array($name, self::$coreFields[$this->objectType]);
         }, ARRAY_FILTER_USE_KEY);
         $results = $this->setAll($notCore);
      }
      if (array_key_exists('post_thumbnail', $core)) {
         $results['post_thumbnail'] = $this->setCoreField('post_thumbnail', $core['post_thumbnail']);
         unset ($core['post_thumbnail']);
      }
      $return = match ($this->objectType) {
         'post'   => !is_wp_error(wp_update_post(array_merge(['ID' => $this->ID], $core))),
         'term'   => !is_wp_error(wp_update_term($this->ID, $this->content, $core)),
         'user'   => !is_wp_error(wp_update_user(array_merge(['ID' => $this->ID], $core))),
      };
      foreach ($core as $name => $value) {
         $results[$name] = $return;
      }
      return $results;
   }
   protected function setCoreField(string $name, mixed $value): bool {
      $success = match ($this->objectType) {
         'post' => $name === 'post_thumbnail'
            ? (!empty($value) ? set_post_thumbnail($this->ID, (int)$value) : delete_post_thumbnail($this->ID))
            : !is_wp_error(wp_update_post(['ID' => $this->ID, $name => $value])),
         'term' => !is_wp_error(wp_update_term($this->ID, $this->content, [$name => $value])),
         'user' => !is_wp_error(wp_update_user(['ID' => $this->ID, $name => $value])),
         default => false
      };
      if ($success) {
         $this->clearCache();
         $this->fields[$name] = $value;
      }
      return $success;
   }
   protected function setMetaField(string $name, mixed $value): bool {
      $key = $this->metaKey($name);
      $result = match ($this->objectType) {
         'post' => update_post_meta($this->ID, $key, $value),
         'term' => update_term_meta($this->ID, $key, $value),
         'user' => update_user_meta($this->ID, $key, $value),
         'options' => update_option($key, $value)
      };
      if ($result) {
         $this->fields[$name] = $value;
         $this->clearCache();
      }
      return $result !== false;
   }
   /********************************************************************************
    * DELETING FIELDS
   ********************************************************************************/
   public function delete(string $name):bool
   {
      if (in_array($name, static::$coreFields[$this->objectType])) {
         return $this->deleteCoreField($name);
      }
      $field = $this->getFieldConfig($name);
      if ($field) {
         if ($this->isTaxonomy($field)) {
            return !is_wp_error(wp_set_object_terms($this->ID, [], $this->content));
         }
      }
      $key = $this->metaKey($name);
      return match ($this->objectType) {
         'post'   => delete_post_meta($this->ID, $key),
         'term'   => delete_term_meta($this->ID, $key),
         'user'   => delete_user_meta($this->ID, $key),
         'option'=> delete_option($key)
      };
   }
   public function deleteAll(array $fieldNames):array
   {
      $core = array_filter($fieldNames, function ($name) {
         return in_array($name, self::$coreFields[$this->objectType]);
      }, ARRAY_FILTER_USE_KEY);
      $results = [];
      if (!empty($core)) {
         $fieldNames = array_filter($fieldNames, function ($name) {
            return !in_array($name, self::$coreFields[$this->objectType]);
         });
         $results = $this->deleteAllCoreFields($core);
      }
      foreach ($fieldNames as $name) {
         $results[$name] = $this->delete($name);
      }
      return $results;
   }
   protected function deleteAllCoreFields(array $fieldNames):array
   {
      $results = [];
      $core = array_filter($fieldNames, function ($name) {
         return in_array($name, self::$coreFields[$this->objectType]);
      }, ARRAY_FILTER_USE_KEY);
      if (count($core) !== count($fieldNames)) {
         $notCore = array_filter($fieldNames, function ($name) {
            return !in_array($name, self::$coreFields[$this->objectType]);
         }, ARRAY_FILTER_USE_KEY);
         $results = $this->deleteAll($notCore);
      }
      if (array_key_exists('post_thumbnail', $fieldNames)) {
         $results['post_thumbnail'] = delete_post_thumbnail($this->ID);
         unset($fieldNames['post_thumbnail']);
         unset($core['post_thumbnail']);
      }
      $updated = [];
      foreach ($core as $name) {
         $updated[$name] = $this->getCoreDefault($name);
      }
      $success = match ($this->objectType) {
         'post'   => !is_wp_error(wp_update_post(array_merge(['ID' => $this->ID], $updated))),
         'term'   => !is_wp_error(wp_update_term($this->ID, $this->content, $updated)),
         'user'   => !is_wp_error(wp_update_user(array_merge(['ID' => $this->ID], $updated)))
      };
      foreach ($core as $name) {
         $results[$name] = $success;
      }
      return $results;
   }
   protected function deleteCoreField(string $name):bool
   {
      if ($name === 'post_thumbnail') {
         return delete_post_thumbnail($this->ID);
      }
      $default = $this->getCoreDefault($name);
      return $this->setCoreField($name, $default);
   }
   /**************************************************************
    * WOOCOMMERCE PRODUCT SPECIFIC
   **************************************************************/
   protected function isProductField(string $name): bool {
      return method_exists($this->product, 'get_'.$name) || method_exists($this->product, 'set_'.$name);
   }
   protected function getProductField(string $name): mixed {
      $method = 'get_'.$name;
      if ($name === 'variations') {
         return $this->getVariations();
      }
      return method_exists($this->product, $method) ? $this->product->$method() : null;
   }
      protected function getVariations():array
      {
         $variations = $this->product->get_children();
         $out = [];
         foreach ($variations as $v) {
            $out[] = self::forPost($v)->getAll();
         }
         return $out;
      }
   protected function setProductField(string $name, mixed $value): bool {
      $method = 'set_'.$name;
      if (!method_exists($this->product, $method)) return false;
      $this->product->$method($value);
      $this->product->save();
      $this->fields[$name] = $value;
      $this->clearCache();
      return true;
   }
   protected function getAllProductFieldNames(): array
   {
      return [
         'price',
         'sku',
         'sale',
         'short_description',
         'image_id',
         'gallery_image_ids',
         'stock',
         'stock_quantity',
         'stock_status',
         'variations'
      ];
   }
   /*** Sanitization & Validation ***/
// protected function sanitize(mixed $value, array $field): mixed {
//    return match ($field['type']) {
//       'text', 'tel' => sanitize_text_field($value),
//       'email' => sanitize_email($value),
//       'url' => esc_url_raw($value),
//       'textarea' => !empty($field['quill']) ? wp_kses_post($value) : sanitize_textarea_field($value),
//       'number' => is_numeric($value) ? $value + 0 : 0,
//       'true_false' => (bool)$value,
//       'datetime' => sanitize_text_field($value),
//       'upload', 'image', 'gallery' => !empty($field['multiple']) ? array_map('intval', (array)$value) : (int)$value,
//       'checkbox', 'set' => array_map('sanitize_text_field', (array)$value),
//       'radio', 'select' => sanitize_text_field($value),
//       'selector', 'taxonomy', 'user', 'post' => array_map('intval', (array)$value),
//       'repeater', 'tagList' => $this->sanitizeRepeater($value, $field),
//       'group' => $this->sanitizeGroup($value, $field),
//       default => is_array($value) ? array_map('sanitize_text_field', $value) : sanitize_text_field($value),
//    };
// }
// protected function validate(string $name, mixed $value, array $field): bool|string {
//    if (!empty($field['required']) && ($value === '' || $value === null)) {
//       return $name.' is required';
//    }
//    if (isset($field['maxLength']) && is_string($value) && strlen($value) > $field['maxLength']) {
//       return $name.' exceeds max length of '.$field['maxLength'];
//    }
//    if (isset($field['min']) && is_numeric($value) && $value < $field['min']) {
//       return $name.' is below minimum of '.$field['min'];
//    }
//    if (isset($field['max']) && is_numeric($value) && $value > $field['max']) {
//       return $name.' exceeds maximum of '.$field['max'];
//    }
//    if (in_array($field['type'], ['checkbox', 'set', 'radio', 'select'], true) && !empty($field['options'])) {
//       foreach ((array)$value as $v) {
//          if (!array_key_exists($v, $field['options'])) {
//             return $name.': invalid option '.$v;
//          }
//       }
//    }
//    return true;
// }
   /************************************************************************
    * REPEATER Helpers
   ************************************************************************/
   public function repeater(string $name):Repeater
   {
      return new Repeater($this, $name);
   }
   protected function setByPath(string $path, mixed $value): self
   protected function setByPath(string $path, mixed $value):bool
   {
      error_log('Setting by path: '.$path.', with value: '.print_r($value, true));
      $parts = explode(':', $path, 3);
      if (count($parts) !== 3) {
         return $this;
         $this->log('Could not set value by path');
         return false;
      }
      [$repeaterName, $rowIndex, $subField] = $parts;
      $this->repeater($repeaterName)->setField((int) $rowIndex, $subField, $value);
      return $this;
      $this->clearCache();
      return $this->repeater($repeaterName)->setField((int) $rowIndex, $subField, $value);
   }
   protected function getByPath(string $path): mixed
   protected function getByPath(string $path):mixed
   {
      $parts = explode(':', $path, 3);
      if (count($parts) !== 3) {
@@ -427,102 +525,85 @@
      [$repeaterName, $rowIndex, $subField] = $parts;
      return $this->repeater($repeaterName)->field((int) $rowIndex, $subField);
   }
   /****************************************************************************************
    * GROUP helpers
   ****************************************************************************************/
   // ─────────────────────────────────────────────────────────────
   // Utility Methods
   // ─────────────────────────────────────────────────────────────
   /**
    * Discard all unsaved changes
    */
   public function reset(): self
   public function group(string $name): Group
   {
      $this->item->resetAll();
      return $this;
      return new Group($this, $name);
   }
   /**
    * Get field configuration
    */
   public function config(string $name): ?array
   protected function setGroup(string $name, mixed $value):bool
   {
      return $this->fields[$name]->config??null;
      [$parent, $child] = explode('|', $name, 2);
      $this->clearCache();
      return $this->group($parent)->set($child, $value);
   }
   /**
    * Get item ID
    */
   public function id(): int|string|null
   /***************************************************************************************
    * UTILITY
   ***************************************************************************************/
   protected function clearCache():void
   {
      return $this->ID;
      match ($this->objectType) {
         'post'   => Cache::clearPost($this->ID),
         'term'   => Cache::clearTerm($this->ID),
         'user'   => Cache::clearUser($this->ID),
      };
   }
   protected function isTaxonomy(array $field):bool
   {
      $type = $field['type']??'';
      return $type === 'taxonomy' || ($type === 'selector' &&$field['subtype']??'' === 'taxonomy');
   }
   /**
    * Get object type (post, term, user, options)
    */
   public function objectType(): string
   {
      return $this->type;
   }
   /**
    * Get content type (tattoo, artist, etc)
    */
   public function contentType(): ?string
   {
      return $this->contentType;
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers
   // ─────────────────────────────────────────────────────────────
   /**
    * Check for field update overrides
    */
   public function checkOverrides(Field $field): bool
   {
      $name = $field->name;
      $type = $field->type();
      $value = $field->value;
      do_action('jvb_meta_update', $name, $value, $this->item->objectType);
      $overrides = [
         BASE . 'update_' . $name,
         BASE . 'update_' . $type,
         'jvb_update_' . $name,
         'jvb_update_' . $type,
      ];
      foreach ($overrides as $override) {
         if (function_exists($override)) {
            $override($this->item->id, $value);
            return true;
         }
   protected function getAllFieldNames(): array {
      $names = static::$coreFields[$this->objectType];
      if ($this->hasRegistrar()) {
         $names = array_merge($names, array_keys(self::$registrars[$this->content]));
      }
      return false;
      if ($this->product) {
         $names = array_merge($names, $this->getAllProductFieldNames());
      }
      return array_unique($names);
   }
   /**
    * Get default value for a field type
    */
   protected function getDefaultValue(string $name): mixed
   protected function hasRegistrar():bool
   {
      $config = $this->item->getFieldConfig($name);
      $type = $config['type'] ?? 'text';
      return array_key_exists($this->content, self::$registrars);
   }
      return match ($this->typeManager->getMetaType($type)) {
         'object', 'array' => [],
         'boolean' => false,
         'integer' => 0,
         default => '',
   public function getFieldConfig(string $name): ?array {
      if (!$this->hasRegistrar()) return null;
      return self::$registrars[$this->content][$name] ?? null;
   }
   protected function getCoreDefault(string $name):string
   {
      return match($name) {
         'post_status'           => 'draft',
         'post_date','post_modified'   => date('mysql'),
         'post_name' => sanitize_title($this->get('post_title')),
         default => ''
      };
   }
   protected function metaKey(string $name): string {
      if ($this->product) {
         if (in_array($name, $this->getAllProductFieldNames())) {
            return $name;
         }
      }
      return jvbCheckBase($name);
   }
   protected function log(string $message, array $context = []):void
   {
      $caller = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['function'] ?? '';
      error_log('[Meta] '.$caller.' '.$message);
      if (!empty($context)) {
         error_log('    CONTEXT: '.print_r($context, true));
      }
   }
}