'','fields'=>[]]) : ['revision'=>'','fields'=>[]]; } public static function definitions(string $type = ''): array { return array_values(array_filter(self::schema()['fields'], static fn($d) => !$type || in_array($type, $d['postTypes'], true))); } private static function error(string $message): \WP_Error { return new \WP_Error('digibl_native_fields', $message, ['status'=>400]); } private static function list_of($value, int $limit): bool { return is_array($value) && array_is_list($value) && count($value) <= $limit; } public static function clean_definitions($raw) { if (!self::list_of($raw, 40)) { return self::error('Use a list of at most 40 field definitions.'); } $out = []; $keys = []; $allowed = array_diff(get_post_types(['public'=>true]), ['attachment']); foreach ($raw as $field) { if (!is_array($field) || !is_string($field['key']??null) || !preg_match('/^digibl_field_[a-z][a-z0-9_]{0,49}$/D', $field['key']) || isset($keys[$field['key']])) { return self::error('Use unique field keys starting with digibl_field_ followed by a letter.'); } $key = $field['key']; $keys[$key] = true; $type = $field['type']??null; if (!is_string($type) || !in_array($type, self::TYPES, true) || !is_string($field['label']??null) || trim($field['label'])==='') { return self::error('Each field needs a label and a supported type.'); } if (array_key_exists('public',$field) && !is_bool($field['public'])) { return self::error('Public must be a JSON boolean.'); } $post_types = $field['postTypes']??null; if (!self::list_of($post_types, 50) || !$post_types) { return self::error('Choose at least one public content type.'); } foreach ($post_types as $post_type) { if (!is_string($post_type) || !in_array($post_type, $allowed, true)) { return self::error('Unknown or invalid content type.'); } } $options = $field['options']??''; if (!is_string($options) || strlen($options)>1500) { return self::error('Select choices must be text, at most 1500 bytes.'); } if ($type==='select' && !array_filter(array_map('trim', explode('|', $options)), static fn($v) => $v!=='')) { return self::error('A select field needs choices separated by |.'); } $columns = []; $seen = []; if ($type==='repeater') { if (!self::list_of($field['columns']??null, 12) || !$field['columns']) { return self::error('A repeater needs 1–12 columns.'); } foreach ($field['columns'] as $column) { if (!is_array($column) || !is_string($column['key']??null) || !preg_match('/^[a-z][a-z0-9_]{0,39}$/D', $column['key']) || isset($seen[$column['key']]) || !is_string($column['label']??null) || trim($column['label'])==='' || !in_array($column['type']??null, ['text','textarea','number','url','image','checkbox'], true)) { return self::error('Each repeater column needs a unique key, label and supported type.'); } $seen[$column['key']] = true; $columns[] = ['key'=>$column['key'], 'label'=>Document::text($column['label'],100), 'type'=>$column['type']]; } } $out[] = ['key'=>$key, 'label'=>Document::text($field['label'],100), 'type'=>$type, 'postTypes'=>array_values(array_unique($post_types)), 'public'=>$field['public']??false, 'options'=>Document::text($options,1500), 'columns'=>$columns]; } return $out; } /** Reject malformed values rather than silently truncating a partially submitted form. */ public static function value($value, array $definition, bool $stored = false) { $type = $definition['type']; if ($type==='repeater') { if (!self::list_of($value, 50)) { return self::error('A repeater supports at most 50 rows.'); } $rows = []; foreach ($value as $row) { if (!is_array($row)) { return self::error('Invalid repeater row.'); } $clean = []; foreach ($definition['columns'] as $column) { if (!$stored && !array_key_exists($column['key'],$row)) { return self::error('Send all configured repeater columns; reload if definitions changed.'); } $cell = array_key_exists($column['key'],$row) ? $row[$column['key']] : self::empty_value($column); $clean[$column['key']] = self::value($cell, $column, $stored); if (is_wp_error($clean[$column['key']])) { return $clean[$column['key']]; } } if (array_diff(array_keys($row), array_column($definition['columns'],'key'))) { return self::error('Unknown repeater column. Reload field definitions.'); } $rows[] = $clean; } return $rows; } if ($type==='checkbox') { return is_bool($value) ? $value : self::error('Checkbox values must be JSON booleans.'); } if ($type==='image') { // An attachment can disappear independently of the saved rows that reference it. if ($stored && is_int($value) && $value>0 && !wp_attachment_is_image($value)) { return 0; } if (!is_int($value) || $value<0 || ($value && !wp_attachment_is_image($value))) { return self::error('Choose an image from the media library.'); } return $value; } if ($type==='number') { if ($value==='') { return ''; } if ((!is_int($value) && !is_float($value) && !is_string($value)) || !is_numeric($value) || !is_finite((float)$value) || abs((float)$value)>1e12) { return self::error('Enter a finite number between -1000000000000 and 1000000000000.'); } return (float)$value; } if (!is_string($value) || strlen($value)>10000) { return self::error('Text values must be strings, at most 10000 bytes.'); } if ($type==='url') { $clean = Document::url($value); return $value!=='' && $clean==='' ? self::error('Enter a valid web address.') : $clean; } if ($type==='select') { return $stored || $value==='' || in_array($value,array_map('trim',explode('|',$definition['options'])),true) ? $value : self::error('Choose one of the configured options.'); } return $type==='textarea' ? sanitize_textarea_field($value) : sanitize_text_field($value); } public static function empty_value(array $definition) { return $definition['type']==='repeater' ? [] : ($definition['type']==='checkbox' ? false : ($definition['type']==='image' ? 0 : '')); } public static function record(int $id) { if (!metadata_exists('post',$id,self::META)) { return ['revision'=>'','values'=>[]]; } $raw = get_post_meta($id, self::META, true); if (!is_array($raw) || !is_string($raw['revision']??null) || !is_array($raw['values']??null)) { return new \WP_Error('digibl_native_record','The stored field record is invalid. It has not been replaced.', ['status'=>409]); } return $raw; } public static function payload(int $id) { $record = self::record($id); if (is_wp_error($record)) { return $record; } $fields = self::definitions((string)get_post_type($id)); $values = []; foreach ($fields as $field) { $raw = array_key_exists($field['key'],$record['values']) ? $record['values'][$field['key']] : self::empty_value($field); $value = self::value($raw,$field,true); if (is_wp_error($value)) { return new \WP_Error('digibl_native_record',sprintf(Language::text('Stored field %s needs repair. No values have been replaced. %s'),$field['key'],Language::text($value->get_error_message())),['status'=>409]); } $values[$field['key']] = $value; } return ['fields'=>$fields, 'values'=>$values, 'revision'=>$record['revision'], 'schemaRevision'=>self::schema()['revision']]; } public static function public_value(int $id, string $key, bool $array = false) { foreach (self::definitions((string)get_post_type($id)) as $field) { if ($field['key']!==$key || !$field['public']) { continue; } $record = self::record($id); if (is_wp_error($record)) { return ''; } $value = self::value($record['values'][$key]??self::empty_value($field),$field,true); return !is_wp_error($value) && (is_scalar($value) || ($array && is_array($value))) ? $value : ''; } return ''; } }