= self::MAX_DEPTH) { return self::error(__('Composite fields support at most three container levels.', 'customfields-com')); } $children = $raw['sub_fields'] ?? array(); if (is_string($children)) { $children = json_decode($children, true); } if (!is_array($children) || count($children) < 1 || count($children) > self::MAX_CHILDREN) { return self::error(__('A repeater or group must contain 1–20 child fields.', 'customfields-com')); } $normalized['sub_fields'] = array(); $names = array(); $keys = array(); foreach ($children as $child) { if (!is_array($child) || !in_array($child['type'] ?? '', apply_filters('customfields_pro_child_types', self::CHILD_TYPES), true)) { return self::error(__('This child field type is not supported.', 'customfields-com')); } $field = \CustomFields\Schema::validate_field($child, $depth + 1); if (is_wp_error($field)) { return $field; } if (isset($names[$field['name']]) || isset($keys[$field['key']])) { return self::error(__('Child field names and keys must be unique within their parent.', 'customfields-com')); } $names[$field['name']] = true; $keys[$field['key']] = true; $normalized['sub_fields'][] = $field; } $conditions = \CustomFields\Conditions::validate_fields($normalized['sub_fields']); if (is_wp_error($conditions)) { return $conditions; } if (self::schema_size($normalized) > self::MAX_SCHEMA_FIELDS) { return self::error(__('A composite field may contain at most 200 total nested fields.', 'customfields-com')); } if ($normalized['type'] === 'repeater') { foreach (array('min_rows' => 0, 'max_rows' => 20) as $key => $default) { $value = $raw[$key] ?? $default; if (filter_var($value, FILTER_VALIDATE_INT) === false || (int) $value < 0 || (int) $value > self::MAX_ROWS) { return self::error(__('Repeater row limits must be integers between 0 and 100.', 'customfields-com')); } $normalized[$key] = (int) $value; } if ($normalized['max_rows'] < 1 || $normalized['min_rows'] > $normalized['max_rows']) { return self::error(__('The maximum row count must be 1–100 and at least the minimum.', 'customfields-com')); } } $normalized['default'] = array(); if (self::minimum_size($normalized) > self::MAX_VALUE_NODES) { return self::error(__('The minimum rows and nested templates exceed 4,000 field controls. Reduce the minimum rows or nesting.', 'customfields-com')); } return $normalized; } /** Project automatic controls, including inert templates, before constructing HTML. */ public static function minimum_size($field) { return self::render_size($field, $field['default'] ?? null); } /** Saturation keeps legacy or programmatically registered schemas bounded too. */ public static function render_size($field, $value = null, $depth = 0) { $limit = self::MAX_VALUE_NODES; if (!is_array($field) || $depth > self::MAX_DEPTH) { return $limit + 1; } $type = $field['type'] ?? ''; if ($type === 'gallery') { return 1 + (is_array($value) ? count($value) : 0); } if (!in_array($type, array('group', 'clone', 'repeater', 'flexible'), true)) { return 1; } $row_size = static function($children, $row) use ($depth, $limit) { if (!is_array($children) || count($children) > self::MAX_CHILDREN) { return $limit + 1; } $total = 1; foreach ($children as $child) { $name = $child['name'] ?? ''; $total += self::render_size($child, is_array($row) && array_key_exists($name, $row) ? $row[$name] : ($child['default'] ?? null), $depth + 1); if ($total > $limit) { return $limit + 1; } } return $total; }; if ($type === 'group' || $type === 'clone') { return $row_size($field['sub_fields'] ?? array(), $value); } $minimum = max(0, (int) ($field['min_rows'] ?? 0)); $rows = is_array($value) ? array_values($value) : array(); if ($minimum > self::MAX_ROWS || count($rows) > self::MAX_ROWS) { return $limit + 1; } $layouts = $type === 'flexible' ? ($field['layouts'] ?? array()) : array(); if ($type === 'flexible' && (!is_array($layouts) || !$layouts || count($layouts) > 12)) { return $limit + 1; } foreach ($layouts as $layout) { if (!is_array($layout) || !is_string($layout['name'] ?? null)) { return $limit + 1; } } $first = $layouts ? reset($layouts) : array(); $by_name = $layouts ? array_column($layouts, null, 'name') : array(); while (count($rows) < $minimum) { $rows[] = $type === 'flexible' ? array('__layout' => $first['name'] ?? '') : array(); } // A repeater renders one blank template; flexible adds one for every layout. $total = 1 + $row_size($type === 'flexible' ? ($first['sub_fields'] ?? array()) : ($field['sub_fields'] ?? array()), array()); foreach ($layouts as $layout) { $total += $row_size($layout['sub_fields'] ?? array(), array()); if ($total > $limit) { return $limit + 1; } } foreach ($rows as $row) { if ($type === 'flexible' && (!is_array($row) || !is_string($row['__layout'] ?? null) || !isset($by_name[$row['__layout']]))) { return $limit + 1; } $children = $type === 'flexible' ? $by_name[$row['__layout']]['sub_fields'] : ($field['sub_fields'] ?? array()); $total += $row_size($children, $row); if ($total > $limit) { return $limit + 1; } } return min($limit + 1, $total); } private static function schema_size($field) { $size = 1; foreach ($field['sub_fields'] ?? array() as $child) { $size += self::schema_size($child); } foreach ($field['layouts'] ?? array() as $layout) { foreach ($layout['sub_fields'] ?? array() as $child) { $size += self::schema_size($child); } } return $size; } public static function validate_group($group, $raw) { if (is_wp_error($group)) { return $group; } $minimum = 0; foreach ($group['fields'] as $field) { $minimum += self::minimum_size($field); if ($minimum > self::MAX_VALUE_NODES) { return self::error(__('This group would automatically render more than 4,000 field controls. Reduce its minimum rows or split the group.', 'customfields-com')); } } if (($group['target'] ?? '') !== 'options') { return $group; } $page = $raw['options_page'] ?? array(); if (!is_array($page) || array_diff(array_keys($page), array('slug', 'title', 'capability'))) { return self::error(__('Options-page settings are invalid.', 'customfields-com')); } $slug = $page['slug'] ?? 'site'; $title = $page['title'] ?? __('Site options', 'customfields-com'); $cap = $page['capability'] ?? 'manage_options'; if (!is_string($slug) || !preg_match('/^[a-z][a-z0-9_-]{0,39}$/D', $slug) || !is_string($title) || trim($title) === '' || strlen($title) > 80) { return self::error(__('Use an options page slug of 1–40 lowercase characters and a title of 1–80 characters.', 'customfields-com')); } if (!in_array($cap, self::option_capabilities(), true)) { return self::error(__('Choose an explicitly supported options-page capability.', 'customfields-com')); } $group['options_page'] = array('slug' => $slug, 'title' => sanitize_text_field($title), 'capability' => $cap); return $group; } public static function option_capabilities() { return array('manage_options', 'edit_theme_options', 'edit_others_posts', 'edit_others_pages'); } /** Compatibility alias: conditional visibility now belongs to the Free plugin. */ public static function is_active($active, $field, $submitted_values, $post_id = 0) { return class_exists('CustomFields\\Conditions') ? \CustomFields\Conditions::is_active($active, $field, $submitted_values, $post_id) : $active; } public static function conditions($field, $value, $input_name) { if (class_exists('CustomFields\\Conditions')) { \CustomFields\Conditions::render($field, $value, $input_name); } } public static function storage_error($error, $value, $field) { if (is_wp_error($error) || !in_array($field['type'] ?? '', array('repeater', 'group', 'gallery'), true)) { return $error; } return self::compatible_storage($value, $field) ? null : new \WP_Error('customfields_repeater_storage', __('Existing structured data does not match this schema. Restore the previous schema or migrate its stored values before editing.', 'customfields-com')); } public static function sanitize($clean, $field, $raw = null, $context = array()) { if (!in_array($field['type'] ?? '', array('repeater', 'group', 'gallery'), true)) { return $clean; } if (self::$sanitize_depth === 0) { self::$value_nodes = 0; } self::$sanitize_depth++; try { if (++self::$value_nodes > self::MAX_VALUE_NODES) { return self::error(__('This field exceeds the limit of 4,000 nested values.', 'customfields-com')); } if (self::$sanitize_depth > self::MAX_DEPTH + 1) { return self::error(__('Nested values exceed the supported depth.', 'customfields-com')); } if ($raw === '' || $raw === null) { $raw = array(); } if ($field['type'] === 'gallery') { self::$value_nodes += is_array($raw) ? count($raw) : 0; if (self::$value_nodes > self::MAX_VALUE_NODES) { return self::error(__('This field exceeds the limit of 4,000 nested values.', 'customfields-com')); } return self::gallery_value($raw, $field, empty($field['_validating_default'])); } if (!is_array($raw) || array_key_exists('__storage_conflict', $raw)) { return new \WP_Error('customfields_repeater', __('Structured values must have a compatible array shape.', 'customfields-com')); } if (!empty($context['has_stored_value']) && !is_array($context['stored_value'] ?? null)) { return self::error(__('The original structured value has an incompatible shape. Restore its schema or migrate it before editing.', 'customfields-com')); } $stored = !empty($context['has_stored_value']) ? $context['stored_value'] : array(); if (empty($field['_validating_default']) && !empty($context['has_stored_value']) && self::has_nested_conditions($field)) { if (!is_string($raw['__cfp_fingerprint'] ?? null) || !hash_equals(self::fingerprint($stored), $raw['__cfp_fingerprint'])) { return self::error(__('These structured values changed after this form opened. Reload before saving so hidden values stay attached to the correct row.', 'customfields-com')); } } unset($raw['__present'], $raw['__cfp_fingerprint']); if (!empty($field['_validating_default']) && !$raw) { return array(); } if ($field['type'] === 'group') { return self::sanitize_children($raw, $field, $stored); } $min = max(!empty($field['required']) ? 1 : 0, $field['min_rows']); if (count($raw) < $min || count($raw) > $field['max_rows']) { return new \WP_Error('customfields_repeater', sprintf(/* translators: 1: Minimum row count; 2: maximum row count. */ __('Repeater requires between %1$d and %2$d rows.', 'customfields-com'), $min, $field['max_rows'])); } $rows = array(); foreach ($raw as $index => $row) { if (!ctype_digit((string) $index)) { return self::error(__('Repeater row indexes must be nonnegative integers.', 'customfields-com')); } if (!is_array($row)) { return self::error(__('A repeater row must be an object.', 'customfields-com')); } $has_source = array_key_exists('__cfp_source', $row); $source = $row['__cfp_source'] ?? null; if (self::has_nested_conditions($field) && !empty($context['has_stored_value']) && !$has_source) { return self::error(__('This row is missing its original identity. Reload the form before saving.', 'customfields-com')); } if ($has_source && $source !== '' && ((!is_int($source) && !is_string($source)) || !ctype_digit((string) $source) || !array_key_exists((int) $source, $stored))) { return self::error(__('A repeater row references an unavailable original row.', 'customfields-com')); } $previous = $has_source ? ($source === '' ? array() : $stored[(int) $source]) : ($stored[$index] ?? array()); unset($row['__cfp_source']); $value = self::sanitize_children($row, $field, is_array($previous) ? $previous : array()); if (is_wp_error($value)) { return $value; } $rows[] = $value; } return $rows; } finally { self::$sanitize_depth--; } } public static function has_nested_conditions($field) { foreach ($field['sub_fields'] ?? array() as $child) { if (!empty($child['conditional_logic']) || self::has_nested_conditions($child)) { return true; } } foreach ($field['layouts'] ?? array() as $layout) { if (self::has_nested_conditions($layout)) { return true; } } return false; } public static function fingerprint($value) { return hash('sha256', wp_json_encode($value)); } private static function sanitize_children($row, $field, $stored = array()) { if (!is_array($row) || array_diff(array_keys($row), array_column($field['sub_fields'], 'name'))) { return self::error(__('Structured data contains an invalid row or an unknown child field.', 'customfields-com')); } $output = array(); $by_name = array_column($field['sub_fields'], null, 'name'); $visiting = array(); $resolve = function ($name) use (&$resolve, &$output, &$visiting, $by_name, $row, $stored) { if (array_key_exists($name, $output)) { return $output[$name]; } if (isset($visiting[$name]) || !isset($by_name[$name])) { return self::error(__('Nested visibility has an invalid dependency.', 'customfields-com')); } $visiting[$name] = true; $child = $by_name[$name]; if (++self::$value_nodes > self::MAX_VALUE_NODES) { return self::error(__('This field exceeds the limit of 4,000 nested values.', 'customfields-com')); } $values = $row; foreach ($child['conditional_logic'] ?? array() as $rule) { $controller = $resolve($rule['field']); if (is_wp_error($controller)) { return $controller; } $values[$rule['field']] = $controller; } if (!\CustomFields\Conditions::is_active(true, $child, $values, 0, array_column($by_name, 'type', 'name'))) { $output[$name] = array_key_exists($name, $stored) ? $stored[$name] : ($child['default'] ?? ''); unset($visiting[$name]); return $output[$name]; } if (!array_key_exists($name, $row)) { return self::error(__('A nested field is missing from this submission. No structured values were saved.', 'customfields-com')); } $value = \CustomFields\Fields::sanitize($row[$name], $child, array('has_stored_value' => array_key_exists($name, $stored), 'stored_value' => $stored[$name] ?? null)); if (is_wp_error($value)) { return new \WP_Error('customfields_repeater', $child['label'] . ': ' . $value->get_error_message()); } $output[$name] = $value; unset($visiting[$name]); return $value; }; foreach ($field['sub_fields'] as $child) { $value = $resolve($child['name']); if (is_wp_error($value)) { return $value; } } // Schema order keeps exports and row summaries stable regardless of dependency order. $ordered = array(); foreach ($field['sub_fields'] as $child) { $ordered[$child['name']] = $output[$child['name']]; } return $ordered; } private static function gallery_value($raw, $field, $check_permission = false) { if (!is_array($raw)) { return self::error(__('Gallery values must be an array of image attachment IDs.', 'customfields-com')); } unset($raw['__present']); if (count($raw) > 100) { return self::error(__('A gallery supports up to 100 images.', 'customfields-com')); } $ids = array(); foreach ($raw as $index => $id) { if (!ctype_digit((string) $index) || (!is_int($id) && !is_string($id)) || !preg_match('/^[1-9][0-9]*$/D', (string) $id) || !wp_attachment_is_image((int) $id)) { return self::error(__('Every gallery item must be an existing image attachment ID.', 'customfields-com')); } if ($check_permission && !current_user_can('read_post', (int) $id)) { return self::error(__('You cannot select one of these gallery images.', 'customfields-com')); } $ids[] = (int) $id; } if (count($ids) !== count(array_unique($ids))) { return self::error(__('A gallery may not contain duplicate image IDs.', 'customfields-com')); } return $ids; } private static function compatible_storage($value, $field, $depth = 0, &$nodes = null) { if ($nodes === null) { $nodes = 0; } if (++$nodes > self::MAX_VALUE_NODES) { return false; } if ($depth > self::MAX_DEPTH || !is_array($value)) { return false; } if ($field['type'] === 'gallery') { $nodes += count($value); return $nodes <= self::MAX_VALUE_NODES && !is_wp_error(self::gallery_value($value, $field)); } if ($field['type'] === 'repeater' && count($value) > $field['max_rows']) { return false; } $rows = $field['type'] === 'group' ? array($value) : $value; foreach ($rows as $index => $row) { if (!ctype_digit((string) $index) || !is_array($row) || array_diff(array_keys($row), array_column($field['sub_fields'], 'name'))) { return false; } foreach ($field['sub_fields'] as $child) { if (++$nodes > self::MAX_VALUE_NODES) { return false; } if (!array_key_exists($child['name'], $row)) { continue; } if (in_array($child['type'], array('repeater', 'group', 'gallery'), true)) { if (!self::compatible_storage($row[$child['name']], $child, $depth + 1, $nodes)) { return false; } } elseif (is_wp_error(\CustomFields\Fields::storage_error($row[$child['name']], $child))) { return false; } } } return true; } public static function render($field, $value, $input_name) { if (!in_array($field['type'], array('repeater', 'group', 'gallery'), true)) { return; } if (self::render_size($field, $value) > self::MAX_VALUE_NODES) { echo ''; return; } if (!self::compatible_storage($value, $field)) { echo '

' . esc_html__('Stored data does not match this schema. Restore its previous schema or migrate values before editing.', 'customfields-com') . '

'; return; } if ($field['type'] === 'gallery') { self::gallery($value, $input_name); return; } if ($field['type'] === 'group') { echo '
'; self::render_fingerprint($field, $value, $input_name); self::children($field, $value, $input_name); echo '
'; return; } $rows = array_values($value); $sources = array_keys($value); while (count($rows) < $field['min_rows']) { $rows[] = array(); } $token = '__CFP_' . substr(md5($input_name), 0, 12) . '__'; echo '
'; echo '
'; foreach ($rows as $index => $row) { self::row($field, $row, $input_name . '[' . $index . ']', array_key_exists($index, $sources) ? (string) $sources[$index] : ''); } echo '
'; self::render_fingerprint($field, $value, $input_name); echo '
'; } private static function render_fingerprint($field, $value, $name) { if (self::has_nested_conditions($field)) { echo ''; } } public static function children($field, $row, $prefix) { foreach ($field['sub_fields'] as $child) { $name = $prefix . '[' . $child['name'] . ']'; $id = 'cf-' . substr(md5($name), 0, 16); $value = $row[$child['name']] ?? ($child['default'] ?? ''); echo '
'; if ($child['type'] === 'wysiwyg' && !is_wp_error(\CustomFields\Fields::storage_error($value, $child))) { echo ''; do_action('customfields_after_render_field', $child, $value, $name); } else { \CustomFields\Fields::render($child, $value, $name); } if (!empty($child['instructions'])) { echo '

' . esc_html($child['instructions']) . '

'; } echo '
'; } } public static function row($field, $row, $prefix, $source = '') { echo '
' . esc_html__('Row', 'customfields-com') . '
'; foreach (array('up' => __('Move up', 'customfields-com'), 'down' => __('Move down', 'customfields-com'), 'duplicate' => __('Duplicate', 'customfields-com'), 'collapse' => __('Collapse', 'customfields-com'), 'remove' => __('Remove', 'customfields-com')) as $action => $label) { echo ' '; } echo '
'; self::children($field, $row, $prefix); echo '
'; } private static function gallery($value, $name) { echo ''; } private static function gallery_buttons() { foreach (array('up' => __('Move up', 'customfields-com'), 'down' => __('Move down', 'customfields-com'), 'remove' => __('Remove', 'customfields-com')) as $key => $label) { echo ' '; } } public static function editor($field, $prefix) { $active = in_array($field['type'] ?? '', array('repeater', 'group'), true); echo '
' . esc_html__('Child fields', 'customfields-com') . ''; echo '
' . esc_html__('Advanced child schema JSON', 'customfields-com') . '

'; echo '
'; foreach (array('min_rows' => 0, 'max_rows' => 20) as $key => $default) { echo ' '; } echo '

' . esc_html__('Up to three container levels and 20 children per container. Child visibility uses sibling fields in the same row or group.', 'customfields-com') . '

'; } public static function group_editor($group) { $page = $group['options_page'] ?? array('slug' => 'site', 'title' => __('Site options', 'customfields-com'), 'capability' => 'manage_options'); echo '
' . esc_html__('Options page', 'customfields-com') . ''; foreach (array('slug' => __('Page slug', 'customfields-com'), 'title' => __('Page title', 'customfields-com')) as $key => $label) { echo '

'; } echo '

' . esc_html__('Groups with the same slug share a page. Each group independently enforces its capability. Named pages appear under Tools.', 'customfields-com') . '

'; } }