[ 'post_title', 'post_excerpt', 'post_content', 'post_date', 'post_status', 'post_modified', 'post_thumbnail', 'menu_order' ], 'user' => [ 'first_name', 'last_name', 'display_name', 'description', 'user_email', ], 'term' => [ 'term_name', 'description' ] ]; public function __construct(int|string|null $ID = null, ?string $type = null, ?string $content = null) { global $wpdb; $this->wpdb = $wpdb; $this->object_id = is_int($ID) ? $ID : null; $this->object_type = $type; if ($ID) { switch ($type) { case 'post': $this->data = get_post((string)$ID); $this->content = jvbNoBase($this->data->post_type); $registrar = Registrar::getInstance($this->content); $this->isTimeline = $registrar && $registrar->hasFeature('is_timeline'); break; case 'term': $this->data = get_term($ID); $this->content = jvbNoBase($this->data->taxonomy); break; case 'user': case 'integrations': $this->data = get_user($ID); $this->content = jvbUserRole($ID); break; case 'options': $this->baseKey = $ID; $this->data = null; break; default: $this->data = null; break; } } $this->type_manager = new MetaTypeManager(); $this->validator = new Validator(); $this->sanitizer = new Sanitizer(); $this->renderer = new Render(); } /** * @param string $field_type * * @return string */ public function getMetaType(string $field_type): string { return $this->type_manager->getMetaType($field_type); } /** * @param array $field * * @return string */ public function getSanitizeCallback(array $field): string { return $this->sanitizer->getCallback($field); } /** * @param string $name * * @return mixed */ public function getValue(string $name): mixed { //Get standard post fields first if (array_key_exists($this->object_type, $this->wpDefaults)) { $defaults = $this->wpDefaults[$this->object_type]; if (in_array($name, $defaults)) { if (in_array($name, ['featured_image', 'post_thumbnail'])) { return get_post_thumbnail_id($this->object_id); } return match ($this->object_type) { 'term' => $this->getTermField($name), 'post' => $this->getPostField($name), 'user' => $this->getUserField($name), default => '' }; } } $meta_key = BASE . $name; switch ($this->object_type) { case 'post': return get_post_meta($this->object_id, $meta_key, true); case 'term': return get_term_meta($this->object_id, $meta_key, true); case 'user': case 'integrations': return get_user_meta($this->object_id, $meta_key, true); case 'options': $key = $this->baseKey ? BASE . $this->baseKey . '_' . $name : BASE . $name; return get_option($key); default: return ''; } } protected function getTermField(string $name): mixed { // WordPress handles entity decoding and filters return match ($name) { 'term_name' => get_term_field('name', $this->object_id), 'description' => get_term_field('description', $this->object_id), default => '' }; } protected function getPostField(string $name): mixed { return match ($name) { 'post_title' => get_the_title($this->object_id), 'post_excerpt' => get_the_excerpt($this->object_id), 'post_content' => get_post_field('post_content', $this->object_id), default => $this->data->$name ?? '' }; } protected function getUserField(string $name): mixed { return match ($name) { 'display_name' => get_the_author_meta('display_name', $this->object_id), 'user_email' => get_the_author_meta('user_email', $this->object_id), 'first_name' => get_the_author_meta('first_name', $this->object_id), 'last_name' => get_the_author_meta('last_name', $this->object_id), default => $this->data->$name ?? '' }; } /** * @param string $name * * @return bool */ public function deleteValue(string $name): bool { try { $meta_key = BASE . "{$name}"; $result = false; switch ($this->object_type) { case 'post': $config = $this->getFieldConfig($name); if ($config['type'] === 'taxonomy' && !array_key_exists('taxonomy_type', $config)) { $result = wp_set_post_terms($this->object_id, '', $config['taxonomy']); } else { $result = delete_post_meta((int)$this->object_id, $meta_key); } break; case 'term': $result = delete_term_meta($this->object_id, $meta_key); break; case 'user': case 'integrations': $result = delete_user_meta($this->object_id, $meta_key); break; } if ($result === false) { throw new Exception("Failed to delete meta value for {$this->field}"); } return true; } catch (Exception $e) { $this->handleError( $e->getMessage(), [ 'object_id' => $this->object_id, 'field' => $name, 'object_type' => $this->object_type, ] ); return false; } } /** * @param array $fields * * @return array */ public function batchDelete(array $fields): array { $results = []; foreach ($fields as $field) { $results[$field] = $this->deleteValue($field); } return $results; } /** * @param string $name * @param mixed $value * * @return bool */ public function updateValue(string $name, mixed $value, bool $updatePost = true): bool { try { // Get field definition $fields = $this->getFields(); $field_config = $fields[$name] ?? null; if (!$field_config) { throw new Exception("Field configuration not found for {$name}"); } $field_config['name'] = $name; // Validate value if (!$this->validator->validate($value, $field_config)) { throw new Exception("Validation failed for {$name}"); } // Sanitize value $sanitized = $this->sanitizer->sanitize($value, $field_config); if ($this->checkOverrides($name, $sanitized, $field_config)) { return true; } if (array_key_exists($this->object_type, $this->wpDefaults)) { $check = $this->wpDefaults[$this->object_type]; if (in_array($name, $check)) { $ID = true; if (in_array($name, ['featured_image', 'post_thumbnail'])) { $old = get_post_thumbnail_id($this->object_id); if ($old !== $sanitized) { $ID = set_post_thumbnail($this->object_id, $sanitized); } return $ID !== false; } $old = $this->data->$name; if ($old !== $sanitized) { switch ($this->object_type) { case 'post': $ID = jvb_update_post([ 'ID' => $this->object_id, $name => $sanitized ]); break; case 'term': $data = [$name => $sanitized]; if ($name === 'term_name') { $data['slug'] = sanitize_title($sanitized); } $ID = wp_update_term( $this->data->term_id, $this->data->taxonomy, $data ); break; case 'user': $ID = wp_update_user([ 'ID' => $this->object_id, $name => $sanitized ]); if ($name === 'display_name') { $link = get_user_meta($this->object_id, BASE.'link', true); if ($link !== '') { jvb_update_post([ 'ID' => $link, 'post_title' => $sanitized ]); } } break; } } return $ID !== false; } } if ($field_config['type'] == 'taxonomy' && (!array_key_exists('taxonomy_type', $field_config))) { if (empty(trim($sanitized))) { // Clear all terms when value is empty wp_set_object_terms($this->object_id, [], jvbCheckBase($field_config['taxonomy']), false); } else { $term_ids = array_map('intval', array_filter(explode(',', $sanitized))); wp_set_object_terms($this->object_id, $term_ids, jvbCheckBase($field_config['taxonomy']), false); } } if ($field_config['type'] === 'location' && empty($sanitized)) { $this->addMeta('has_map', false); } // Store value $meta_key = BASE . $name; $result = false; switch ($this->object_type) { case 'post': $result = update_post_meta($this->object_id, $meta_key, $sanitized); break; case 'term': $result = update_term_meta($this->object_id, $meta_key, $sanitized); break; case 'user': case 'integrations': $result = update_user_meta($this->object_id, $meta_key, $sanitized); break; case 'options': $key = $this->baseKey ? BASE . $this->baseKey . '_' . $name : BASE . $name; return update_option($key, $sanitized); } if ($result === false) { throw new Exception("Failed to update meta value for {$name}"); } if ($updatePost && $this->object_type === 'post') { //Flush the cache for this post. jvb_update_post([ 'ID' => $this->object_id, ]); } return true; } catch (Exception $e) { JVB()->error()->log( 'meta_manager', $e->getMessage(), [ 'object_id' => $this->object_id, 'field' => $name, 'object_type' => $this->object_type ], 'error' ); return false; } } /********************* REPEATER HELPERS * mainly for when we need to update a processed image to a repeater row field *********************/ /** * Update a specific field within a repeater row * * @param string $field_string The field string in format "repeater_name:row_index:field_name" * @param mixed $value The value to set for the field * @return bool Success status */ public function updateRepeaterRowField(string $field_string, mixed $value): bool { // Parse the field string $parsed = $this->parseRepeaterFieldString($field_string); if (!$parsed) { error_log('[MetaManager] Invalid repeater field string: ' . $field_string); return false; } $repeater_name = $parsed['repeater']; $row_index = $parsed['row_index']; $field_name = $parsed['field']; // Get current repeater data $repeater_data = $this->getValue($repeater_name); // Initialize as array if empty or not an array if (!is_array($repeater_data)) { $repeater_data = []; } // Ensure row exists if (!isset($repeater_data[$row_index])) { $repeater_data[$row_index] = []; } // Update the specific field in the row $repeater_data[$row_index][$field_name] = $value; // Save the updated repeater data $success = $this->updateValue($repeater_name, $repeater_data); if ($success) { error_log(sprintf( '[MetaManager] Updated repeater field: %s[%d][%s] = %s', $repeater_name, $row_index, $field_name, is_scalar($value) ? $value : json_encode($value) )); } return $success; } /** * Parse a repeater field string * * @param string $field_string Format: "repeater_name:row_index:field_name" or "repeater_name:row_index:field_name:sub_field" * @return array|false Parsed components or false if invalid */ public function parseRepeaterFieldString(string $field_string): array|false { $parts = explode(':', $field_string); if (count($parts) < 3) { return false; } // Handle nested repeaters (4+ parts) if (count($parts) === 3) { return [ 'repeater' => $parts[0], 'row_index' => (int)$parts[1], 'field' => $parts[2], 'nested' => false ]; } elseif (count($parts) === 4) { // Nested repeater or sub-field return [ 'repeater' => $parts[0], 'row_index' => (int)$parts[1], 'field' => $parts[2], 'sub_field' => $parts[3], 'nested' => true ]; } return false; } protected function checkOverrides(string $name, mixed $sanitized, array $config): bool { do_action('jvb_meta_update', $name, $sanitized, $this->object_type); //check for overrides by field name or type $type = $config['type'] ?? false; $overrides = [ 'update_' . $name, 'update_' . $type ]; foreach ($overrides as $override) { $override = BASE . $override; if (function_exists($override)) { $override($this->object_id, $sanitized); return true; } $override = 'jvb_' . $override; if (function_exists($override)) { $override($this->object_id, $sanitized); return true; } } return false; } protected function getFields(): array { if (!empty($this->fields)) { return $this->fields; } $type = false; switch ($this->object_type) { case 'post': $type = get_post_type((int)$this->object_id); break; case 'term': $type = get_term((int)$this->object_id)->taxonomy; break; case 'user': $type = jvbUserRole((int)$this->object_id); break; case 'options': return Registrar::getFieldsFor('options'); } if (!$type) { return []; } $this->fields = Registrar::getFieldsFor($type); return $this->fields; } protected function getObjectType(): string|false { switch ($this->object_type) { case 'post': $type = get_post_type((int)$this->object_id); break; case 'term': $type = get_term((int)$this->object_id)->taxonomy; break; case 'user': $type = jvbUserRole((int)$this->object_id); break; case 'options': $type = 'options'; break; default: return false; } return $type; } protected function getSections():array { $type = false; switch ($this->object_type) { case 'post': $type = get_post_type((int)$this->object_id); break; case 'term': $type = get_term((int)$this->object_id)->taxonomy; break; case 'user': $type = jvbUserRole((int)$this->object_id); break; case 'options': $type = 'options'; break; } if (!$type) { return []; } return Registrar::getInstance($type)->getSections()??[]; } /** * @param string $message * @param array $context * @param string $level * * @return void */ protected function handleError(string $message, array $context = [], string $level = E_USER_WARNING):void { $class = get_class($this); $formatted = sprintf('[%s] %s', $class, $message); // Log to ErrorHandler if available JVB()->error()->log( 'meta_manager', $message, $context, 'error' ); if (defined('WP_DEBUG') && WP_DEBUG) { trigger_error($formatted, $level); } } public function setFieldConfig(array $fields):void { $this->fields = $fields; } protected function getFieldConfig(string $name):array|false { $fields = $this->getFields(); if (array_key_exists($name, $fields)) { return $fields[$name]; } // For nested fields $result = null; $found = false; array_walk_recursive($fields, function ($value, $k) use ($name, &$result, &$found) { if (!$found && $k === $name) { $result = $value; $found = true; } }); return $found ? $result : false; } public function render(string $type, string $name, array|null $config = null, bool $showHidden = false, $return = false, bool $hideEmpty = true):mixed { if (!apply_filters('jvbShouldRenderMeta', true, $name, $type, $this->getObjectType())) { return false; } if (!$config) { $config = $this->getFieldConfig($name); if (!$config) { return false; } } if (jvbCheck('hidden', $config) && !$showHidden) { return false; } if ($this->object_type === 'form') { $value = $this->getDefaultValue($config['type']); } else { try { $value =$this->getValue($name); } catch (Exception $e) { $value = $this->getDefaultValue($name); } } if ($config['type'] === 'location'){ $this->addMeta('has_map', true); } //test for form or frontend $out = ''; switch ($type) { case 'form': $out = Form::render($name, $value, $config); $out = apply_filters('jvbRenderFormMeta', $out, $name, $config, $value, $this->getObjectType()); break; case 'render': $out = $this->renderer->render($name, $value, $config); if (empty($out) && !$hideEmpty) { $out = $this->getEmptyTemplate($config['type'], $name); } $out = apply_filters('jvbRenderFrontendMeta', $out, $name, $config, $value, $this->getObjectType()); } if (!$return) { echo $out; } return $out; } public function renderForm(string $endpoint, array $options = [], array $fields = [], false|array $sections = [], bool $return = false):mixed { $ID = (array_key_exists('form-id', $options)) ? $options['form-id'] : $endpoint; ob_start(); $classes = (array_key_exists('classes', $options)) ? ' class="'.implode($options['classes']).'"' : ''; echo '
'; echo (array_key_exists('heading', $options)) ? '

'.$options['heading'].'

' : ''; if (array_key_exists('description', $options)) { if (is_array($options['description'])) { foreach ($options['description'] as $d) { echo '

'.$d.'

'; } } else { echo '

'.$options['description'].'

'; } } if (empty($fields)) { $fields = ($this->content) ? Registrar::getFieldsFor($this->content) : $this->getFields(); } if ($sections !== false && empty($sections)) { $sections = ($this->content) ? Registrar::getInstance($this->content)->getSections() : $this->getSections(); } if (!empty($sections)){ $tabs = []; foreach ($sections as $config) { $tabs[$config['slug']] = $config; } } else { $tabs = false; } $first = ['post_thumbnail', 'post_title', 'price']; foreach ($first as $f) { if (array_key_exists($f, $fields)) { if ($tabs) { $tabs['basic']['content'] .= $this->render('form', $f, $fields[$f], false, true); } else { $this->render('form', $f, $fields[$f]); } unset($fields[$f]); } } foreach ($fields as $n => $config) { if ($tabs) { $section = (array_key_exists('section', $config)) ? $config['section'] : 'basic'; $tabs[$section]['content'] .= $this->render('form', $n, $config, false, true); } else { $this->render('form', $n, $config); } } if ($tabs) { jvbRenderTabs($tabs); } echo (jvbCheck('submit', $options)) ? '' : ''; echo '
'; $out = ob_get_clean(); if (!$return) { echo $out; } return $out; } function getEmptyTemplate($type, $name):string { $template = '