Jake Vanderwerf
2026-05-11 ac444cba221832c012c0435fdc8339fe9f37febb
inc/meta/Meta.php
@@ -1,105 +1,163 @@
<?php
namespace JVBase\meta;
use Exception;
use JVBase\registrar\Registrar;
use WP_Post;
use WP_Term;
use WP_User;
if (!defined('ABSPATH')) {
   exit;
}
/**
 * Main facade for meta operations
 * Fluent API for getting/setting meta values with validation & sanitization
 */
class Meta
{
   /**
    * @var string post, term, user, or options
    */
   protected string $type;
   /**
    * @var string the full slug, with BASE
    */
   protected string $slug;
   protected string $contentType;
   protected Item $item;
   protected Storage $storage;
   protected MetaValidator $validator;
   protected MetaSanitizer $sanitizer;
   protected Validator $validator;
   protected Sanitizer $sanitizer;
   protected array $fields;
   protected WP_Post|WP_Term|WP_User|null $wpObject;
   protected int|string $ID;
   protected MetaTypeManager $typeManager;
   protected static array $instances = ['post' => [],'term' => [], 'user'=>[],'options'=>[]];
   protected bool $autoValidate = true;
   protected bool $autoSanitize = true;
   // ─────────────────────────────────────────────────────────────
   // Factory Methods
   // ─────────────────────────────────────────────────────────────
   protected array $defaults = ['post_thumbnail'];
   /**
    * Create Meta instance for a post
    */
   public static function forPost(int $id): self
   {
      return new self($id, 'post');
      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;
   }
   /**
    * Create Meta instance for a term
    */
   public static function forTerm(int $id): self
   {
      return new self($id, 'term');
      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;
   }
   /**
    * Create Meta instance for a user
    */
   public static function forUser(int $id): self
   {
      return new self($id, 'user');
      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;
   }
   public static function forOptions(?string $baseKey = null): self
   /**
    * Create Meta instance for options
    */
   public static function forOptions(?string $baseKey = 'ajv'): self
   {
      $instance = new self($baseKey, 'options');
      $instance->item->baseKey = $baseKey;
      return $instance;
      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
   // ─────────────────────────────────────────────────────────────
   /***************************************************************
    * Constructor
   ***************************************************************/
   public function __construct(int|string|null $id, string $type)
   {
      $this->storage = new Storage();
      $this->validator = new MetaValidator();
      $this->sanitizer = new MetaSanitizer();
      $this->validator = new Validator();
      $this->sanitizer = new Sanitizer();
      $this->typeManager = new MetaTypeManager();
      $this->item = $this->buildItem($id, $type);
      $this->type = $type;
      $this->ID = $id;
      $this->buildData($id, $type);
   }
   protected function buildItem(int|string|null $id, string $type): Item
   protected function buildData(int|string|null $id, string $type):void
   {
      $contentType = null;
      $wpObject = null;
      $this->wpObject = match($type) {
         'post'   => get_post($id),
         'term'   => get_term($id),
         'user', 'integrations' => get_userdata($id),
         default => false
      };
      if ($id && $type !== 'options') {
         [$wpObject, $contentType] = match ($type) {
            'post' => [get_post($id), jvbNoBase(get_post_type($id))],
            'term' => [get_term($id), jvbNoBase(get_term($id)->taxonomy)],
            'user', 'integrations' => [get_user_by('id', $id), jvbUserRole($id)],
            default => [null, null]
         };
      }
      $this->slug = match($type) {
         'post'   => $this->wpObject->post_type,
         'term'   => $this->wpObject->taxonomy,
         'user'   => jvbUserRole($id),
         default => null
      };
      $item = new Item($id, $type, $contentType);
      $item->wpObject = $wpObject;
      $item->fieldConfigs = $this->loadFieldConfigs($contentType, $type);
      // Mark WP defaults in configs
      $defaults = Item::WP_DEFAULTS[$type] ?? [];
      foreach ($defaults as $name) {
         if (!isset($item->fieldConfigs[$name])) {
            $item->fieldConfigs[$name] = ['type' => 'text', '_wp_default' => true];
      $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 {
            $item->fieldConfigs[$name]['_wp_default'] = true;
            $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);
      }
      return $item;
   }
   protected function loadFieldConfigs(?string $contentType, string $objectType): array
   {
      if (!$contentType && $objectType !== 'options') {
         return [];
      }
      return jvbGetFields($contentType ?? 'options', $objectType);
   }
   // ─────────────────────────────────────────────────────────────
@@ -130,59 +188,41 @@
    */
   public function get(string $name): mixed
   {
      // Return from loaded field if exists
      if ($field = $this->item->getField($name)) {
         return $field->get();
      // Handle repeater subfield path
      if (str_contains($name, ':')) {
         return $this->getByPath($name);
      }
      // Load from storage
      $value = $this->storage->get($this->item, $name);
      $config = $this->item->getFieldConfig($name) ?? ['type' => 'text'];
      $field = new Field($name, $value, $config);
      $this->item->setField($field);
      return $value;
      return $this->fields[$name]->get();
   }
   /**
    * Set a field value (validates & sanitizes)
    * Set a field value (validates & sanitizes by default)
    */
   public function set(string $name, mixed $value): self
   public function set(string $name, mixed $value, $autosave = true): self
   {
      $config = $this->item->getFieldConfig($name);
      if (!$config) {
         // Allow setting unknown fields with minimal config
         $config = ['type' => 'text', 'name' => $name];
      // Handle repeater subfield path (e.g., "services:2:image")
      if (str_contains($name, ':')) {
         return $this->setByPath($name, $value);
      }
      $config['name'] = $name;
      $field = $this->fields[$name]??false;
      if (!$field) {
         error_log('No config found for field: '.$name);
         return $this;
      }
      // Validate
      if ($this->autoValidate && !$this->validator->validate($value, $config)) {
         $field = $this->item->getField($name) ?? new Field($name, $value, $config);
      if (!$this->validator->validate($value, $field->config)) {
         $field->addError("Validation failed for {$name}");
         $this->item->setField($field);
         return $this;
      }
      // Sanitize
      if ($this->autoSanitize) {
         $value = $this->sanitizer->sanitize($value, $config);
      }
      // Get or create field
      $field = $this->item->getField($name);
      if ($field) {
         $field->set($value);
      } else {
         // Need to load original to track dirty state
         $original = $this->storage->get($this->item, $name);
         $field = new Field($name, $original, $config);
         $field->set($value);
         $this->item->setField($field);
      $value = $this->sanitizer->sanitize($value, $field->config);
      $field->set($value);
      if ($autosave && $field->isDirty) {
         $this->save();
      }
      return $this;
@@ -194,54 +234,122 @@
   public function getAll(array $fields = []): array
   {
      if (empty($fields) || $fields === ['all']) {
         $fields = array_keys($this->item->fieldConfigs);
         $fields = array_keys($this->fields);
      }
      $fields = array_filter($this->fields, function ($field) use ($fields) {
         return in_array($field, $fields);
      }, ARRAY_FILTER_USE_KEY);
      // Load all from storage
      $values = $this->storage->getAll($this->item, $fields);
      // Create Field instances
      foreach ($values as $name => $value) {
         if (!$this->item->getField($name)) {
            $config = $this->item->getFieldConfig($name) ?? ['type' => 'text'];
            $this->item->setField(new Field($name, $value, $config));
         }
      }
      return $values;
      return array_map(function ($field) {
         return $field->value;
      }, $fields);
   }
   /**
    * Set multiple fields
    */
   public function setAll(array $data): self
   public function setAll(array $data):bool
   {
      foreach ($data as $name => $value) {
         $this->set($name, $value);
         $this->set($name, $value, false);
      }
      return $this;
      return $this->save();
   }
   /**
    * Save all dirty fields to database
    */
   public function save(bool $updateTimestamp = true): bool
   public function save(): bool
   {
      if (!$this->item->isValid()) {
         JVB()->error()->log('meta', 'Cannot save: validation errors exist', [
            'fields' => array_keys($this->item->getInvalidFields())
         ], 'warning');
         return false;
      }
      $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;
      });
      // Check for field overrides before saving
      foreach ($this->item->getDirtyFields() as $field) {
         if ($this->checkOverrides($field)) {
            $field->markClean();
      $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 (!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 (!$function) {
            error_log('[Meta]::save() Invalid type, cannot save: '.$this->type);
            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'));
         }
      }
      return $this->storage->save($this->item, $updateTimestamp);
      return $success;
   }
   /**
@@ -249,117 +357,134 @@
    */
   public function delete(string $name): bool
   {
      $result = $this->storage->delete($this->item, $name);
      if ($result && $field = $this->item->getField($name)) {
         $field->set($this->getDefaultValue($name));
         $field->markClean();
      $field = $this->fields[$name]??false;
      if (!$field) {
         error_log('[Meta]::delete Could not delete field '.$name.': not registered');
         return true;
      }
      return $result;
      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
   {
      $results = [];
      foreach ($names as $name) {
         $results[$name] = $this->delete($name);
      }
      return $results;
   }
   /*****************************************************************
    * Repeater Access
   *****************************************************************/
   /**
    * Get repeater accessor for fluent repeater operations
    */
   public function repeater(string $name): Repeater
   {
      return new Repeater($this, $name);
   }
   protected function setByPath(string $path, mixed $value): self
   {
      error_log('Setting by path: '.$path.', with value: '.print_r($value, true));
      $parts = explode(':', $path, 3);
      if (count($parts) !== 3) {
         return $this;
      }
      [$repeaterName, $rowIndex, $subField] = $parts;
      $this->repeater($repeaterName)->setField((int) $rowIndex, $subField, $value);
      return $this;
   }
   protected function getByPath(string $path): mixed
   {
      $parts = explode(':', $path, 3);
      if (count($parts) !== 3) {
         return null;
      }
      [$repeaterName, $rowIndex, $subField] = $parts;
      return $this->repeater($repeaterName)->field((int) $rowIndex, $subField);
   }
   // ─────────────────────────────────────────────────────────────
   // Utility Methods
   // ─────────────────────────────────────────────────────────────
   /**
    * Get all dirty (changed) fields
    */
   public function getDirty(): array
   {
      return array_map(
         fn(Field $f) => $f->value,
         $this->item->getDirtyFields()
      );
   }
   /**
    * Check if any fields have changed
    */
   public function isDirty(): bool
   {
      return $this->item->hasDirtyFields();
   }
   /**
    * Discard all unsaved changes
    */
   public function reset(): self
   {
      foreach ($this->item->fields as $field) {
         $field->reset();
      }
      $this->item->resetAll();
      return $this;
   }
   /**
    * Get validation errors
    */
   public function getErrors(): array
   {
      $errors = [];
      foreach ($this->item->getInvalidFields() as $name => $field) {
         $errors[$name] = $field->errors;
      }
      return $errors;
   }
   /**
    * Check if valid (no validation errors)
    */
   public function isValid(): bool
   {
      return $this->item->isValid();
   }
   /**
    * Disable auto-validation for bulk operations
    */
   public function withoutValidation(): self
   {
      $this->autoValidate = false;
      return $this;
   }
   /**
    * Disable auto-sanitization
    */
   public function withoutSanitization(): self
   {
      $this->autoSanitize = false;
      return $this;
   }
   /**
    * Get the underlying item (for rendering, etc)
    */
   public function item(): Item
   {
      return $this->item;
   }
   /**
    * Get field configuration
    */
   public function config(string $name): ?array
   {
      return $this->item->getFieldConfig($name);
      return $this->fields[$name]->config??null;
   }
   /**
    * Get item ID
    */
   public function id(): int|string|null
   {
      return $this->ID;
   }
   /**
    * Get all field configurations
    * Get object type (post, term, user, options)
    */
   public function configs(): array
   public function objectType(): string
   {
      return $this->item->fieldConfigs;
      return $this->type;
   }
   /**
    * Get content type (tattoo, artist, etc)
    */
   public function contentType(): ?string
   {
      return $this->contentType;
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers
   // ─────────────────────────────────────────────────────────────
   protected function checkOverrides(Field $field): bool
   /**
    * Check for field update overrides
    */
   public function checkOverrides(Field $field): bool
   {
      $name = $field->name;
      $type = $field->type();
@@ -384,6 +509,9 @@
      return false;
   }
   /**
    * Get default value for a field type
    */
   protected function getDefaultValue(string $name): mixed
   {
      $config = $this->item->getFieldConfig($name);
@@ -396,4 +524,5 @@
         default => '',
      };
   }
}