Jake Vanderwerf
2026-05-13 226b50642af0895948fbaa623a9b7180399a63b6
inc/meta/Meta.php
@@ -1,48 +1,49 @@
<?php
namespace JVBase\meta;
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
 *
 * Usage:
 *   $meta = Meta::forPost($id);
 *   $meta->price = 150;
 *   $meta->save();
 *
 *   Meta::forPost($id)->set('price', 150)->set('style', 'traditional')->save();
 */
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 Validator $validator;
   protected Sanitizer $sanitizer;
   protected array $fields;
   protected WP_Post|WP_Term|WP_User|false|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;
   /** @var array<string, callable[]> */
   protected array $onChangeCallbacks = [];
   /** @var array<string, callable> */
   protected array $computed = [];
   // ─────────────────────────────────────────────────────────────
   // 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;
   }
   /**
@@ -50,7 +51,12 @@
    */
   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;
   }
   /**
@@ -58,194 +64,100 @@
    */
   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;
   }
   /**
    * Create Meta instance for options
    */
   public static function forOptions(?string $baseKey = null): self
   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;
   }
   /**
    * Bulk load multiple posts with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForPosts(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'post', $preloadFields);
   }
   /**
    * Bulk load multiple terms with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForTerms(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'term', $preloadFields);
   }
   /**
    * Bulk load multiple users with optional field preloading
    * @return array<int, Meta>
    */
   public static function bulkForUsers(array $ids, array $preloadFields = []): array
   {
      return self::bulkFor($ids, 'user', $preloadFields);
   }
   /**
    * Generic bulk loader
    * @return array<int, Meta>
    */
   protected static function bulkFor(array $ids, string $type, array $preloadFields = []): array
   {
      if (empty($ids)) {
         return [];
      }
      $metas = [];
      // Create instances
      foreach ($ids as $id) {
         $metas[$id] = new self($id, $type);
      }
      // Preload fields if specified
      if (!empty($preloadFields)) {
         self::bulkPreload($metas, $type, $preloadFields);
      }
      return $metas;
   }
   /**
    * Bulk preload fields for multiple Meta instances
    * @param Meta[] $metas
    */
   protected static function bulkPreload(array $metas, string $objectType, array $fields): void
   {
      if (empty($metas) || empty($fields)) {
         return;
      }
      $ids = array_keys($metas);
      $values = Storage::getBulkValues($ids, $objectType, $fields);
      // Distribute results to Meta instances
      foreach ($values as $id => $fieldValues) {
         if (!isset($metas[$id])) {
            continue;
         }
         $meta = $metas[$id];
         foreach ($fieldValues as $name => $value) {
            $config = $meta->config($name) ?? ['type' => 'text'];
            $field = new Field($name, $value, $config);
            $meta->item()->setField($field);
         }
      }
   }
   /**
    * Save multiple Meta instances efficiently
    * @param Meta[] $metas
    * @return array<int, bool>
    */
   public static function saveBulk(array $metas, bool $updateTimestamp = true): array
   {
      // Validate all first
      $invalid = [];
      foreach ($metas as $id => $meta) {
         if (!$meta->isValid()) {
            $invalid[$id] = $meta->getErrors();
         }
      }
      if (!empty($invalid)) {
         JVB()->error()->log('meta', 'Bulk save has validation errors', [
            'invalid_items' => $invalid
         ], 'warning');
      }
      // Filter to only valid metas
      $validMetas = array_filter($metas, fn($m) => $m->isValid());
      // Check overrides before bulk save
      foreach ($validMetas as $meta) {
         foreach ($meta->item()->getDirtyFields() as $field) {
            if ($meta->checkOverrides($field)) {
               $field->markClean();
            }
         }
      }
      $results = Storage::saveBulk($validMetas, $updateTimestamp);
      // Mark invalid ones as failed
      foreach ($invalid as $id => $errors) {
         $results[$id] = false;
      }
      return $results;
   }
   // ─────────────────────────────────────────────────────────────
   // Constructor
   // ─────────────────────────────────────────────────────────────
   /***************************************************************
    * Constructor
   ***************************************************************/
   public function __construct(int|string|null $id, string $type)
   {
      $this->storage = new Storage();
      $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 = !is_null($this->slug) ? Registrar::getInstance($this->slug) : false;
      $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);
   }
   // ─────────────────────────────────────────────────────────────
@@ -264,7 +176,7 @@
   public function __isset(string $name): bool
   {
      return $this->item->hasField($name) || isset($this->computed[$name]);
      return $this->item->hasField($name);
   }
   // ─────────────────────────────────────────────────────────────
@@ -276,72 +188,41 @@
    */
   public function get(string $name): mixed
   {
      // Check computed fields first
      if (isset($this->computed[$name])) {
         return ($this->computed[$name])($this);
      // Handle repeater subfield path
      if (str_contains($name, ':')) {
         return $this->getByPath($name);
      }
      // Return from loaded field if exists
      if ($field = $this->item->getField($name)) {
         return $field->get();
      }
      // 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 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);
      // Handle repeater subfield path (e.g., "services:2:image")
      if (str_contains($name, ':')) {
         return $this->setByPath($name, $value);
      }
      if (!$config) {
         // Allow setting unknown fields with minimal config
         $config = ['type' => 'text', '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);
      $oldValue = null;
      if ($field) {
         $oldValue = $field->value;
         $field->set($value);
      } else {
         // Load original to track dirty state
         $original = $this->storage->get($this->item, $name);
         $oldValue = $original;
         $field = new Field($name, $original, $config);
         $field->set($value);
         $this->item->setField($field);
      }
      // Fire change callbacks
      if (isset($this->onChangeCallbacks[$name]) && $oldValue !== $value) {
         foreach ($this->onChangeCallbacks[$name] as $callback) {
            $callback($value, $oldValue, $this);
         }
      $value = $this->sanitizer->sanitize($value, $field->config);
      $field->set($value);
      if ($autosave && $field->isDirty) {
         $this->save();
      }
      return $this;
@@ -353,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;
   }
   /**
@@ -408,16 +357,28 @@
    */
   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
    */
@@ -430,9 +391,9 @@
      return $results;
   }
   // ─────────────────────────────────────────────────────────────
   // Repeater Access
   // ─────────────────────────────────────────────────────────────
   /*****************************************************************
    * Repeater Access
   *****************************************************************/
   /**
    * Get repeater accessor for fluent repeater operations
@@ -442,28 +403,35 @@
      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) field values
    */
   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
@@ -474,84 +442,22 @@
      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;
   }
   /**
    * Re-enable validation and sanitization
    */
   public function withDefaults(): self
   {
      $this->autoValidate = true;
      $this->autoSanitize = true;
      return $this;
   }
   /**
    * Get the underlying Item
    */
   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 all field configurations
    */
   public function configs(): array
   {
      return $this->item->fieldConfigs;
   }
   /**
    * Get item ID
    */
   public function id(): int|string|null
   {
      return $this->item->id;
      return $this->ID;
   }
   /**
@@ -559,7 +465,7 @@
    */
   public function objectType(): string
   {
      return $this->item->objectType;
      return $this->type;
   }
   /**
@@ -567,47 +473,9 @@
    */
   public function contentType(): ?string
   {
      return $this->item->contentType;
      return $this->contentType;
   }
   /**
    * Eager load all fields
    */
   public function eager(): self
   {
      $this->getAll();
      return $this;
   }
   /**
    * Convert loaded fields to array
    */
   public function toArray(): array
   {
      return $this->item->toArray();
   }
   // ─────────────────────────────────────────────────────────────
   // Event Callbacks
   // ─────────────────────────────────────────────────────────────
   /**
    * Register callback for field changes
    */
   public function onChange(string $field, callable $callback): self
   {
      $this->onChangeCallbacks[$field][] = $callback;
      return $this;
   }
   /**
    * Register computed/virtual field
    */
   public function computed(string $name, callable $getter): self
   {
      $this->computed[$name] = $getter;
      return $this;
   }
   // ─────────────────────────────────────────────────────────────
   // Protected Helpers