| | |
| | | <?php |
| | | namespace JVBase\meta; |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\registrar\Registrar; |
| | | |
| | | 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 |
| | | { |
| | | protected Item $item; |
| | | protected Storage $storage; |
| | | protected Validator $validator; |
| | | protected Sanitizer $sanitizer; |
| | | protected MetaTypeManager $typeManager; |
| | | |
| | | protected bool $autoValidate = true; |
| | | protected bool $autoSanitize = true; |
| | | |
| | | /** @var array<string, callable[]> */ |
| | | protected array $onChangeCallbacks = []; |
| | | |
| | | /** @var array<string, callable> */ |
| | | protected array $computed = []; |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Factory Methods |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | class Meta { |
| | | /** |
| | | * Create Meta instance for a post |
| | | * @var string post, term, user, or options |
| | | */ |
| | | public static function forPost(int $id): self |
| | | { |
| | | return new self($id, 'post'); |
| | | } |
| | | |
| | | protected string $objectType; |
| | | /** |
| | | * Create Meta instance for a term |
| | | * @var string|null post type, user role, or taxonomy |
| | | */ |
| | | public static function forTerm(int $id): self |
| | | { |
| | | return new self($id, 'term'); |
| | | } |
| | | |
| | | protected ?string $content; |
| | | /** |
| | | * Create Meta instance for a user |
| | | * @var int|string <int>post, term, or user ID OR {string} option key base |
| | | */ |
| | | public static function forUser(int $id): self |
| | | { |
| | | return new self($id, 'user'); |
| | | } |
| | | |
| | | protected int|string $ID; |
| | | /** |
| | | * Create Meta instance for options |
| | | * @var array of Registrar's defined fields, if any |
| | | */ |
| | | public static function forOptions(?string $baseKey = null): self |
| | | { |
| | | $instance = new self($baseKey, 'options'); |
| | | $instance->item->baseKey = $baseKey; |
| | | return $instance; |
| | | } |
| | | |
| | | protected static array $registrars = []; |
| | | /** |
| | | * Bulk load multiple posts with optional field preloading |
| | | * @return array<int, Meta> |
| | | * @var array Field values, stored in memory |
| | | */ |
| | | public static function bulkForPosts(array $ids, array $preloadFields = []): array |
| | | { |
| | | return self::bulkFor($ids, 'post', $preloadFields); |
| | | } |
| | | protected array $fields; |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | protected ?\WC_Product $product = null; |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | protected static Validator $validator; |
| | | protected static Sanitizer $sanitizer; |
| | | |
| | | /** |
| | | * Generic bulk loader |
| | | * @return array<int, Meta> |
| | | */ |
| | | protected static function bulkFor(array $ids, string $type, array $preloadFields = []): array |
| | | { |
| | | if (empty($ids)) { |
| | | return []; |
| | | } |
| | | 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' => [] |
| | | ]; |
| | | |
| | | $metas = []; |
| | | protected static array $instances = [ |
| | | 'post' => [], |
| | | 'term' => [], |
| | | 'user' => [], |
| | | 'options'=> [], |
| | | ]; |
| | | |
| | | // Create instances |
| | | foreach ($ids as $id) { |
| | | $metas[$id] = new self($id, $type); |
| | | } |
| | | private function __construct(string $objectType, int|string $ID) { |
| | | $this->objectType = $objectType; |
| | | $this->ID = $ID; |
| | | $this->content = $this->resolveBasedType(); |
| | | |
| | | // 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 ($this->content && !array_key_exists($this->content, self::$registrars)) { |
| | | $registrar = Registrar::getInstance($this->content); |
| | | if ($registrar) { |
| | | self::$registrars[$this->content] = $registrar->getFields(); |
| | | } |
| | | } |
| | | |
| | | if (!empty($invalid)) { |
| | | JVB()->error()->log('meta', 'Bulk save has validation errors', [ |
| | | 'invalid_items' => $invalid |
| | | ], 'warning'); |
| | | 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 |
| | | ); |
| | | |
| | | // 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(); |
| | | //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??''; |
| | | } |
| | | } |
| | | } |
| | | |
| | | $results = Storage::saveBulk($validMetas, $updateTimestamp); |
| | | |
| | | // Mark invalid ones as failed |
| | | foreach ($invalid as $id => $errors) { |
| | | $results[$id] = false; |
| | | public static function forPost(int $ID):self |
| | | { |
| | | if (array_key_exists($ID, self::$instances['post'])) { |
| | | return self::$instances['post'][$ID]; |
| | | } |
| | | |
| | | return $results; |
| | | self::$instances['post'][$ID] = new self('post', $ID); |
| | | return self::$instances['post'][$ID]; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Constructor |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | public function __construct(int|string|null $id, string $type) |
| | | public static function forTerm(int $ID):self |
| | | { |
| | | $this->storage = new Storage(); |
| | | $this->validator = new Validator(); |
| | | $this->sanitizer = new Sanitizer(); |
| | | $this->typeManager = new MetaTypeManager(); |
| | | |
| | | $this->item = $this->buildItem($id, $type); |
| | | } |
| | | |
| | | protected function buildItem(int|string|null $id, string $type): Item |
| | | { |
| | | $contentType = null; |
| | | $wpObject = null; |
| | | |
| | | 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] |
| | | }; |
| | | 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]; |
| | | } |
| | | |
| | | $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]; |
| | | } else { |
| | | $item->fieldConfigs[$name]['_wp_default'] = true; |
| | | } |
| | | public static function forUser(int $ID):self |
| | | { |
| | | if (array_key_exists($ID, self::$instances['user'])) { |
| | | return self::$instances['user'][$ID]; |
| | | } |
| | | |
| | | return $item; |
| | | self::$instances['user'][$ID] = new self('user', $ID); |
| | | return self::$instances['user'][$ID]; |
| | | } |
| | | |
| | | protected function loadFieldConfigs(?string $contentType, string $objectType): array |
| | | public static function forOptions(string $base = 'default'):self |
| | | { |
| | | if (!$contentType && $objectType !== 'options') { |
| | | return []; |
| | | if (array_key_exists($base, self::$instances['options'])) { |
| | | return self::$instances['options'][$base]; |
| | | } |
| | | |
| | | return jvbGetFields($contentType ?? 'options', $objectType); |
| | | self::$instances['options'][$base] = new self('options', $base); |
| | | return self::$instances['options'][$base]; |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // 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) || isset($this->computed[$name]); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // Core API |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * Get a field value |
| | | */ |
| | | public function get(string $name): mixed |
| | | { |
| | | // Check computed fields first |
| | | if (isset($this->computed[$name])) { |
| | | return ($this->computed[$name])($this); |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | /** |
| | | * Set a field value (validates & sanitizes by default) |
| | | */ |
| | | public function set(string $name, mixed $value): self |
| | | { |
| | | $config = $this->item->getFieldConfig($name); |
| | | |
| | | if (!$config) { |
| | | // Allow setting unknown fields with minimal config |
| | | $config = ['type' => 'text', 'name' => $name]; |
| | | } |
| | | |
| | | // Validate |
| | | if ($this->autoValidate && !$this->validator->validate($value, $config)) { |
| | | $field = $this->item->getField($name) ?? new Field($name, $value, $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); |
| | | } |
| | | } |
| | | |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Get multiple fields |
| | | */ |
| | | public function getAll(array $fields = []): array |
| | | { |
| | | if (empty($fields) || $fields === ['all']) { |
| | | $fields = array_keys($this->item->fieldConfigs); |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | /** |
| | | * Set multiple fields |
| | | */ |
| | | public function setAll(array $data): self |
| | | { |
| | | foreach ($data as $name => $value) { |
| | | $this->set($name, $value); |
| | | } |
| | | return $this; |
| | | } |
| | | |
| | | /** |
| | | * Save all dirty fields to database |
| | | */ |
| | | public function save(bool $updateTimestamp = true): bool |
| | | { |
| | | if (!$this->item->isValid()) { |
| | | JVB()->error()->log('meta', 'Cannot save: validation errors exist', [ |
| | | 'fields' => array_keys($this->item->getInvalidFields()) |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Check for field overrides before saving |
| | | foreach ($this->item->getDirtyFields() as $field) { |
| | | if ($this->checkOverrides($field)) { |
| | | $field->markClean(); |
| | | } |
| | | } |
| | | |
| | | return $this->storage->save($this->item, $updateTimestamp); |
| | | } |
| | | |
| | | /** |
| | | * Delete a field value |
| | | */ |
| | | 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(); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | |
| | | // ───────────────────────────────────────────────────────────── |
| | | // 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 |
| | | */ |
| | | public function reset(): self |
| | | { |
| | | $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; |
| | | } |
| | | |
| | | /** |
| | | * 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); |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | |
| | | /** |
| | | * Get object type (post, term, user, options) |
| | | */ |
| | | public function objectType(): string |
| | | { |
| | | return $this->item->objectType; |
| | | } |
| | | |
| | | /** |
| | | * Get content type (tattoo, artist, etc) |
| | | */ |
| | | public function contentType(): ?string |
| | | { |
| | | return $this->item->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 |
| | | // ───────────────────────────────────────────────────────────── |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Get default value for a field type |
| | | */ |
| | | protected function getDefaultValue(string $name): mixed |
| | | { |
| | | $config = $this->item->getFieldConfig($name); |
| | | $type = $config['type'] ?? 'text'; |
| | | |
| | | return match ($this->typeManager->getMetaType($type)) { |
| | | 'object', 'array' => [], |
| | | 'boolean' => false, |
| | | 'integer' => 0, |
| | | default => '', |
| | | 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); |
| | | } |
| | | |
| | | 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)); |
| | | } |
| | | } |
| | | } |