From f021ad2cc501628aeff3c2431f0123a251f239c4 Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Wed, 11 Mar 2026 01:24:11 +0700 Subject: [PATCH 1/7] fix: preserve custom field attributes during JSON import --- src/Unparsers/Field.php | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index 124b68f..51b38ff 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -10,6 +10,38 @@ class Field extends Base { private $choice_types = [ 'select', 'radio', 'checkbox_list', 'select_advanced', 'button_group', 'image_select', 'autocomplete' ]; + /** + * Known field keys handled natively by the Builder UI. + * Any other scalar keys will be moved to custom_settings during unparse. + */ + private static $known_keys = [ + // Core & identity + 'id', '_id', 'type', 'name', 'std', 'placeholder', 'save_field', '_state', + // Description & appearance + 'label_description', 'desc', 'size', 'columns', 'before', 'after', 'class', + 'prepend', 'append', 'input_attributes', + // Behavior flags + 'required', 'disabled', 'readonly', 'hide_from_rest', 'hide_from_front', + 'multiple', 'inline', 'select_all_none', 'flatten', + 'sanitize_callback', 'force_delete', 'timestamp', 'raw', 'alpha_channel', + // Clone + 'clone', 'sort_clone', 'clone_default', 'clone_as_multiple', + 'min_clone', 'max_clone', 'add_button', 'clone_empty_start', + // Complex/array keys + 'options', 'js_options', 'query_args', 'attributes', 'custom_settings', + 'text_limiter', 'limit', 'limit_type', 'conditional_logic', 'visible', 'hidden', + 'tooltip', 'admin_columns', 'datalist', 'datalist_choices', 'tab', 'validation', + 'fields', 'field_type', 'collapsible', + // File & image + 'max_file_uploads', 'image_size', 'add_to', 'max_status', 'upload_dir', + // Group + 'default_state', 'save_state', 'group_title', + // Key-value field + 'placeholder_key', 'placeholder_value', + // Internal + '_callback', + ]; + /** * This is revert of parse method. While parse method converts to the minimal format, * this method converts back to the original format. @@ -34,6 +66,7 @@ public function unparse() { ->unparse_conditional_logic() ->unparse_tooltip() ->unparse_admin_columns() + ->unparse_custom_settings() ->ensure_boolean( 'save_field' ); $func = "unparse_field_{$this->type}"; @@ -206,6 +239,36 @@ private function unparse_admin_columns() { return $this; } + /** + * Inverse of parse_custom_settings. + * + * Detects unknown field-level attributes and converts them into the + * custom_settings format ({id, key, value}) that the Builder UI can render. + */ + private function unparse_custom_settings(): self { + $known = array_flip( self::$known_keys ); + $custom = $this->custom_settings ?? []; + + foreach ( array_diff_key( $this->settings, $known ) as $key => $value ) { + if ( ! is_scalar( $value ) ) { + continue; + } + $uid = uniqid(); + $custom[ $uid ] = [ + 'id' => $uid, + 'key' => $key, + 'value' => (string) $value, + ]; + unset( $this->settings[ $key ] ); + } + + if ( ! empty( $custom ) ) { + $this->custom_settings = $custom; + } + + return $this; + } + public function unparse_default_values() { $this->id = $this->id ?? uniqid(); $this->_id = $this->_id ?? $this->id; From d43655ddf3f746df01ddd7c2efee03371a411789 Mon Sep 17 00:00:00 2001 From: Anh Tran Date: Fri, 13 Mar 2026 15:48:03 +0700 Subject: [PATCH 2/7] Update list of known keys --- src/Unparsers/Field.php | 162 ++++++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 24 deletions(-) diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index 51b38ff..57090fd 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -13,32 +13,146 @@ class Field extends Base { /** * Known field keys handled natively by the Builder UI. * Any other scalar keys will be moved to custom_settings during unparse. + * @var array */ private static $known_keys = [ - // Core & identity - 'id', '_id', 'type', 'name', 'std', 'placeholder', 'save_field', '_state', - // Description & appearance - 'label_description', 'desc', 'size', 'columns', 'before', 'after', 'class', - 'prepend', 'append', 'input_attributes', - // Behavior flags - 'required', 'disabled', 'readonly', 'hide_from_rest', 'hide_from_front', - 'multiple', 'inline', 'select_all_none', 'flatten', - 'sanitize_callback', 'force_delete', 'timestamp', 'raw', 'alpha_channel', - // Clone - 'clone', 'sort_clone', 'clone_default', 'clone_as_multiple', - 'min_clone', 'max_clone', 'add_button', 'clone_empty_start', - // Complex/array keys - 'options', 'js_options', 'query_args', 'attributes', 'custom_settings', - 'text_limiter', 'limit', 'limit_type', 'conditional_logic', 'visible', 'hidden', - 'tooltip', 'admin_columns', 'datalist', 'datalist_choices', 'tab', 'validation', - 'fields', 'field_type', 'collapsible', - // File & image - 'max_file_uploads', 'image_size', 'add_to', 'max_status', 'upload_dir', - // Group - 'default_state', 'save_state', 'group_title', - // Key-value field - 'placeholder_key', 'placeholder_value', - // Internal + // Core field settings (all field types) + 'id', + '_id', + 'type', + 'name', + 'std', + 'placeholder', + 'save_field', + '_state', + 'field_name', + 'label_description', + 'desc', + 'columns', + 'before', + 'after', + 'class', + 'required', + 'disabled', + 'readonly', + 'hide_from_rest', + 'hide_from_front', + 'sanitize_callback', + 'attributes', + 'input_attributes', + 'multiple', + // Text and textarea fields + 'size', + 'maxlength', + 'minlength', + 'pattern', + 'autocomplete', + 'autofocus', + 'prepend', + 'append', + 'datalist', + 'datalist_choices', + 'textarea_size', + // Number and range fields + 'min', + 'max', + 'step', + 'minmax', + // Choice fields (select, radio, checkbox, button group, image select) + 'options', + 'std', + 'inline', + 'select_all_none', + 'flatten', + 'field_type', + // Select advanced + 'js_options', + // File and image upload fields + 'max_file_uploads', + 'max_file_size', + 'mime_type', + 'force_delete', + 'image_size', + 'add_to', + 'max_status', + 'upload_dir', + 'unique_filename_callback', + // Date and time fields + 'timestamp', + 'format', + 'save_format', + // Color picker + 'alpha_channel', + // Wysiwyg editor + 'raw', + 'height', + // Switch field + 'style', + 'on_label', + 'off_label', + // Slider field + 'prefix', + 'suffix', + // Map and OSM fields + 'api_key', + 'address_field', + 'language', + 'region', + 'marker_draggable', + // Icon field + 'icon_set', + 'icon_file', + 'icon_dir', + 'icon_css', + // Object fields (taxonomy, post, user) + 'taxonomy', + 'post_type', + 'query_args', + 'ajax', + 'add_new', + 'remove_default', + 'parent', + 'display_field', + // Block editor field + 'allowed_blocks', + // Group field (Meta Box Group extension) + 'fields', + 'collapsible', + 'default_state', + 'save_state', + 'group_title', + // Key-value field (sub-type of group) + 'placeholder_key', + 'placeholder_value', + // Clone extension (Meta Box Group) + 'clone', + 'sort_clone', + 'clone_default', + 'clone_as_multiple', + 'min_clone', + 'max_clone', + 'add_button', + 'clone_empty_start', + // Meta Box Builder extensions + // Text limiter + 'text_limiter', + 'limit', + 'limit_type', + // Conditional logic + 'conditional_logic', + 'visible', + 'hidden', + // Tabs + 'tab', + // Validation + 'validation', + // Tooltips + 'tooltip', + // Admin columns + 'admin_columns', + // Custom settings container + 'custom_settings', + // Internal use only '_callback', ]; From d96622846237502ce4c5931f89ad55a856985eba Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Mon, 30 Mar 2026 15:28:51 +0700 Subject: [PATCH 3/7] fix: unparse custom field attributes on import --- src/SettingsTrait.php | 18 ++++ src/Unparsers/Field.php | 199 +++++++++------------------------------- 2 files changed, 60 insertions(+), 157 deletions(-) diff --git a/src/SettingsTrait.php b/src/SettingsTrait.php index 7270b18..eeb1252 100644 --- a/src/SettingsTrait.php +++ b/src/SettingsTrait.php @@ -3,23 +3,41 @@ /** * Use overloading magic methods for short syntax. + * Property accesses are recorded so unparse_custom_settings() can detect + * which keys were touched (= natively handled) vs untouched (= potentially custom). */ trait SettingsTrait { protected $settings; + /** + * Keys accessed via __get / __set / __isset during this object's lifetime. + * Stored as a hash-map (key => true) for O(1) lookup. + * + * @var array + */ + private $accessed_keys = []; + public function get_settings(): array { return $this->settings; } + /** Return all keys that were accessed (touched) so far. */ + public function get_accessed_keys(): array { + return $this->accessed_keys; + } + public function __get( string $key ) { + $this->accessed_keys[ $key ] = true; return $this->settings[ $key ] ?? null; } public function __set( string $key, $value ): void { + $this->accessed_keys[ $key ] = true; $this->settings[ $key ] = $value; } public function __isset( string $key ): bool { + $this->accessed_keys[ $key ] = true; return isset( $this->settings[ $key ] ); } diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index 57090fd..5910033 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -10,151 +10,6 @@ class Field extends Base { private $choice_types = [ 'select', 'radio', 'checkbox_list', 'select_advanced', 'button_group', 'image_select', 'autocomplete' ]; - /** - * Known field keys handled natively by the Builder UI. - * Any other scalar keys will be moved to custom_settings during unparse. - * @var array - */ - private static $known_keys = [ - // Core field settings (all field types) - 'id', - '_id', - 'type', - 'name', - 'std', - 'placeholder', - 'save_field', - '_state', - 'field_name', - 'label_description', - 'desc', - 'columns', - 'before', - 'after', - 'class', - 'required', - 'disabled', - 'readonly', - 'hide_from_rest', - 'hide_from_front', - 'sanitize_callback', - 'attributes', - 'input_attributes', - 'multiple', - // Text and textarea fields - 'size', - 'maxlength', - 'minlength', - 'pattern', - 'autocomplete', - 'autofocus', - 'prepend', - 'append', - 'datalist', - 'datalist_choices', - 'textarea_size', - // Number and range fields - 'min', - 'max', - 'step', - 'minmax', - // Choice fields (select, radio, checkbox, button group, image select) - 'options', - 'std', - 'inline', - 'select_all_none', - 'flatten', - 'field_type', - // Select advanced - 'js_options', - // File and image upload fields - 'max_file_uploads', - 'max_file_size', - 'mime_type', - 'force_delete', - 'image_size', - 'add_to', - 'max_status', - 'upload_dir', - 'unique_filename_callback', - // Date and time fields - 'timestamp', - 'format', - 'save_format', - // Color picker - 'alpha_channel', - // Wysiwyg editor - 'raw', - 'height', - // Switch field - 'style', - 'on_label', - 'off_label', - // Slider field - 'prefix', - 'suffix', - // Map and OSM fields - 'api_key', - 'address_field', - 'language', - 'region', - 'marker_draggable', - // Icon field - 'icon_set', - 'icon_file', - 'icon_dir', - 'icon_css', - // Object fields (taxonomy, post, user) - 'taxonomy', - 'post_type', - 'query_args', - 'ajax', - 'add_new', - 'remove_default', - 'parent', - 'display_field', - // Block editor field - 'allowed_blocks', - // Group field (Meta Box Group extension) - 'fields', - 'collapsible', - 'default_state', - 'save_state', - 'group_title', - // Key-value field (sub-type of group) - 'placeholder_key', - 'placeholder_value', - // Clone extension (Meta Box Group) - 'clone', - 'sort_clone', - 'clone_default', - 'clone_as_multiple', - 'min_clone', - 'max_clone', - 'add_button', - 'clone_empty_start', - // Meta Box Builder extensions - // Text limiter - 'text_limiter', - 'limit', - 'limit_type', - // Conditional logic - 'conditional_logic', - 'visible', - 'hidden', - // Tabs - 'tab', - // Validation - 'validation', - // Tooltips - 'tooltip', - // Admin columns - 'admin_columns', - // Custom settings container - 'custom_settings', - // Internal use only - '_callback', - ]; /** * This is revert of parse method. While parse method converts to the minimal format, @@ -179,14 +34,17 @@ public function unparse() { ->unparse_text_limiter() ->unparse_conditional_logic() ->unparse_tooltip() - ->unparse_admin_columns() - ->unparse_custom_settings() - ->ensure_boolean( 'save_field' ); + ->unparse_admin_columns(); + // Field-type handler must run BEFORE unparse_custom_settings() so its + // property accesses are tracked and not mis-classified as custom attributes. $func = "unparse_field_{$this->type}"; if ( method_exists( $this, $func ) ) { $this->$func(); } + + $this->unparse_custom_settings(); + $this->ensure_boolean( 'save_field' ); } private function unparse_datalist() { @@ -287,15 +145,19 @@ private function unparse_field_group() { } private function unparse_text_limiter() { - if ( ! isset( $this->limit ) ) { + // Accept fields that only export limit_type (limit=0 is stripped on export as default). + if ( ! isset( $this->limit ) && ! isset( $this->limit_type ) ) { return $this; } $this->text_limiter = [ - 'limit' => $this->limit, + 'limit' => $this->limit ?? 0, 'limit_type' => $this->limit_type ?? 'word', ]; + // Remove flat keys so they are not double-stored alongside text_limiter. + unset( $this->settings['limit'], $this->settings['limit_type'] ); + return $this; } @@ -354,24 +216,47 @@ private function unparse_admin_columns() { } /** - * Inverse of parse_custom_settings. + * Pure pass-through keys that no unparse_* method ever reads via $this->key. + * They are kept in settings as-is, so they must never be classified as + * user-defined custom attributes. + * + * Every other native key is auto-tracked by SettingsTrait the moment any + * method does $this->key — no need to list them here. * - * Detects unknown field-level attributes and converts them into the - * custom_settings format ({id, key, value}) that the Builder UI can render. + * Extend via filter: + * add_filter( 'mbb_parser_known_field_keys', fn( $k ) => array_merge( $k, [ 'my_key' ] ) ); + */ + private const STRUCTURAL_KEYS = [ + 'type', 'id', '_id', 'name', 'std', 'placeholder', + 'columns', 'field_name', '_state', 'input_attributes', + 'multiple', 'fields', 'tab', 'validation', + 'custom_settings', '_callback', 'options', + ]; + + /** + * Detect unknown top-level keys and move them into the custom_settings + * format `{ uid: { id, key, value } }` that the Builder UI renders in + * the "Custom Settings" tab. + * + * "Known" = auto-tracked by SettingsTrait + STRUCTURAL_KEYS pass-through list. */ private function unparse_custom_settings(): self { - $known = array_flip( self::$known_keys ); - $custom = $this->custom_settings ?? []; + $always_native = apply_filters( 'mbb_parser_known_field_keys', self::STRUCTURAL_KEYS ); + $known = $this->get_accessed_keys() + array_flip( $always_native ); + $custom = $this->custom_settings ?? []; foreach ( array_diff_key( $this->settings, $known ) as $key => $value ) { - if ( ! is_scalar( $value ) ) { + if ( is_resource( $value ) ) { continue; } + $uid = uniqid(); $custom[ $uid ] = [ 'id' => $uid, 'key' => $key, - 'value' => (string) $value, + 'value' => ( is_array( $value ) || is_object( $value ) ) + ? wp_json_encode( $value ) + : (string) $value, ]; unset( $this->settings[ $key ] ); } From e56f153d6495d3d51890df8a90a70760f70ded64 Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Wed, 1 Apr 2026 09:32:11 +0700 Subject: [PATCH 4/7] Remove mbb_parser_known_field_keys filter --- src/Unparsers/Field.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index 5910033..20f76d0 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -241,7 +241,7 @@ private function unparse_admin_columns() { * "Known" = auto-tracked by SettingsTrait + STRUCTURAL_KEYS pass-through list. */ private function unparse_custom_settings(): self { - $always_native = apply_filters( 'mbb_parser_known_field_keys', self::STRUCTURAL_KEYS ); + $always_native = self::STRUCTURAL_KEYS; $known = $this->get_accessed_keys() + array_flip( $always_native ); $custom = $this->custom_settings ?? []; From 3acb2ec8710908759c6363c62e749c3c8545177d Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Wed, 1 Apr 2026 16:09:25 +0700 Subject: [PATCH 5/7] simplify custom settings detection logic --- src/SettingsTrait.php | 10 +++++----- src/Unparsers/Field.php | 9 ++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/SettingsTrait.php b/src/SettingsTrait.php index eeb1252..bb23dcc 100644 --- a/src/SettingsTrait.php +++ b/src/SettingsTrait.php @@ -11,9 +11,9 @@ trait SettingsTrait { /** * Keys accessed via __get / __set / __isset during this object's lifetime. - * Stored as a hash-map (key => true) for O(1) lookup. + * Used by unparse_custom_settings() to detect natively-handled keys. * - * @var array + * @var string[] */ private $accessed_keys = []; @@ -27,17 +27,17 @@ public function get_accessed_keys(): array { } public function __get( string $key ) { - $this->accessed_keys[ $key ] = true; + $this->accessed_keys[] = $key; return $this->settings[ $key ] ?? null; } public function __set( string $key, $value ): void { - $this->accessed_keys[ $key ] = true; + $this->accessed_keys[] = $key; $this->settings[ $key ] = $value; } public function __isset( string $key ): bool { - $this->accessed_keys[ $key ] = true; + $this->accessed_keys[] = $key; return isset( $this->settings[ $key ] ); } diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index 20f76d0..afb510e 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -241,12 +241,11 @@ private function unparse_admin_columns() { * "Known" = auto-tracked by SettingsTrait + STRUCTURAL_KEYS pass-through list. */ private function unparse_custom_settings(): self { - $always_native = self::STRUCTURAL_KEYS; - $known = $this->get_accessed_keys() + array_flip( $always_native ); - $custom = $this->custom_settings ?? []; + $known = array_merge( $this->get_accessed_keys(), self::STRUCTURAL_KEYS ); + $custom = $this->custom_settings ?? []; - foreach ( array_diff_key( $this->settings, $known ) as $key => $value ) { - if ( is_resource( $value ) ) { + foreach ( $this->settings as $key => $value ) { + if ( in_array( $key, $known, true ) ) { continue; } From e9e8d908dc04e166814c4349b1c51d2940b6b7ee Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Wed, 1 Apr 2026 19:24:07 +0700 Subject: [PATCH 6/7] clean up unparse_custom_settings detection logic --- composer.json | 1 + src/SettingsTrait.php | 2 +- src/Unparsers/Field.php | 41 ++++++++++++++++++----------------------- 3 files changed, 20 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index cf236a5..dd7f9d0 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "psr-4": {"MBBParser\\": "src/"} }, "require": { + "php": ">=7.4", "wpmetabox/support": "dev-master", "riimu/kit-phpencoder": "^2.4" } diff --git a/src/SettingsTrait.php b/src/SettingsTrait.php index bb23dcc..61d36f9 100644 --- a/src/SettingsTrait.php +++ b/src/SettingsTrait.php @@ -15,7 +15,7 @@ trait SettingsTrait { * * @var string[] */ - private $accessed_keys = []; + private array $accessed_keys = []; public function get_settings(): array { return $this->settings; diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index afb510e..aa8cffb 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -10,6 +10,22 @@ class Field extends Base { private $choice_types = [ 'select', 'radio', 'checkbox_list', 'select_advanced', 'button_group', 'image_select', 'autocomplete' ]; + /** + * Native keys that no unparse_* method accesses via $this->key (magic method), + * so they are never auto-tracked by SettingsTrait. List them explicitly here to + * prevent unparse_custom_settings() from mis-classifying them as custom attributes. + * + * Every other native key is auto-tracked the moment any method reads or writes + * $this->key — no need to list those here. + */ + private const STRUCTURAL_KEYS = [ + 'type', 'id', '_id', 'name', 'std', 'placeholder', + 'columns', 'field_name', '_state', 'input_attributes', + 'multiple', 'fields', 'tab', 'validation', + 'custom_settings', '_callback', 'options', + 'limit', 'limit_type', + ]; + /** * This is revert of parse method. While parse method converts to the minimal format, @@ -146,18 +162,15 @@ private function unparse_field_group() { private function unparse_text_limiter() { // Accept fields that only export limit_type (limit=0 is stripped on export as default). - if ( ! isset( $this->limit ) && ! isset( $this->limit_type ) ) { + if ( ! isset( $this->limit ) ) { return $this; } $this->text_limiter = [ - 'limit' => $this->limit ?? 0, + 'limit' => $this->limit, 'limit_type' => $this->limit_type ?? 'word', ]; - // Remove flat keys so they are not double-stored alongside text_limiter. - unset( $this->settings['limit'], $this->settings['limit_type'] ); - return $this; } @@ -215,24 +228,6 @@ private function unparse_admin_columns() { return $this; } - /** - * Pure pass-through keys that no unparse_* method ever reads via $this->key. - * They are kept in settings as-is, so they must never be classified as - * user-defined custom attributes. - * - * Every other native key is auto-tracked by SettingsTrait the moment any - * method does $this->key — no need to list them here. - * - * Extend via filter: - * add_filter( 'mbb_parser_known_field_keys', fn( $k ) => array_merge( $k, [ 'my_key' ] ) ); - */ - private const STRUCTURAL_KEYS = [ - 'type', 'id', '_id', 'name', 'std', 'placeholder', - 'columns', 'field_name', '_state', 'input_attributes', - 'multiple', 'fields', 'tab', 'validation', - 'custom_settings', '_callback', 'options', - ]; - /** * Detect unknown top-level keys and move them into the custom_settings * format `{ uid: { id, key, value } }` that the Builder UI renders in From aa8f50fe4a0a47a0f9ba68dc6eb22ffa7d86eeb6 Mon Sep 17 00:00:00 2001 From: Hoang Minh Huong Date: Wed, 1 Apr 2026 22:14:43 +0700 Subject: [PATCH 7/7] remove comment unussage --- src/Unparsers/Field.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Unparsers/Field.php b/src/Unparsers/Field.php index aa8cffb..a3cfb18 100644 --- a/src/Unparsers/Field.php +++ b/src/Unparsers/Field.php @@ -161,7 +161,6 @@ private function unparse_field_group() { } private function unparse_text_limiter() { - // Accept fields that only export limit_type (limit=0 is stripped on export as default). if ( ! isset( $this->limit ) ) { return $this; }