'text', 'textarea' => 'textarea', 'number' => 'number', 'email' => 'email', 'url' => 'url', 'wysiwyg' => 'wysiwyg', 'true_false' => 'checkbox', 'color_picker' => 'color', 'select' => empty($field['multiple']) ? 'select' : 'multiselect', 'radio' => 'radio', 'checkbox' => 'checkboxes', 'image' => 'image', 'file' => 'file', 'gallery' => 'gallery', 'group' => 'group', 'clone' => 'group', 'link' => 'group', 'google_map' => 'group', 'repeater' => 'repeater', 'flexible_content' => 'flexible', 'date_picker' => 'date', 'date_time_picker' => 'datetime', 'time_picker' => 'time'); $mb = array('text' => 'text', 'textarea' => 'textarea', 'number' => 'number', 'email' => 'email', 'url' => 'url', 'wysiwyg' => 'wysiwyg', 'checkbox' => 'checkbox', 'color' => 'color', 'select' => empty($field['multiple']) ? 'select' : 'multiselect', 'select_advanced' => empty($field['multiple']) ? 'select' : 'multiselect', 'radio' => 'radio', 'checkbox_list' => 'checkboxes', 'single_image' => 'image', 'image_advanced' => 'gallery', 'group' => empty($field['clone']) ? 'group' : 'repeater'); if ($provider === 'metabox' && !empty($field['clone'])) { return 'repeater'; } $acf += array('post_object' => empty($field['multiple']) ? 'post' : 'relationship', 'relationship' => 'relationship', 'user' => empty($field['multiple']) ? 'user' : 'relationship', 'taxonomy' => in_array($field['field_type'] ?? 'checkbox', array('checkbox', 'multi_select'), true) ? 'relationship' : 'taxonomy'); $mb += array('post' => empty($field['multiple']) ? 'post' : 'relationship', 'user' => empty($field['multiple']) ? 'user' : 'relationship', 'taxonomy_advanced' => empty($field['multiple']) ? 'taxonomy' : 'relationship', 'date' => 'date', 'datetime' => 'datetime', 'time' => 'time', 'file_input' => 'url'); $map = $provider === 'metabox' ? $mb : $acf; return $map[$field['type'] ?? ''] ?? null; } /** Only matching, explicitly understood definitions qualify. Unknown storage never falls back. */ private static function definition($source, $destination, $provider, $depth = 0) { if (!is_array($source) || $depth > 3 || ++self::$nodes > self::MAX_NODES) { self::fail(__('Source definitions exceed the supported bounds.', 'customfields-com')); } $name = $provider === 'metabox' ? ($source['id'] ?? '') : ($source['name'] ?? ''); $type = self::target_type($source, $provider); if (!self::name($name) || !$type || $type !== $destination['type']) { self::fail(__('Source and destination types or child names do not match a supported conversion.', 'customfields-com')); } if ($provider === 'metabox') { $expected_storage = array('post' => 'RWMB_Post_Storage', 'user' => 'RWMB_User_Storage', 'term' => 'RWMB_Term_Storage'); if (!self::$table_context && (!empty($source['table']) || (!empty($source['storage_type']) && !in_array($source['storage_type'], array(self::$target, self::$target . '_meta'), true)) || (isset($source['storage']) && (!is_object($source['storage']) || get_class($source['storage']) !== ($expected_storage[self::$target] ?? ''))))) { self::fail(__('Only registered standard post, user or term metadata storage is supported. Custom tables and settings-page containers need a separate adapter.', 'customfields-com')); } if (!empty($source['parent'])) { self::fail(__('Parent-post fields need a separately reviewed parent object.', 'customfields-com')); } if (!empty($source['timestamp']) && !in_array($source['type'], array('date', 'datetime'), true)) { self::fail(__('Timestamp conversion requires a registered date or date-time field.', 'customfields-com')); } } if ($provider !== 'metabox' && $source['type'] === 'taxonomy' && !empty($source['load_terms']) && self::$target !== 'post') { self::fail(__('Authoritative taxonomy relationships can be copied from posts only.', 'customfields-com')); } $key = $provider === 'metabox' ? $name : ($source['__key'] ?? $source['key'] ?? ''); if ($provider !== 'metabox' && (!is_string($key) || !preg_match('/^field_[a-zA-Z0-9_-]{1,80}$/D', $key))) { self::fail(__('The source field key is invalid.', 'customfields-com')); } $def = array('name' => $name, 'key' => $key, 'type' => $type, 'source_type' => $source['type'], 'required' => !empty($source['required']), 'choices' => $source[$provider === 'metabox' ? 'options' : 'choices'] ?? array()); foreach (array('min', 'max', 'min_clone', 'max_clone', 'maxlength', 'save_format', 'php_format', 'timestamp', 'multiple', 'clone', 'conditional_logic', 'visible', 'hidden', 'clone_as_multiple', 'post_type', 'post_status', 'taxonomy', 'role', 'field_type', 'load_terms', 'save_terms', 'parent', 'prefix_name', 'prefix_label', 'display') as $setting) { if (isset($source[$setting])) { $def[$setting] = $source[$setting]; } } if (in_array($source['type'], array('post_object', 'relationship', 'post', 'user', 'taxonomy', 'taxonomy_advanced'), true)) { $def['reference_object'] = $source['type'] === 'user' ? 'user' : (in_array($source['type'], array('taxonomy', 'taxonomy_advanced'), true) ? 'term' : 'post'); if ($type === 'relationship' && ($destination['relationship_object'] ?? 'post') !== $def['reference_object']) { self::fail(__('Source and destination relationships must use the same object kind.', 'customfields-com')); } } if ($provider === 'metabox' && !empty($source['clone']) && $source['type'] !== 'group') { $children = $destination['sub_fields'] ?? array(); if (count($children) !== 1 || ($children[0]['name'] ?? '') !== 'value') { self::fail(__('A cloned scalar needs a repeater destination with one child named value, using the original scalar type.', 'customfields-com')); } $leaf = $source; $leaf['clone'] = false; $leaf['clone_as_multiple'] = false; $def['clone_leaf'] = self::definition($leaf, $children[0], $provider, $depth + 1); } elseif ($provider !== 'metabox' && $source['type'] === 'link') { $targets = array_column($destination['sub_fields'] ?? array(), null, 'name'); if (count($targets) !== 3 || !isset($targets['url'], $targets['title'], $targets['target']) || $targets['url']['type'] !== 'url' || $targets['title']['type'] !== 'text' || !in_array($targets['target']['type'], array('text', 'select'), true)) { self::fail(__('A link needs a group with url (URL), title (text) and target (text or select). All three values are preserved.', 'customfields-com')); } $def['link'] = true; } elseif ($provider !== 'metabox' && $source['type'] === 'google_map') { $targets = array_column($destination['sub_fields'] ?? array(), null, 'name'); $known = array('address', 'lat', 'lng', 'zoom', 'place_id', 'name', 'street_number', 'street_name', 'street_name_short', 'city', 'state', 'state_short', 'post_code', 'country', 'country_short'); if (!isset($targets['address'], $targets['lat'], $targets['lng']) || array_diff(array_keys($targets), $known)) { self::fail(__('A map needs a group with address, lat and lng, plus every stored optional map property using its original name.', 'customfields-com')); } foreach ($targets as $property => $field) { $expected = in_array($property, array('lat', 'lng', 'zoom'), true) ? 'number' : 'text'; if ($field['type'] !== $expected) { self::fail(__('Map coordinates and zoom need number fields; address details need text fields.', 'customfields-com')); } } $def['map_properties'] = array_keys($targets); } elseif (in_array($type, array('group', 'repeater'), true)) { $children = $source[$provider === 'metabox' ? 'fields' : 'sub_fields'] ?? array(); $def['children'] = self::children($children, $destination['sub_fields'] ?? array(), $provider, $depth + 1, ($source['type'] === 'clone')); if ($source['type'] === 'clone') { $def['clone_basename'] = $source['_name'] ?? $name; self::clone_dependencies($source); } } elseif ($type === 'flexible') { $layouts = $source['layouts'] ?? array(); $targets = array_column($destination['layouts'] ?? array(), null, 'name'); if (!is_array($layouts) || count($layouts) < 1 || count($layouts) > 12 || count($layouts) !== count($targets)) { self::fail(__('Flexible layout names must match the destination exactly (at most 12).', 'customfields-com')); } $def['layouts'] = array(); foreach ($layouts as $layout) { $layout_name = $layout['name'] ?? ''; if (!self::name($layout_name) || !isset($targets[$layout_name]) || isset($def['layouts'][$layout_name])) { self::fail(__('A flexible layout is missing, duplicated or unsupported.', 'customfields-com')); } $def['layouts'][$layout_name] = self::children($layout['sub_fields'] ?? array(), $targets[$layout_name]['sub_fields'], $provider, $depth + 1); } } return $def; } private static function children($children, $targets, $provider, $depth, $cloned = false) { $targets = array_column($targets, null, 'name'); if (!is_array($children) || !$children || count($children) > 20 || count($children) !== count($targets)) { self::fail(__('Composite children must have exactly matching names, with 1–20 children per container.', 'customfields-com')); } $result = array(); foreach ($children as $child) { $name = $provider !== 'metabox' && $cloned ? ($child['__name'] ?? $child['name'] ?? '') : ($child[$provider === 'metabox' ? 'id' : 'name'] ?? ''); if (!self::name($name) || !isset($targets[$name]) || isset($result[$name])) { self::fail(__('A composite child is missing, duplicated or renamed.', 'customfields-com')); } $result[$name] = self::definition($child, $targets[$name], $provider, $depth); } return $result; } /** Keep top-level Clone identities before the vendor expands seamless fields for rendering. */ private static function acf_group_fields($group) { if (function_exists('acf_have_local_fields') && acf_have_local_fields($group['key'])) { $raw = acf_get_local_fields($group['key']); } elseif (function_exists('acf_get_raw_fields') && !empty($group['ID'])) { $raw = acf_get_raw_fields($group['ID']); } else { return (array) acf_get_fields($group); } if (!is_array($raw) || count($raw) > 100) { return array(); } $fields = array(); foreach ($raw as $field) { $resolved = acf_get_field($field['key'] ?? $field['ID'] ?? 0); if ($resolved) { $fields[] = $resolved; } } return $fields; } /** Resolve every selected dependency, including disabled module groups; never accept silently missing clones. */ private static function clone_dependencies($source, $trail = array(), $depth = 0) { $identity = $source['__key'] ?? $source['key'] ?? ''; if ($depth > 8 || ++self::$nodes > self::MAX_NODES || isset($trail[$identity])) { self::fail(__('Recursive or excessive Clone dependencies cannot be converted.', 'customfields-com')); } $trail[$identity] = true; if (($source['type'] ?? '') === 'clone') { if (!is_array($source['clone'] ?? null) || !$source['clone'] || count($source['clone']) > 20) { self::fail(__('Clone requires 1–20 resolvable field or group selections.', 'customfields-com')); } foreach ($source['clone'] as $selector) { if (!is_string($selector)) { self::fail(__('A Clone selection is invalid.', 'customfields-com')); } if (strpos($selector, 'group_') === 0) { $group = acf_get_field_group($selector); $fields = $group ? self::acf_group_fields($group) : array(); if (!$fields) { self::fail(__('A selected Clone group is missing or empty.', 'customfields-com')); } foreach ($fields as $field) { self::clone_dependencies($field, $trail, $depth + 1); } } else { $field = acf_get_field($selector); if (!$field) { self::fail(__('A selected Clone field is missing.', 'customfields-com')); } self::clone_dependencies($field, $trail, $depth + 1); } } } else { foreach ((array) ($source['sub_fields'] ?? array()) as $field) { self::clone_dependencies($field, $trail, $depth + 1); } foreach ((array) ($source['layouts'] ?? array()) as $layout) { foreach ((array) ($layout['sub_fields'] ?? array()) as $field) { self::clone_dependencies($field, $trail, $depth + 1); } } } } private static function safe($value, $depth = 0) { if ($depth > 8 || is_object($value) || is_resource($value)) { return false; } if (is_array($value)) { if (count($value, COUNT_RECURSIVE) > self::MAX_NODES) { return false; } foreach ($value as $item) { if (!self::safe($item, $depth + 1)) { return false; } } } return true; } private static function decode($raw) { $value = is_serialized($raw) ? @unserialize($raw, array('allowed_classes' => false)) : $raw; if (!self::safe($value)) { self::fail(__('Serialized objects and excessive nesting are not supported.', 'customfields-com')); } return $value; } private static function rows($object_id, $name, &$snapshots, $multiple = false) { $permission_name = isset($name[0]) && $name[0] === '_' ? substr($name, 1) : $name; if (++self::$nodes > self::MAX_NODES || strlen($name) > 255 || (self::$target !== 'options' && !\CustomFields\Editor::can_edit(self::$target, $object_id, $permission_name))) { self::fail(__('Source size or metadata permissions block this conversion.', 'customfields-com')); } global $wpdb; if (self::$target === 'options') { $prefix = self::$option_context['prefix'] ?? 'options'; $name = (isset($name[0]) && $name[0] === '_') ? '_' . $prefix . '_' . substr($name, 1) : $prefix . '_' . $name; if (strlen($name) > 191) { self::fail(__('This registered options key exceeds WordPress storage limits.', 'customfields-com')); } $rows = $wpdb->get_results($wpdb->prepare("SELECT option_id AS meta_id, LEFT(option_value, %d) AS meta_value FROM {$wpdb->options} WHERE BINARY option_name = BINARY %s LIMIT 2", self::MAX_BYTES + 1, $name), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. } else { $table = _get_meta_table(self::$target); $identity = self::$target === 'user' ? 'umeta_id' : 'meta_id'; $owner = self::$target . '_id'; $rows = $wpdb->get_results($wpdb->prepare("SELECT %i AS meta_id, LEFT(meta_value, %d) AS meta_value FROM %i WHERE %i = %d AND BINARY meta_key = BINARY %s ORDER BY %i LIMIT 101", $identity, self::MAX_BYTES + 1, $table, $owner, $object_id, $name, $identity), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. } if (!is_array($rows) || $wpdb->last_error || count($rows) > 100 || (!$multiple && count($rows) > 1)) { self::fail(__('Source metadata is ambiguous or exceeds its row limit.', 'customfields-com')); } foreach ($rows as &$row) { $row['meta_id'] = (int) $row['meta_id']; self::$bytes += strlen($row['meta_value']); if (self::$bytes > self::MAX_BYTES) { self::fail(__('Use fewer source values; one conversion item is limited to 1 MB.', 'customfields-com')); } } unset($row); $snapshots[$name] = $rows; return $rows; } private static function acf_value($def, $post_id, $path, &$snapshots, $root = false) { $rows = self::rows($post_id, $path, $snapshots); $refs = self::rows($post_id, '_' . $path, $snapshots); if ($def['source_type'] === 'clone') { if (($rows || $refs) && (!$rows || !$refs || $refs[0]['meta_value'] !== $def['key'] || !in_array(self::decode($rows[0]['meta_value']), array('', false, null), true))) { self::fail(__('The Clone marker or field-key reference does not match its registered definition.', 'customfields-com')); } $base = $def['clone_basename']; if (!self::name($base) || substr($path, -strlen($base)) !== $base) { self::fail(__('Clone storage names cannot be resolved without ambiguity.', 'customfields-com')); } $prefix = substr($path, 0, strlen($path) - strlen($base)); $result = array(); $present = 0; foreach ($def['children'] as $name => $child) { $result[$name] = self::acf_value($child, $post_id, $prefix . $child['name'], $snapshots, true); if ($result[$name] !== null) { $present++; } } if (!$present && !$rows && !$refs && $root) { return null; } if ($present !== count($def['children'])) { self::fail(__('Some Clone children are missing. Create a complete matching source before converting; no child was defaulted.', 'customfields-com')); } return $result; } if (!empty($def['load_terms'])) { if (($rows || $refs) && (!$rows || !$refs || $refs[0]['meta_value'] !== $def['key'])) { self::fail(__('The taxonomy field-key reference does not match its source.', 'customfields-com')); } return self::acf_terms($def, $post_id, $rows ? self::decode($rows[0]['meta_value']) : array(), $snapshots); } if (!$rows && !$refs && $root) { return null; } if (!$rows || !$refs || $refs[0]['meta_value'] !== $def['key']) { self::fail(__('Missing ACF values or mismatched field-key references block conversion.', 'customfields-com')); } $value = self::decode($rows[0]['meta_value']); $type = $def['type']; if (!empty($def['link'])) { if (!is_array($value) || count($value) !== 3 || array_diff(array_keys($value), array('url', 'title', 'target')) || !is_string($value['url'] ?? null) || !is_string($value['title'] ?? null) || !in_array($value['target'] ?? null, array('', '_blank'), true)) { self::fail(__('Link storage must contain exactly url, title and target; additional or missing data needs manual review.', 'customfields-com')); } foreach (array('title', 'target') as $property) { if (sanitize_text_field($value[$property]) !== $value[$property]) { self::fail(__('Link text would change during sanitization. Review the source before converting.', 'customfields-com')); } } if (esc_url_raw($value['url'], array('http', 'https')) !== $value['url']) { self::fail(__('The link URL cannot be copied exactly into the destination URL field.', 'customfields-com')); } return $value; } if (isset($def['map_properties'])) { return self::map_value($value, $def); } if ($type === 'group') { if (!in_array($value, array('', false, null), true)) { self::fail(__('An ACF group marker has an unsupported representation.', 'customfields-com')); } $result = array(); foreach ($def['children'] as $name => $child) { $result[$name] = self::acf_value($child, $post_id, $path . '_' . $name, $snapshots); } return $result; } if ($type === 'repeater' || $type === 'flexible') { if ($type === 'repeater') { if (!is_scalar($value) || !preg_match('/^(0|[1-9][0-9]{0,2})$/D', (string) $value) || (int) $value > 100) { self::fail(__('Repeater counters must be between 0 and 100.', 'customfields-com')); } $layouts = array_fill(0, (int) $value, null); } else { if (!is_array($value) || array_values($value) !== $value || count($value) > 100) { self::fail(__('Flexible content must contain an ordered list of at most 100 layout names.', 'customfields-com')); } $layouts = $value; } $result = array(); foreach ($layouts as $i => $layout) { if ($type === 'flexible' && (!is_string($layout) || !isset($def['layouts'][$layout]))) { self::fail(__('A saved flexible layout is not registered.', 'customfields-com')); } $row = $type === 'flexible' ? array('__layout' => $layout) : array(); $children = $type === 'flexible' ? $def['layouts'][$layout] : $def['children']; foreach ($children as $name => $child) { $row[$name] = self::acf_value($child, $post_id, $path . '_' . $i . '_' . $name, $snapshots); } $result[] = $row; } return $result; } return self::leaf($value, $def); } private static function decimal($value) { $value = (string) $value; if (!preg_match('/^-?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)$/D', $value)) { return null; } $negative = substr($value, 0, 1) === '-'; if ($negative) { $value = substr($value, 1); } $parts = explode('.', $value); $whole = ltrim($parts[0], '0'); $fraction = rtrim($parts[1] ?? '', '0'); $normalized = ($whole === '' ? '0' : $whole) . ($fraction === '' ? '' : '.' . $fraction); return ($negative && $normalized !== '0' ? '-' : '') . $normalized; } private static function map_value($value, $def) { if (!is_array($value) || array_diff(array_keys($value), $def['map_properties']) || array_diff($def['map_properties'], array_keys($value))) { self::fail(__('Every stored map property must have a matching destination child. Nothing was dropped or filled in.', 'customfields-com')); } foreach ($value as $property => $item) { if (in_array($property, array('lat', 'lng', 'zoom'), true)) { if ((!is_string($item) && !is_int($item) && !is_float($item)) || !is_numeric($item) || !is_finite((float) $item) || (!is_float($item) && (self::decimal($item) === null || self::decimal($item) !== self::decimal(json_encode((float) $item, JSON_PRESERVE_ZERO_FRACTION))))) { self::fail(__('A map coordinate cannot retain its precision in this numeric destination. The source is unchanged.', 'customfields-com')); } $number = (float) $item; // Feed numeric text to the native sanitizer so PHP's shorter float-to-string display cannot round a source double. $value[$property] = json_encode($number, JSON_PRESERVE_ZERO_FRACTION); if (!is_string($value[$property]) || (float) $value[$property] !== $number) { self::fail(__('This numeric value cannot be copied without rounding.', 'customfields-com')); } if (($property === 'lat' && abs($number) > 90) || ($property === 'lng' && abs($number) > 180) || ($property === 'zoom' && ($number < 0 || $number > 30 || floor($number) !== $number))) { self::fail(__('A map coordinate or zoom level is out of range.', 'customfields-com')); } } else { if ((!is_string($item) && !in_array($property, array('street_number', 'post_code'), true)) || (!is_string($item) && !is_int($item)) || sanitize_text_field((string) $item) !== (string) $item) { self::fail(__('Map address details must be plain text that can be copied without loss.', 'customfields-com')); } } } return $value; } /** Copy the taxonomy field's effective values without changing WordPress term assignments. */ private static function acf_terms($def, $id, $saved, &$snapshots) { global $wpdb; $taxonomy = $def['taxonomy'] ?? ''; if (!is_string($taxonomy) || !taxonomy_exists($taxonomy)) { self::fail(__('Choose one registered taxonomy for relationship-backed values.', 'customfields-com')); } foreach (array($wpdb->term_relationships, $wpdb->term_taxonomy) as $table) { $status = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS LIKE %s', $wpdb->esc_like($table)), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees. if (!$status || strtolower($status['Engine'] ?? '') !== 'innodb') { self::fail(__('Relationship conversion requires transactional taxonomy tables.', 'customfields-com')); } } // SERIALIZABLE workflow transactions re-read these exact rows, retaining insertion-gap locks. $rows = $wpdb->get_results($wpdb->prepare("SELECT r.term_taxonomy_id, r.term_order, t.term_id, t.taxonomy FROM {$wpdb->term_relationships} r INNER JOIN {$wpdb->term_taxonomy} t ON r.term_taxonomy_id=t.term_taxonomy_id WHERE r.object_id=%d AND t.taxonomy=%s ORDER BY r.term_taxonomy_id LIMIT 101", $id, $taxonomy), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. if (!is_array($rows) || $wpdb->last_error || count($rows) > 100) { self::fail(__('The authoritative taxonomy assignment list is unavailable or too large.', 'customfields-com')); } $snapshots['@term_relationships/' . $taxonomy] = $rows; $ids = array_map('intval', array_column($rows, 'term_id')); // Match ACF's saved-order overlay. Missing assigned terms sort before saved positions. if ($saved !== '' && $saved !== null && $saved !== false && $saved !== array()) { $saved = is_array($saved) ? $saved : array($saved); $order = array(); foreach ($ids as $i => $term_id) { $order[$i] = array_search($term_id, $saved); } if ($ids) { array_multisort($order, $ids); } } if ($def['type'] !== 'relationship' && count($ids) > 1) { self::fail(__('Multiple assigned terms do not fit a single-term destination. Choose a multiple relationship field.', 'customfields-com')); } return self::reference($def['type'] === 'relationship' ? $ids : ($ids ? $ids[0] : ''), $def); } private static function leaf($value, $def) { if (!self::safe($value)) { self::fail(__('The source value is unsafe or too large.', 'customfields-com')); } if ($value !== '' && $value !== null && in_array($def['source_type'], array('date_picker', 'date_time_picker', 'time_picker'), true)) { if (!is_string($value)) { self::fail(__('A source date or time must use the original plugin storage format.', 'customfields-com')); } if ($def['source_type'] === 'date_picker') { if (!preg_match('/^(\d{4})(\d{2})(\d{2})$/D', $value, $parts) || !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) { self::fail(__('The source date is not a real calendar date in YYYYMMDD format.', 'customfields-com')); } $value = $parts[1] . '-' . $parts[2] . '-' . $parts[3]; } elseif ($def['source_type'] === 'date_time_picker') { if (!preg_match('/^(\d{4})-(\d{2})-(\d{2}) ([01]\d|2[0-3]):([0-5]\d):00$/D', $value, $parts) || !checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) { self::fail(__('Date-time conversion requires YYYY-MM-DD HH:MM:00. Nonzero seconds are preserved at the source and refused, not truncated.', 'customfields-com')); } $value = $parts[1] . '-' . $parts[2] . '-' . $parts[3] . 'T' . $parts[4] . ':' . $parts[5]; } else { if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d):00$/D', $value, $parts)) { self::fail(__('Time conversion requires HH:MM:00. Nonzero seconds are preserved at the source and refused, not truncated.', 'customfields-com')); } $value = $parts[1] . ':' . $parts[2]; } } if ($value !== '' && $value !== null && in_array($def['source_type'], array('date', 'datetime', 'time'), true)) { $defaults = array('date' => 'Y-m-d', 'datetime' => 'Y-m-d H:i', 'time' => 'H:i'); $formats = array('date' => array('Y-m-d', 'Ymd', 'd/m/Y', 'm/d/Y', 'd.m.Y'), 'datetime' => array('Y-m-d H:i', 'Y-m-d H:i:s', 'Y-m-d\\TH:i', 'Y-m-d\\TH:i:s'), 'time' => array('H:i', 'H:i:s')); $format = !empty($def['save_format']) ? $def['save_format'] : ($def['php_format'] ?? $defaults[$def['source_type']]); if (!empty($def['timestamp'])) { if ((!is_int($value) && !is_string($value)) || !preg_match('/^-?(0|[1-9][0-9]{0,11})$/D', (string) $value)) { self::fail(__('Timestamp storage must be a whole Unix timestamp.', 'customfields-com')); } if ((string) $value === '0') { return ''; } // Meta Box treats timestamp zero as an empty selection. $date = (new \DateTimeImmutable('@' . $value))->setTimezone(new \DateTimeZone('UTC')); if ($date->format('s') !== '00' || $date->format('Y') < '0001' || $date->format('Y') > '9999' || ($def['source_type'] === 'date' && $date->format('H:i') !== '00:00')) { self::fail(__('This timestamp contains precision the destination cannot retain. Seconds and hidden date times are not truncated.', 'customfields-com')); } // Meta Box's own from_timestamp() displays gmdate(), independent of the site's timezone. return $date->format($def['source_type'] === 'date' ? 'Y-m-d' : ($def['source_type'] === 'time' ? 'H:i' : 'Y-m-d\\TH:i')); } if (!is_string($value) || !in_array($format, $formats[$def['source_type']], true)) { self::fail(__('This Meta Box date storage format needs a dedicated adapter. Only explicitly registered supported numeric formats are converted.', 'customfields-com')); } $date = \DateTimeImmutable::createFromFormat('!' . $format, $value, new \DateTimeZone('UTC')); $errors = \DateTimeImmutable::getLastErrors(); if (!$date || ($errors && ($errors['warning_count'] || $errors['error_count'])) || $date->format($format) !== $value || (strpos($format, 's') !== false && $date->format('s') !== '00')) { self::fail(__('Invalid dates and nonzero seconds are preserved at the source and refused, without guessing or truncation.', 'customfields-com')); } $value = $date->format($def['source_type'] === 'date' ? 'Y-m-d' : ($def['source_type'] === 'time' ? 'H:i' : 'Y-m-d\\TH:i')); } if (isset($def['reference_object'])) { return self::reference($value, $def); } if (in_array($def['type'], array('multiselect', 'checkboxes', 'gallery'), true)) { if ($value === '') { $value = array(); } if (!is_array($value) || array_values($value) !== $value || count($value) > 100) { self::fail(__('Expected an ordered scalar collection of at most 100 values.', 'customfields-com')); } foreach ($value as $item) { if (!is_scalar($item)) { self::fail(__('Nested values are unsupported in this collection.', 'customfields-com')); } } } elseif (!is_scalar($value) && $value !== null) { self::fail(__('A scalar source field contains structured data.', 'customfields-com')); } if (in_array($def['type'], array('select', 'radio', 'multiselect', 'checkboxes'), true)) { if (!is_array($def['choices'])) { self::fail(__('Dynamic choice callbacks are not supported.', 'customfields-com')); } foreach (is_array($value) ? $value : array($value) as $choice) { if ($choice !== '' && !array_key_exists((string) $choice, $def['choices'])) { self::fail(__('A source choice is absent from its original definition.', 'customfields-com')); } } } return $value; } private static function mb_value($value, $def) { if (++self::$nodes > self::MAX_NODES) { self::fail(__('The nested value limit was exceeded.', 'customfields-com')); } if (isset($def['clone_leaf'])) { if (!is_array($value) || array_values($value) !== $value || count($value) > 100) { self::fail(__('Cloned values require an ordered list of at most 100 items.', 'customfields-com')); } $rows = array(); foreach ($value as $item) { $rows[] = array('value' => self::leaf($item, $def['clone_leaf'])); } return $rows; } if (!in_array($def['type'], array('group', 'repeater'), true)) { return self::leaf($value, $def); } if (!is_array($value)) { self::fail(__('Meta Box groups must use serialized arrays.', 'customfields-com')); } $rows = $def['type'] === 'repeater' ? $value : array($value); if (array_values($rows) !== $rows || count($rows) > 100) { self::fail(__('Meta Box repeaters need sequential rows, with at most 100.', 'customfields-com')); } $result = array(); foreach ($rows as $row) { if (!is_array($row) || array_diff(array_keys($row), array_keys($def['children'])) || array_diff(array_keys($def['children']), array_keys($row))) { self::fail(__('Missing or extra Meta Box child values need manual review; nothing was defaulted or dropped.', 'customfields-com')); } $clean = array(); foreach ($def['children'] as $name => $child) { $clean[$name] = self::mb_value($row[$name], $child); } $result[] = $clean; } return $def['type'] === 'repeater' ? $result : $result[0]; } private static function reference($value, $def) { $multiple = $def['type'] === 'relationship'; if ($value === '' || $value === null || $value === false) { return $multiple ? array() : ''; } $values = $multiple ? $value : array($value); if (!is_array($values) || array_values($values) !== $values || count($values) > 100) { self::fail(__('References require an ordered ID list, without returned objects or URLs.', 'customfields-com')); } $result = array(); foreach ($values as $id) { if (!is_scalar($id) || !preg_match('/^[1-9][0-9]*$/D', (string) $id) || (float) $id > PHP_INT_MAX || in_array((int) $id, $result, true)) { self::fail(__('Reference IDs must be positive, distinct integers; duplicates are never silently removed.', 'customfields-com')); } $id = (int) $id; $kind = $def['reference_object']; if ($kind === 'post') { $object = get_post($id); $types = array_values(array_filter((array) ($def['post_type'] ?? array()), 'strlen')); $statuses = array_values(array_filter((array) ($def['post_status'] ?? array()), 'strlen')); if (!$object || !current_user_can('read_post', $id) || ($types && !in_array($object->post_type, $types, true)) || ($statuses && !in_array($object->post_status, $statuses, true))) { self::fail(__('A source post reference is unavailable or outside its registered scope.', 'customfields-com')); } } elseif ($kind === 'user') { $object = get_userdata($id); $roles = array_values(array_filter((array) ($def['role'] ?? array()), 'strlen')); if (!$object || (!current_user_can('list_users') && get_current_user_id() !== $id) || ($roles && !array_intersect($roles, $object->roles))) { self::fail(__('A source user reference is unavailable or outside its registered roles.', 'customfields-com')); } } else { $object = get_term($id); $taxonomy = $object && !is_wp_error($object) ? get_taxonomy($object->taxonomy) : null; $scope = array_values(array_filter((array) ($def['taxonomy'] ?? array()), 'strlen')); if (!$taxonomy || !current_user_can($taxonomy->cap->assign_terms) || ($scope && !in_array($object->taxonomy, $scope, true))) { self::fail(__('A source term reference is unavailable or outside its registered taxonomies.', 'customfields-com')); } } $result[] = $id; } return $multiple ? $result : $result[0]; } /** Resolve table identity only from one applicable active Meta Box registration, never request input. */ private static function mb_table($id, $target, $key, $source) { if (!function_exists('rwmb_get_registry') || !function_exists('rwmb_check_meta_box_supports')) { return null; } $boxes = rwmb_get_registry('meta_box')->get_by(array('object_type' => $target)); if (!is_array($boxes) || count($boxes) > 100) { self::fail(__('The source registry is too large to review safely.', 'customfields-com')); } array_walk($boxes, 'rwmb_check_meta_box_supports', array($target, $id)); $matches = array(); foreach (array_filter($boxes) as $box) { foreach ((array) $box->fields as $field) { if (($field['id'] ?? '') === $key) { $matches[] = $box; } } } if (count($matches) !== 1) { self::fail(__('The Meta Box source must belong to exactly one applicable registered group.', 'customfields-com')); } $box = $matches[0]; $config = $box->meta_box; if (($config['storage_type'] ?? '') !== 'custom_table') { return null; } if (!class_exists('MetaBox\\CustomTable\\API') || !is_array($source) || !is_object($source['storage'] ?? null) || !self::name($key)) { self::fail(__('Activate MB Custom Table with its original registered storage before reviewing these values.', 'customfields-com')); } $storage_class = get_class($source['storage']); if (strpos($storage_class, 'MetaBox\\CustomTable\\') !== 0 && $storage_class !== 'RWMB_Custom_Table_Storage') { self::fail(__('The registered field is not using the MB Custom Table storage adapter.', 'customfields-com')); } global $wpdb; $table = $config['table'] ?? ''; if (!is_string($table) || !preg_match('/^[A-Za-z0-9_]{1,64}$/D', $table) || in_array($table, $wpdb->tables('all', true), true) || (class_exists('CustomFields\\ContentStore') && $table === ContentStore::table())) { self::fail(__('Only a dedicated registered table can be a migration source.', 'customfields-com')); } $status = $wpdb->get_row($wpdb->prepare('SHOW TABLE STATUS LIKE %s', $wpdb->esc_like($table)), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees. if (!$status || strtolower($status['Engine'] ?? '') !== 'innodb') { self::fail(__('The registered source table must use InnoDB so reviewed source rows stay locked during each destination transaction.', 'customfields-com')); } $columns = $wpdb->get_results($wpdb->prepare("SHOW COLUMNS FROM %i", $table), ARRAY_A); $indexes = $wpdb->get_results($wpdb->prepare("SHOW INDEX FROM %i", $table), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inspect/provision the actual database schema; cached application metadata cannot establish storage-engine or column guarantees. if (!is_array($columns) || !is_array($indexes) || $wpdb->last_error) { self::fail(__('The registered source table schema cannot be inspected.', 'customfields-com')); } $column_map = array_column($columns, null, 'Field'); $primary = array_values(array_filter($indexes, static function($index) { return $index['Key_name'] === 'PRIMARY'; })); if (!isset($column_map['ID'], $column_map[$key]) || count($primary) !== 1 || $primary[0]['Column_name'] !== 'ID' || !preg_match('/^(tinyint|smallint|mediumint|int|bigint)\b/i', $column_map['ID']['Type']) || !empty($column_map[$key]['Extra'])) { self::fail(__('The source needs one integer primary ID and a direct field-ID column. Composite IDs, generated columns and alternate mappings are unsupported.', 'customfields-com')); } return array('table' => $table, 'field' => $key, 'group' => $config['id'] ?? '', 'object' => $target, 'storage_class' => get_class($source['storage']), 'columns_hash' => hash('sha256', serialize($columns)), 'indexes_hash' => hash('sha256', serialize(array_map(static function($index) { return array_intersect_key($index, array_flip(array('Key_name', 'Non_unique', 'Seq_in_index', 'Column_name', 'Sub_part', 'Index_type'))); }, $indexes)))); } private static function table_rows($id, $key, &$snapshots) { if (!\CustomFields\Editor::can_edit(self::$target, $id, $key)) { self::fail(__('Source field permissions block this table conversion.', 'customfields-com')); } global $wpdb; $table = self::$table_context['table']; $rows = $wpdb->get_results($wpdb->prepare("SELECT ID AS meta_id, LEFT(CAST(%i AS BINARY), %d) AS meta_value FROM %i WHERE ID = %d LIMIT 2", $key, self::MAX_BYTES + 1, $table, $id), ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read authoritative workflow/metadata state for validation and conflict detection; cached values can miss concurrent edits or duplicate rows. if (!is_array($rows) || $wpdb->last_error || count($rows) > 1) { self::fail(__('The registered source row is unavailable or ambiguous.', 'customfields-com')); } foreach ($rows as &$row) { $row['meta_id'] = (int) $row['meta_id']; self::$bytes += strlen((string) $row['meta_value']); if (self::$bytes > self::MAX_BYTES) { self::fail(__('The source table value exceeds 1 MB.', 'customfields-com')); } } unset($row); $snapshots['@registered_table/' . $table . '/' . $key] = $rows; return $rows; } /** Options storage is derived from accessible registered pages, never an arbitrary request prefix. */ private static function option_pages() { if (!function_exists('acf_get_options_pages') || !function_exists('acf_decode_post_id')) { return array(); } $pages = array(); foreach (array_slice((array) acf_get_options_pages(), 0, 100) as $page) { if (!is_array($page) || !is_string($page['menu_slug'] ?? null) || !preg_match('/^[a-zA-Z0-9_-]{1,100}$/D', $page['menu_slug']) || !is_string($page['capability'] ?? 'manage_options') || !current_user_can($page['capability'] ?? 'manage_options')) { continue; } $post_id = $page['post_id'] ?? 'options'; if ($post_id === 'option') { $post_id = 'options'; } if (!is_string($post_id)) { continue; } $decoded = acf_decode_post_id($post_id); if (($decoded['type'] ?? '') !== 'option' || !is_string($decoded['id'] ?? null) || !preg_match('/^[a-zA-Z][a-zA-Z0-9_-]{0,119}$/D', $decoded['id'])) { continue; } $page['_cf_prefix'] = $decoded['id']; $pages[] = $page; } return $pages; } private static function source_groups($target, $id, $page_slug = '') { if ($target !== 'options') { if ($page_slug !== '') { self::fail(__('An options-page source must use the site-options location.', 'customfields-com')); } return array_slice((array) acf_get_field_groups(self::screen($target, $id)), 0, 101); } if ((int) $id !== 1) { self::fail(__('Use the registered site-options workspace.', 'customfields-com')); } $groups = array(); foreach (self::option_pages() as $page) { if ($page_slug !== '' ? $page['menu_slug'] !== $page_slug : $page['_cf_prefix'] !== 'options') { continue; } foreach ((array) acf_get_field_groups(array('options_page' => $page['menu_slug'])) as $group) { $group['_cf_options'] = array('prefix' => $page['_cf_prefix'], 'page' => $page_slug, 'capability' => $page['capability'] ?? 'manage_options'); $groups[$group['key'] . ':' . $page['_cf_prefix']] = $group; } if (count($groups) > 100) { self::fail(__('Too many matching options groups.', 'customfields-com')); } } if (!$groups) { self::fail(__('Choose an accessible registered options page. Arbitrary options and pages that store into another object are not supported.', 'customfields-com')); } return array_values($groups); } private static function screen($target, $id) { if ($target === 'user') { return array('user_id' => $id, 'user_form' => 'edit'); } if ($target === 'term') { $term = get_term($id); return array('taxonomy' => $term->taxonomy, 'term_id' => $id); } return array('post_id' => $id); } public static function source($previous, $mode, $key, $post_id, $destination, $target = 'post') { if (!in_array($mode, array('acf_structured', 'metabox'), true)) { return $previous; } try { if (!in_array($target, array('post', 'user', 'term', 'options'), true) || !current_user_can('manage_options') || ($target !== 'options' && !\CustomFields\Editor::can_edit($target, $post_id)) || !is_string($key)) { self::fail(__('Administrator and object-editing access are required.', 'customfields-com')); } self::$nodes = 0; self::$bytes = 0; self::$target = $target; self::$table_context = null; self::$option_context = null; $snapshots = array(); if ($mode === 'acf_structured') { if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields') || !function_exists('acf_get_field')) { self::fail(__('Activate ACF or Secure Custom Fields with the original field definitions.', 'customfields-com')); } $selection = explode('@options:', $key); if (count($selection) > 2 || (isset($selection[1]) && !preg_match('/^[a-zA-Z0-9_-]{1,100}$/D', $selection[1]))) { self::fail(__('The registered options selection is invalid.', 'customfields-com')); } $key = $selection[0]; $page_slug = $selection[1] ?? ''; $source = acf_get_field($key); $found = 0; foreach (self::source_groups($target, $post_id, $page_slug) as $group) { if (isset($group['active']) && !$group['active']) { continue; } foreach (self::acf_group_fields($group) as $candidate) { if (($candidate['key'] ?? '') === $key && ($candidate['name'] ?? '') === ($source['name'] ?? null)) { $found++; self::$option_context = $group['_cf_options'] ?? null; } } } if ($found !== 1 || !is_array($source) || ($source['key'] ?? '') !== $key) { self::fail(__('Select one applicable, active, top-level source field.', 'customfields-com')); } $def = self::definition($source, $destination, $mode); if (self::$option_context) { $def['registered_options'] = self::$option_context; } if (!self::safe($def)) { self::fail(__('Dynamic callbacks or objects in source definitions cannot be journaled.', 'customfields-com')); } if ($def['name'] === $destination['name']) { self::fail(__('The destination must have a different name so original content is preserved.', 'customfields-com')); } $value = self::acf_value($def, $post_id, $def['name'], $snapshots, true); } else { if ($target === 'options') { self::fail(__('Meta Box settings-page containers require a dedicated adapter. Registered ACF / SCF default options storage and native copies are available.', 'customfields-com')); } if (!function_exists('rwmb_get_object_fields')) { self::fail(__('Activate Meta Box with the original field definitions.', 'customfields-com')); } $fields = rwmb_get_object_fields($post_id, $target); $source = $fields[$key] ?? null; self::$table_context = self::mb_table($post_id, $target, $key, $source); if (!is_array($source)) { self::fail(__('The Meta Box source is not registered for this object.', 'customfields-com')); } $def = self::definition($source, $destination, $mode); if (!self::safe($def)) { self::fail(__('Dynamic callbacks or objects in source definitions cannot be journaled.', 'customfields-com')); } if ($def['name'] === $destination['name']) { self::fail(__('Use a separately named destination to preserve the original values.', 'customfields-com')); } if (self::$table_context) { $def['registered_table'] = self::$table_context; $rows = self::table_rows($post_id, $key, $snapshots); $value = !$rows || $rows[0]['meta_value'] === null ? null : self::decode($rows[0]['meta_value']); if ($value !== null) { if ($def['source_type'] === 'taxonomy_advanced' && $def['type'] === 'relationship' && is_string($value) && preg_match('/^[1-9][0-9]*(,[1-9][0-9]*)*$/D', $value)) { $value = explode(',', $value); } $value = self::mb_value($value, $def); } } else { $multiple_rows = (!empty($source['clone']) && !empty($source['clone_as_multiple'])) || (empty($source['clone']) && (!empty($source['multiple']) || in_array($def['type'], array('gallery', 'multiselect', 'checkboxes', 'relationship'), true))); $rows = self::rows($post_id, $def['name'], $snapshots, $multiple_rows); if (!$rows) { $value = null; } elseif ($multiple_rows) { $values = array(); foreach ($rows as $row) { $values[] = self::decode($row['meta_value']); } $value = self::mb_value($values, $def); } else { $value = self::mb_value(self::decode($rows[0]['meta_value']), $def); } } } if (array_key_exists($destination['name'], $snapshots)) { self::fail(__('The destination overlaps source storage. Choose another name.', 'customfields-com')); } return array('definition' => $def, 'snapshots' => $snapshots, 'value' => $value); } catch (\Throwable $e) { return new \WP_Error('customfields_converter', $e->getMessage()); } } private static function hint($field, $provider) { if ($provider !== 'metabox') { if (($field['type'] ?? '') === 'clone') { return __('Choose a Group destination. Its children must use the original names of the cloned fields; source prefixes are resolved automatically.', 'customfields-com'); } if (($field['type'] ?? '') === 'link') { return __('Choose a Group with url (URL), title (text) and target (text or select). All three link values will be copied.', 'customfields-com'); } if (($field['type'] ?? '') === 'google_map') { return __('Choose a Group with every stored map property. Use number fields for lat, lng and zoom, and text fields for address details. No map service is contacted.', 'customfields-com'); } if (!empty($field['load_terms'])) { return __('Copies the terms currently assigned to each post. Original WordPress term assignments stay unchanged.', 'customfields-com'); } } elseif (!empty($field['timestamp'])) { return __('Copies the date or date-time shown by Meta Box in UTC. Timestamps with seconds or hidden date times that cannot fit the destination are refused.', 'customfields-com'); } return ''; } public static function choices($items) { if (function_exists('acf_get_field_groups') && function_exists('acf_get_fields')) { $count = 0; foreach (array_slice((array) acf_get_field_groups(), 0, 100) as $group) { if (isset($group['active']) && !$group['active']) { continue; } foreach (self::acf_group_fields($group) as $field) { if (++$count > 500) { break 2; } $type = self::target_type($field, 'acf_structured'); if ($type && is_string($field['key'] ?? null)) { $items[] = array('mode' => 'acf_structured', 'value' => $field['key'], 'label' => 'ACF / SCF — ' . ($field['label'] ?? $field['name']), 'hint' => self::hint($field, 'acf_structured'), 'type' => $type); } } } } if (function_exists('acf_get_field_groups') && function_exists('acf_get_fields')) { $count = 0; foreach (self::option_pages() as $page) { foreach ((array) acf_get_field_groups(array('options_page' => $page['menu_slug'])) as $group) { if (isset($group['active']) && !$group['active']) { continue; } foreach (self::acf_group_fields($group) as $field) { if (++$count > 500) { break 3; } $type = self::target_type($field, 'acf_structured'); if ($type && is_string($field['key'] ?? null)) { $items[] = array('target' => 'options', 'mode' => 'acf_structured', 'value' => $field['key'] . '@options:' . $page['menu_slug'], 'label' => 'ACF / SCF — ' . ($page['page_title'] ?? $page['menu_slug']) . ' / ' . ($field['label'] ?? $field['name']), 'hint' => __('Uses this registered page’s options storage. Only the separate destination is changed.', 'customfields-com') . ' ' . self::hint($field, 'acf_structured'), 'type' => $type); } } } } } if (function_exists('rwmb_get_object_fields')) { $seen = array(); $count = 0; foreach (array('post' => array_slice(array_values(get_post_types(array('show_ui' => true))), 0, 100), 'user' => array(get_current_user_id()), 'term' => array_slice(array_values(get_taxonomies(array('show_ui' => true))), 0, 100)) as $target => $objects) { foreach ($objects as $post_type) { foreach ((array) rwmb_get_object_fields($post_type, $target) as $field) { if (++$count > 500) { break 2; } $type = self::target_type($field, 'metabox'); $key = $field['id'] ?? ''; if ($type && self::name($key) && !isset($seen[$target . ':' . $key])) { $seen[$target . ':' . $key] = true; $items[] = array('target' => $target, 'mode' => 'metabox', 'value' => $key, 'label' => 'Meta Box (' . $target . ') — ' . ($field['name'] ?? $key), 'hint' => self::hint($field, 'metabox'), 'type' => $type); } } } } } return $items; } }