post, term, or user ID OR {string} option key base */ protected int|string $ID; /** * @var array of Registrar's defined fields, if any */ protected static array $registrars = []; /** * @var array Field values, stored in memory */ protected array $fields; protected ?\WC_Product $product = null; protected static Validator $validator; protected static Sanitizer $sanitizer; 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 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(); if ($this->content && !array_key_exists($this->content, self::$registrars)) { $registrar = Registrar::getInstance($this->content); if ($registrar) { self::$registrars[$this->content] = $registrar->getFields(); } } 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 => [] }; $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; } $value = self::$sanitizer->sanitize($value, $field); } 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); } 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 = []; 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; } protected function setAllCoreFields(array $data):array { $results = []; $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):bool { $parts = explode(':', $path, 3); if (count($parts) !== 3) { $this->log('Could not set value by path'); return false; } [$repeaterName, $rowIndex, $subField] = $parts; $this->clearCache(); return $this->repeater($repeaterName)->setField((int) $rowIndex, $subField, $value); } 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); } /**************************************************************************************** * GROUP helpers ****************************************************************************************/ public function group(string $name): Group { return new Group($this, $name); } protected function setGroup(string $name, mixed $value):bool { [$parent, $child] = explode('|', $name, 2); $this->clearCache(); return $this->group($parent)->set($child, $value); } /*************************************************************************************** * UTILITY ***************************************************************************************/ protected function clearCache():void { 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'); } protected function getAllFieldNames(): array { $names = static::$coreFields[$this->objectType]; if ($this->hasRegistrar()) { $names = array_merge($names, array_keys(self::$registrars[$this->content])); } if ($this->product) { $names = array_merge($names, $this->getAllProductFieldNames()); } return array_unique($names); } protected function hasRegistrar():bool { return array_key_exists($this->content, self::$registrars); } 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); } /** * Get item ID */ public function id(): int|string|null { return $this->ID; } public function config(string $name):?array { return $this->getFieldConfig($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)); } } }