PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 11.0.0
WooCommerce v11.0.0
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Admin / Settings / SettingsUISchema.php
woocommerce / src / Internal / Admin / Settings Last commit date
Exceptions 1 year ago PaymentsProviders 1 month ago SettingsUIPages 2 months ago LegacySettingsPageAdapter.php 1 month ago Payments.php 5 months ago PaymentsController.php 1 year ago PaymentsProviders.php 3 months ago PaymentsRestController.php 5 months ago RegisteredSettingsSectionAdapter.php 1 month ago SettingsUIPageInterface.php 2 months ago SettingsUIRequestContext.php 1 month ago SettingsUISchema.php 1 month ago Utils.php 4 months ago
SettingsUISchema.php
653 lines
1 <?php
2 /**
3 * Settings UI schema builder.
4 */
5
6 declare( strict_types=1 );
7
8 namespace Automattic\WooCommerce\Internal\Admin\Settings;
9
10 use Automattic\WooCommerce\Internal\Utilities\ArrayUtil;
11
12 defined( 'ABSPATH' ) || exit;
13
14 /**
15 * Builds the canonical settings schema consumed by the settings UI renderer.
16 *
17 * @since 10.9.0
18 */
19 class SettingsUISchema {
20
21 /**
22 * Default group id for fields before the first title marker.
23 *
24 * @var string
25 */
26 private const DEFAULT_GROUP_ID = 'default';
27
28 /**
29 * Build a schema from a legacy WC settings array.
30 *
31 * @since 10.9.0
32 *
33 * @param string $page_id Settings page id.
34 * @param string $section Section id. Empty string means the default section.
35 * @param string $title Page title.
36 * @param array $settings Legacy settings definitions.
37 * @param string $default_save_adapter Default save adapter.
38 * @return array
39 */
40 public static function from_legacy_settings( string $page_id, string $section, string $title, array $settings, string $default_save_adapter = 'form_post' ): array {
41 $groups = array();
42 $current_group = null;
43 $current_id = null;
44 $group_index = 0;
45 $visibility_controller = null;
46
47 foreach ( $settings as $setting ) {
48 if ( ! is_array( $setting ) ) {
49 continue;
50 }
51
52 $type = isset( $setting['type'] ) && is_string( $setting['type'] ) ? $setting['type'] : 'text';
53
54 if ( 'title' === $type ) {
55 $visibility_controller = null;
56 if ( $current_group && $current_id ) {
57 $groups[ $current_id ] = $current_group;
58 }
59
60 $current_id = isset( $setting['id'] ) && is_scalar( $setting['id'] ) && '' !== (string) $setting['id']
61 ? (string) $setting['id']
62 : 'group_' . $group_index;
63 $current_group = array(
64 'id' => $current_id,
65 'title' => isset( $setting['title'] ) && is_scalar( $setting['title'] ) ? html_entity_decode( (string) $setting['title'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) : '',
66 'description' => isset( $setting['desc'] ) && is_scalar( $setting['desc'] ) ? wp_kses_post( (string) $setting['desc'] ) : '',
67 'actions' => self::get_group_actions( $setting ),
68 'order' => isset( $setting['order'] ) ? (int) $setting['order'] : $group_index,
69 'fields' => array(),
70 );
71 ++$group_index;
72 continue;
73 }
74
75 if ( 'sectionend' === $type ) {
76 $visibility_controller = null;
77 if ( $current_group && $current_id ) {
78 $groups[ $current_id ] = $current_group;
79 }
80 $current_group = null;
81 $current_id = null;
82 continue;
83 }
84
85 if ( empty( $setting['id'] ) ) {
86 continue;
87 }
88
89 if ( ! $current_group ) {
90 $current_id = self::DEFAULT_GROUP_ID;
91 $current_group = self::get_default_group( $group_index );
92 ++$group_index;
93 }
94
95 $field = self::transform_legacy_field( $setting, $default_save_adapter, $visibility_controller );
96 if ( $field ) {
97 $current_group['fields'][] = $field;
98 }
99
100 if ( 'checkbox' === $type && 'option' === ( $setting['show_if_checked'] ?? null ) ) {
101 $visibility_controller = $field['id'] ?? null;
102 }
103
104 if ( 'end' === ( $setting['checkboxgroup'] ?? null ) ) {
105 $visibility_controller = null;
106 }
107 }
108
109 if ( $current_group && $current_id ) {
110 $groups[ $current_id ] = $current_group;
111 }
112
113 uasort(
114 $groups,
115 static function ( array $a, array $b ): int {
116 return ( $a['order'] ?? 999 ) <=> ( $b['order'] ?? 999 );
117 }
118 );
119
120 foreach ( $groups as $group_id => $group ) {
121 unset( $group['order'] );
122 $groups[ $group_id ] = $group;
123 }
124
125 $decoded_title = html_entity_decode( $title, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
126
127 return array(
128 'id' => $page_id,
129 'title' => $decoded_title,
130 'section' => '' === $section ? self::DEFAULT_GROUP_ID : $section,
131 'save' => array(
132 'adapter' => $default_save_adapter,
133 ),
134 'shell' => array(
135 'title' => $decoded_title,
136 ),
137 'groups' => $groups,
138 );
139 }
140
141 /**
142 * Canonicalize option values supplied by native Settings UI schema providers.
143 *
144 * Schemas built from legacy settings always carry string option values, but
145 * native providers can supply any scalar. The client matches options against
146 * the stored value with strict string comparison, so scalar option values,
147 * the selected values they match, and visibility values compared against
148 * them are cast here to the string the client's own String() coercion
149 * produces. Malformed entries remain unchanged for the provider to fix.
150 *
151 * @since 11.0.0
152 *
153 * @param array $schema Settings UI schema.
154 * @return array Schema with scalar option values canonicalized to strings.
155 */
156 public static function canonicalize_option_values( array $schema ): array {
157 if ( ! isset( $schema['groups'] ) || ! is_array( $schema['groups'] ) ) {
158 return $schema;
159 }
160
161 $converted_fields = array();
162 $option_field_ids = array();
163
164 foreach ( $schema['groups'] as &$group ) {
165 if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
166 continue;
167 }
168
169 foreach ( $group['fields'] as &$field ) {
170 if (
171 ! is_array( $field ) ||
172 ! isset( $field['id'], $field['options'] ) ||
173 ! is_string( $field['id'] ) ||
174 ! is_array( $field['options'] )
175 ) {
176 continue;
177 }
178
179 $option_field_ids[] = $field['id'];
180 $converted = false;
181
182 foreach ( $field['options'] as &$option ) {
183 if (
184 ! is_array( $option ) ||
185 ! array_key_exists( 'value', $option ) ||
186 is_string( $option['value'] ) ||
187 ! is_scalar( $option['value'] )
188 ) {
189 continue;
190 }
191
192 $option['value'] = self::to_canonical_string( $option['value'] );
193 $converted = true;
194 }
195 unset( $option );
196
197 if ( array_key_exists( 'value', $field ) ) {
198 if ( is_scalar( $field['value'] ) && ! is_string( $field['value'] ) ) {
199 $field['value'] = self::to_canonical_string( $field['value'] );
200 $converted = true;
201 } elseif ( is_array( $field['value'] ) ) {
202 $canonical_list = self::canonicalize_scalar_list( $field['value'] );
203 if ( null !== $canonical_list ) {
204 $field['value'] = $canonical_list;
205 $converted = true;
206 }
207 }
208 }
209
210 if ( $converted ) {
211 $converted_fields[] = $field['id'];
212 }
213 }
214 unset( $field );
215 }
216 unset( $group );
217
218 foreach ( $schema['groups'] as &$group ) {
219 if ( ! is_array( $group ) || ! isset( $group['fields'] ) || ! is_array( $group['fields'] ) ) {
220 continue;
221 }
222
223 foreach ( $group['fields'] as &$field ) {
224 if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_string( $field['id'] ) ) {
225 continue;
226 }
227
228 if ( ! self::is_canonicalizable_visibility_rule( $field['visibility'] ?? null, $option_field_ids ) ) {
229 continue;
230 }
231
232 $rule_value = $field['visibility']['value'];
233
234 if ( is_scalar( $rule_value ) && ! is_string( $rule_value ) ) {
235 $field['visibility']['value'] = self::to_canonical_string( $rule_value );
236 $converted_fields[] = $field['id'];
237 } elseif ( is_array( $rule_value ) ) {
238 $canonical_list = self::canonicalize_scalar_list( $rule_value );
239 if ( null !== $canonical_list ) {
240 $field['visibility']['value'] = $canonical_list;
241 $converted_fields[] = $field['id'];
242 }
243 }
244 }
245 unset( $field );
246 }
247 unset( $group );
248
249 if ( ! empty( $converted_fields ) ) {
250 wc_doing_it_wrong(
251 __METHOD__,
252 sprintf(
253 /* translators: %s: comma-separated field ids. */
254 esc_html__( 'A Settings UI schema provider supplied non-string option, field, or visibility values that WooCommerce converted for compatibility: %s. Update the provider to supply string values.', 'woocommerce' ),
255 esc_html( implode( ', ', array_unique( $converted_fields ) ) )
256 ),
257 '11.0.0'
258 );
259 }
260
261 return $schema;
262 }
263
264 /**
265 * Canonicalize a list of scalar values to strings.
266 *
267 * @param array $values Candidate value list.
268 * @return array|null String list, or null when unchanged or not a scalar list.
269 */
270 private static function canonicalize_scalar_list( array $values ): ?array {
271 if ( ! ArrayUtil::array_is_list( $values ) ) {
272 return null;
273 }
274
275 $needs_conversion = false;
276
277 foreach ( $values as $item ) {
278 if ( ! is_scalar( $item ) ) {
279 return null;
280 }
281
282 if ( ! is_string( $item ) ) {
283 $needs_conversion = true;
284 }
285 }
286
287 return $needs_conversion ? array_map( array( __CLASS__, 'to_canonical_string' ), $values ) : null;
288 }
289
290 /**
291 * Whether a visibility rule carries a value compared against an options field.
292 *
293 * @param mixed $rule Candidate visibility rule.
294 * @param array $option_field_ids Ids of fields carrying an options array.
295 * @return bool
296 */
297 private static function is_canonicalizable_visibility_rule( $rule, array $option_field_ids ): bool {
298 return is_array( $rule )
299 && array_key_exists( 'value', $rule )
300 && in_array( $rule['controller'] ?? null, $option_field_ids, true );
301 }
302
303 /**
304 * Cast a scalar to the string the client's String() coercion produces, so
305 * canonicalizing a value never changes which option or visibility rule it
306 * matches. PHP casts diverge from String() for booleans: (string) true is
307 * '1' and (string) false is '', while String() gives 'true' and 'false'.
308 * Floats convert through wc_float_to_string() because a plain cast is
309 * locale-sensitive before PHP 8.0 and String() never emits a comma.
310 *
311 * @param bool|int|float|string $value Scalar value.
312 * @return string
313 */
314 private static function to_canonical_string( $value ): string {
315 if ( is_bool( $value ) ) {
316 return $value ? 'true' : 'false';
317 }
318
319 if ( is_float( $value ) ) {
320 return wc_float_to_string( $value );
321 }
322
323 return (string) $value;
324 }
325
326 /**
327 * Transform a legacy field into the canonical schema.
328 *
329 * @param array $setting Legacy field definition.
330 * @param string $default_save_adapter Default save adapter.
331 * @param string|null $visibility_controller Current checkbox group controller.
332 * @return array|null
333 */
334 private static function transform_legacy_field( array $setting, string $default_save_adapter, ?string $visibility_controller = null ): ?array {
335 $id = isset( $setting['id'] ) && is_scalar( $setting['id'] ) ? (string) $setting['id'] : '';
336 $type = isset( $setting['type'] ) && is_string( $setting['type'] ) ? $setting['type'] : 'text';
337 if ( '' === $id ) {
338 return null;
339 }
340
341 $canonical_type = self::normalize_type( $type );
342 $field = array(
343 'id' => $id,
344 'label' => self::get_field_label( $setting, $id, $type ),
345 'type' => $canonical_type,
346 'description' => self::get_field_description( $setting, $type ),
347 'value' => self::get_field_value( $setting, $canonical_type ),
348 'save' => self::get_save_schema( $setting, $default_save_adapter ),
349 );
350
351 foreach ( array( 'component', 'placeholder', 'disabled' ) as $key ) {
352 if ( array_key_exists( $key, $setting ) ) {
353 $field[ $key ] = $setting[ $key ];
354 }
355 }
356
357 if ( isset( $setting['custom_attributes'] ) && is_array( $setting['custom_attributes'] ) ) {
358 $field['customAttributes'] = self::get_custom_attributes( $setting['custom_attributes'] );
359 }
360
361 $visibility = self::get_field_visibility( $setting, $visibility_controller );
362 if ( $visibility ) {
363 $field['visibility'] = $visibility;
364 }
365
366 $options = self::get_options( $setting );
367 if ( ! empty( $options ) ) {
368 $field['options'] = $options;
369 }
370
371 if ( 'info' === $type && '' === $field['description'] && isset( $setting['text'] ) && is_scalar( $setting['text'] ) ) {
372 $field['description'] = wp_kses_post( (string) $setting['text'] );
373 $field['save'] = array( 'adapter' => 'none' );
374 }
375
376 return $field;
377 }
378
379 /**
380 * Get a field label.
381 *
382 * @param array $setting Legacy field definition.
383 * @param string $id Field id.
384 * @param string $type Raw field type.
385 * @return string
386 */
387 private static function get_field_label( array $setting, string $id, string $type ): string {
388 if ( 'checkbox' === $type && isset( $setting['desc'] ) && is_scalar( $setting['desc'] ) && '' !== (string) $setting['desc'] ) {
389 return wp_strip_all_tags( html_entity_decode( (string) $setting['desc'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) );
390 }
391
392 foreach ( array( 'title', 'name' ) as $key ) {
393 if ( isset( $setting[ $key ] ) && is_scalar( $setting[ $key ] ) && '' !== (string) $setting[ $key ] ) {
394 return wp_strip_all_tags( html_entity_decode( (string) $setting[ $key ], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) );
395 }
396 }
397
398 return $id;
399 }
400
401 /**
402 * Get a field description.
403 *
404 * @param array $setting Legacy field definition.
405 * @param string $type Raw field type.
406 * @return string
407 */
408 private static function get_field_description( array $setting, string $type ): string {
409 $description = 'checkbox' === $type || ! isset( $setting['desc'] ) || ! is_scalar( $setting['desc'] )
410 ? ''
411 : wp_kses_post( (string) $setting['desc'] );
412
413 $desc_tip = isset( $setting['desc_tip'] ) && is_string( $setting['desc_tip'] ) && '' !== $setting['desc_tip']
414 ? wp_kses_post( $setting['desc_tip'] )
415 : '';
416
417 if ( '' === $description ) {
418 return $desc_tip;
419 }
420
421 if ( '' === $desc_tip ) {
422 return $description;
423 }
424
425 return $description . '<br />' . $desc_tip;
426 }
427
428 /**
429 * Normalize legacy field type.
430 *
431 * @param string $type Legacy field type.
432 * @return string
433 */
434 private static function normalize_type( string $type ): string {
435 $type_map = array(
436 'multiselect' => 'array',
437 'multi_select_countries' => 'array',
438 'single_select_country' => 'select',
439 'single_select_page' => 'select',
440 );
441
442 return $type_map[ $type ] ?? $type;
443 }
444
445 /**
446 * Get a field value.
447 *
448 * @param array $setting Legacy field definition.
449 * @param string $type Canonical field type.
450 * @return mixed
451 */
452 private static function get_field_value( array $setting, string $type ) {
453 if ( array_key_exists( 'value', $setting ) ) {
454 return self::normalize_value( $setting['value'], $type );
455 }
456
457 $default = $setting['default'] ?? '';
458 $value = \WC_Admin_Settings::get_option( (string) $setting['id'], $default );
459
460 return self::normalize_value( $value, $type );
461 }
462
463 /**
464 * Normalize a value for the canonical schema.
465 *
466 * @param mixed $value Field value.
467 * @param string $type Canonical type.
468 * @return mixed
469 */
470 private static function normalize_value( $value, string $type ) {
471 switch ( $type ) {
472 case 'array':
473 return is_array( $value ) ? array_values( $value ) : array();
474 case 'checkbox':
475 return function_exists( 'wc_string_to_bool' ) ? wc_string_to_bool( $value ) : (bool) $value;
476 default:
477 return $value;
478 }
479 }
480
481 /**
482 * Get a field save schema.
483 *
484 * @param array $setting Legacy field definition.
485 * @param string $default_save_adapter Default save adapter.
486 * @return array
487 */
488 private static function get_save_schema( array $setting, string $default_save_adapter ): array {
489 if ( isset( $setting['save'] ) && is_array( $setting['save'] ) ) {
490 return $setting['save'];
491 }
492
493 if ( isset( $setting['is_option'] ) && false === $setting['is_option'] ) {
494 return array( 'adapter' => 'none' );
495 }
496
497 $field_name = isset( $setting['field_name'] ) && is_scalar( $setting['field_name'] )
498 ? (string) $setting['field_name']
499 : (string) $setting['id'];
500
501 return array(
502 'adapter' => $default_save_adapter,
503 'name' => $field_name,
504 );
505 }
506
507 /**
508 * Get visibility metadata for legacy conditional fields.
509 *
510 * @param array $setting Legacy field definition.
511 * @param string|null $visibility_controller Current checkbox group controller.
512 * @return array|null
513 */
514 private static function get_field_visibility( array $setting, ?string $visibility_controller ): ?array {
515 $class_names = isset( $setting['class'] ) && is_string( $setting['class'] ) ? explode( ' ', $setting['class'] ) : array();
516 if ( in_array( 'manage_stock_field', $class_names, true ) ) {
517 return array(
518 'controller' => 'woocommerce_manage_stock',
519 'value' => true,
520 );
521 }
522
523 if ( 'yes' === ( $setting['show_if_checked'] ?? null ) && $visibility_controller ) {
524 return array(
525 'controller' => $visibility_controller,
526 'value' => true,
527 );
528 }
529
530 return null;
531 }
532
533 /**
534 * Normalize field options.
535 *
536 * @param array $setting Legacy field definition.
537 * @return array
538 */
539 private static function get_options( array $setting ): array {
540 if ( ! isset( $setting['options'] ) || ! is_array( $setting['options'] ) ) {
541 return array();
542 }
543
544 $options = array();
545 foreach ( $setting['options'] as $value => $label ) {
546 if ( ! is_scalar( $label ) && null !== $label ) {
547 continue;
548 }
549
550 $options[] = array(
551 'label' => is_scalar( $label ) ? wp_strip_all_tags( html_entity_decode( (string) $label, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ) : '',
552 'value' => (string) $value,
553 );
554 }
555
556 return $options;
557 }
558
559 /**
560 * Normalize custom attributes for React controls.
561 *
562 * @param array $custom_attributes Raw custom attributes.
563 * @return array
564 */
565 private static function get_custom_attributes( array $custom_attributes ): array {
566 $attributes = array();
567
568 foreach ( $custom_attributes as $attribute => $value ) {
569 if ( ! is_scalar( $value ) ) {
570 continue;
571 }
572
573 $attribute_key = sanitize_key( (string) $attribute );
574 if ( '' === $attribute_key ) {
575 continue;
576 }
577
578 $attributes[ $attribute_key ] = $value;
579 }
580
581 return $attributes;
582 }
583
584 /**
585 * Normalize group header actions.
586 *
587 * @param array $setting Legacy title setting definition.
588 * @return array
589 */
590 private static function get_group_actions( array $setting ): array {
591 if ( empty( $setting['actions'] ) || ! is_array( $setting['actions'] ) ) {
592 return array();
593 }
594
595 $actions = array();
596
597 foreach ( $setting['actions'] as $index => $action ) {
598 if ( ! is_array( $action ) || empty( $action['label'] ) || ! is_scalar( $action['label'] ) ) {
599 continue;
600 }
601
602 $href = $action['href'] ?? $action['url'] ?? '';
603 if ( ! is_scalar( $href ) || '' === (string) $href ) {
604 continue;
605 }
606
607 $href = esc_url_raw( (string) $href );
608 if ( '' === $href ) {
609 continue;
610 }
611
612 $normalized_action = array(
613 'id' => isset( $action['id'] ) && is_scalar( $action['id'] ) ? sanitize_key( (string) $action['id'] ) : 'action_' . $index,
614 'label' => wp_strip_all_tags( html_entity_decode( (string) $action['label'], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ),
615 'href' => $href,
616 );
617
618 if ( isset( $action['variant'] ) && is_scalar( $action['variant'] ) ) {
619 $normalized_action['variant'] = sanitize_key( (string) $action['variant'] );
620 }
621
622 if ( isset( $action['target'] ) && is_scalar( $action['target'] ) && in_array( (string) $action['target'], array( '_blank', '_self', '_parent', '_top' ), true ) ) {
623 $normalized_action['target'] = (string) $action['target'];
624 }
625
626 if ( isset( $action['rel'] ) && is_scalar( $action['rel'] ) ) {
627 $normalized_action['rel'] = sanitize_text_field( (string) $action['rel'] );
628 }
629
630 $actions[] = $normalized_action;
631 }
632
633 return $actions;
634 }
635
636 /**
637 * Get the default group.
638 *
639 * @param int $order Group order.
640 * @return array
641 */
642 private static function get_default_group( int $order ): array {
643 return array(
644 'id' => self::DEFAULT_GROUP_ID,
645 'title' => '',
646 'description' => '',
647 'actions' => array(),
648 'order' => $order,
649 'fields' => array(),
650 );
651 }
652 }
653