PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.1.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.1.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / class-mlsimport-field-configuration.php

class-mlsimport-field-configuration.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.1.1, at includes/class-mlsimport-field-configuration.php

618 lines 26.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Authoritative Field Configuration domain module.
4 *
5 * This module hides the legacy `mlsimport_admin_fields_select` parallel-array
6 * schema behind a small public interface. Reads normalize malformed historical
7 * values and reconcile the current MLS metadata in memory. Later methods in
8 * this class own atomic commands and persistence through the injected
9 * compare-and-swap boundary, allowing WordPress and isolated tests to use the
10 * same state-transition rules.
11 *
12 * @package MLSImport
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Own Field Configuration normalization, reconciliation, and mutations.
21 */
22 final class Mlsimport_Field_Configuration {
23
24 /** Revision key stored beside the backward-compatible parallel arrays. */
25 public const REVISION_KEY = '_mlsimport_field_revision';
26
27 /** @var callable Loads the currently persisted option array. */
28 private $load;
29
30 /** @var callable Atomically replaces an expected option array. */
31 private $compare_and_swap;
32
33 /**
34 * Create the module around a storage boundary.
35 *
36 * The load callback returns the saved option. The compare-and-swap callback
37 * receives the exact old and new arrays and returns true only when the old
38 * value was still current. Keeping storage at this boundary makes stale-write
39 * behavior testable without coupling domain tests to WordPress internals.
40 *
41 * @param callable $load Current-value loader.
42 * @param callable $compare_and_swap Atomic persistence callback.
43 */
44 public function __construct( callable $load, callable $compare_and_swap ) {
45 $this->load = $load;
46 $this->compare_and_swap = $compare_and_swap;
47 }
48
49 /**
50 * Return the normalized configuration for the current metadata without saving.
51 *
52 * Processing is deliberately side-effect free: first the legacy schema is
53 * repaired in memory, then missing metadata fields receive initial-setup
54 * defaults, active fields keep their order, and dormant fields remain stored
55 * after the active sequence. Rendering can therefore call this method safely.
56 *
57 * @param array $metadata Current MLS field map keyed by RESO field name.
58 * @param array $theme_schema Theme defaults keyed by RESO field name.
59 * @param array $taxonomies Active taxonomy slug-to-label map; null skips registry cleanup.
60 * @return array Normalized legacy-compatible option array.
61 */
62 public function read( array $metadata, array $theme_schema = array(), $taxonomies = null ): array {
63 $stored = call_user_func( $this->load );
64
65 return $this->reconciled_configuration( $stored, $metadata, $theme_schema, $taxonomies );
66 }
67
68 /**
69 * Return the normalized projection safe for runtime import and rendering.
70 *
71 * Durable state deliberately retains Dormant MLS Fields. Runtime consumers
72 * must not import or display them, so this projection intersects every legacy
73 * band with current metadata and re-indexes the active order in memory only.
74 *
75 * @param array $metadata Current MLS field map keyed by RESO name.
76 * @param array $theme_schema Theme defaults keyed by RESO name.
77 * @param mixed $taxonomies Active taxonomy slug-to-label map, or null.
78 * @return array Active-only legacy-compatible configuration.
79 */
80 public function read_active( array $metadata, array $theme_schema = array(), $taxonomies = null ): array {
81 $configuration = $this->read( $metadata, $theme_schema, $taxonomies );
82 $active_keys = array_values( array_intersect( array_keys( $configuration['field_order'] ), array_keys( $metadata ) ) );
83
84 foreach ( array( 'mls-fields', 'mls-fields-admin', 'mls-fields-label', 'mls-fields-map-postmeta', 'mls-fields-map-taxonomy' ) as $band ) {
85 $configuration[ $band ] = array_intersect_key( $configuration[ $band ], array_flip( $active_keys ) );
86 }
87 $configuration['field_order'] = array();
88 foreach ( $active_keys as $index => $field_key ) {
89 $configuration['field_order'][ $field_key ] = $index;
90 }
91
92 return $configuration;
93 }
94
95 /**
96 * Reconcile freshly gathered metadata and persist it as one configuration change.
97 *
98 * A byte-for-byte equivalent normalized state is a successful no-op: it does
99 * not increase the revision or invoke storage. Otherwise the complete option
100 * receives the next revision and is replaced through compare-and-swap, which
101 * prevents a concurrent administrator change from being overwritten.
102 *
103 * @param array $metadata Current MLS field map.
104 * @param array $theme_schema Theme mapping defaults.
105 * @param array $taxonomies Active taxonomy slug-to-label map.
106 * @return array Field Configuration Result.
107 */
108 public function reconcile( array $metadata, array $theme_schema = array(), array $taxonomies = array() ): array {
109 $result = $this->reconcile_once( $metadata, $theme_schema, $taxonomies );
110
111 // A lost compare-and-swap here means another writer (typically a
112 // concurrent metadata gather from a second admin page) persisted first.
113 // Unlike change(), reconciliation carries no user intent that could be
114 // overwritten: rerun it once on top of the winner's write. Identical
115 // concurrent gathers then converge to changed=false success instead of
116 // a failure that would clear the metadata-populated flag.
117 if ( ! $result['success'] && 'stale_revision' === ( $result['error']['code'] ?? '' ) ) {
118 $result = $this->reconcile_once( $metadata, $theme_schema, $taxonomies );
119 }
120
121 return $result;
122 }
123
124 /**
125 * One reconciliation attempt against the current stored configuration.
126 *
127 * @param array $metadata Current MLS field map.
128 * @param array $theme_schema Theme mapping defaults.
129 * @param array $taxonomies Active taxonomy slug-to-label map.
130 * @return array Field Configuration Result.
131 */
132 private function reconcile_once( array $metadata, array $theme_schema, array $taxonomies ): array {
133 $stored = call_user_func( $this->load );
134 $stored = is_array( $stored ) ? $stored : array();
135 $configuration = $this->reconciled_configuration( $stored, $metadata, $theme_schema, $taxonomies );
136
137 if ( $stored === $configuration ) {
138 return array(
139 'success' => true,
140 'changed' => false,
141 'revision' => $configuration[ self::REVISION_KEY ],
142 'configuration' => $configuration,
143 );
144 }
145
146 $failure = $this->persist_replacement( $stored, $configuration, (int) ( $stored[ self::REVISION_KEY ] ?? 0 ) );
147 if ( null !== $failure ) {
148 return $failure;
149 }
150
151 return array(
152 'success' => true,
153 'changed' => true,
154 'revision' => $configuration[ self::REVISION_KEY ],
155 'configuration' => $configuration,
156 );
157 }
158
159 /**
160 * Replace Field Configuration from an exported settings payload.
161 *
162 * Imported revisions never cross sites: the replacement is normalized using
163 * current metadata and receives the next local revision. Active taxonomy
164 * destinations are checked before the complete replacement reaches storage.
165 *
166 * @param array $incoming Exported legacy-compatible configuration.
167 * @param array $metadata Current MLS field map.
168 * @param array $theme_schema Theme defaults for fields missing in the export.
169 * @param array $taxonomies Allowed active taxonomy destinations.
170 * @return array Field Configuration Result.
171 */
172 public function import_configuration( array $incoming, array $metadata, array $theme_schema, array $taxonomies ): array {
173 $stored = call_user_func( $this->load );
174 $stored = is_array( $stored ) ? $stored : array();
175 $current = $this->reconciled_configuration( $stored, $metadata, $theme_schema, $taxonomies );
176 $local_revision = $current[ self::REVISION_KEY ];
177
178 $allowed_taxonomies = array_keys( $taxonomies );
179 foreach ( array_keys( $metadata ) as $field_key ) {
180 $taxonomy = $this->plain_text( $incoming['mls-fields-map-taxonomy'][ $field_key ] ?? '' );
181 if ( '' !== $taxonomy && ! in_array( $taxonomy, $allowed_taxonomies, true ) ) {
182 return $this->error_result( 'invalid_taxonomy', 'The imported taxonomy is not available for the active property type.', $local_revision );
183 }
184 }
185 $replacement = $this->reconciled_configuration( $incoming, $metadata, $theme_schema, $taxonomies );
186
187 $replacement[ self::REVISION_KEY ] = $local_revision;
188 if ( $stored === $replacement ) {
189 return array(
190 'success' => true,
191 'changed' => false,
192 'revision' => $local_revision,
193 'configuration' => $replacement,
194 );
195 }
196
197 $failure = $this->persist_replacement( $stored, $replacement, $local_revision );
198 if ( null !== $failure ) {
199 return $failure;
200 }
201
202 return array(
203 'success' => true,
204 'changed' => true,
205 'revision' => $replacement[ self::REVISION_KEY ],
206 'configuration' => $replacement,
207 );
208 }
209
210 /**
211 * Apply and persist one compact Field Configuration Change.
212 *
213 * The caller supplies the revision it rendered. The module validates that
214 * revision and every command identifier before mutating a copy of the current
215 * configuration. Only the complete validated replacement reaches storage.
216 * Successful results expose domain field names rather than legacy band names.
217 *
218 * @param int $expected_revision Browser revision that produced the command.
219 * @param array $command Decoded compact command.
220 * @param array $metadata Current MLS fields keyed by RESO name.
221 * @param array $taxonomies Allowed taxonomy slugs, as keys or values.
222 * @param array $theme_schema Initial defaults for any missing field.
223 * @return array Field Configuration Result.
224 */
225 public function change( int $expected_revision, array $command, array $metadata, array $taxonomies, array $theme_schema = array() ): array {
226 $stored = call_user_func( $this->load );
227 $stored = is_array( $stored ) ? $stored : array();
228 $configuration = $this->reconciled_configuration( $stored, $metadata, $theme_schema, $taxonomies );
229 $revision = $configuration[ self::REVISION_KEY ];
230
231 if ( $expected_revision !== $revision ) {
232 return $this->error_result( 'stale_revision', 'Field Configuration changed in another browser. Reload before saving.', $revision );
233 }
234
235 $type = isset( $command['type'] ) && is_scalar( $command['type'] ) ? (string) $command['type'] : '';
236 if ( 'move' === $type ) {
237 return $this->move_field( $stored, $configuration, $command, $metadata );
238 }
239
240 $property = isset( $command['property'] ) && is_scalar( $command['property'] ) ? (string) $command['property'] : '';
241 $value = $command['value'] ?? '';
242 $band = array(
243 'import' => 'mls-fields',
244 'admin' => 'mls-fields-admin',
245 'label' => 'mls-fields-label',
246 'postmeta' => 'mls-fields-map-postmeta',
247 'taxonomy' => 'mls-fields-map-taxonomy',
248 )[ $property ] ?? '';
249 if ( '' === $band ) {
250 return $this->error_result( 'invalid_property', 'The Field Configuration property is not editable.', $revision );
251 }
252 if ( 'bulk' === $type && ! in_array( $property, array( 'import', 'admin' ), true ) ) {
253 return $this->error_result( 'invalid_property', 'Bulk changes may edit only import or administrator visibility.', $revision );
254 }
255
256 if ( in_array( $property, array( 'import', 'admin' ), true ) ) {
257 $value = $this->boolean_value( $value );
258 } else {
259 $value = $this->plain_text( $value );
260 }
261
262 if ( 'taxonomy' === $property && '' !== $value ) {
263 $allowed_taxonomies = array_keys( $taxonomies );
264 if ( ! in_array( $value, $allowed_taxonomies, true ) ) {
265 return $this->error_result( 'invalid_taxonomy', 'The taxonomy is not available for the active property type.', $revision );
266 }
267 }
268
269 if ( 'set' === $type ) {
270 $field_keys = array( isset( $command['field'] ) && is_scalar( $command['field'] ) ? (string) $command['field'] : '' );
271 } elseif ( 'bulk' === $type && isset( $command['fields'] ) && is_array( $command['fields'] ) ) {
272 $field_keys = array_values( array_unique( array_map( 'strval', $command['fields'] ) ) );
273 } else {
274 return $this->error_result( 'invalid_command', 'Unknown Field Configuration command.', $revision );
275 }
276
277 // Validate every named field before changing the copy. This ordering is the
278 // all-or-nothing boundary for bulk commands.
279 foreach ( $field_keys as $field_key ) {
280 if ( '' === $field_key || ! array_key_exists( $field_key, $metadata ) ) {
281 return $this->error_result( 'unknown_field', 'The MLS field is not available in current metadata.', $revision );
282 }
283 }
284
285 foreach ( $field_keys as $field_key ) {
286 $configuration[ $band ][ $field_key ] = $value;
287 if ( 'postmeta' === $property && '' !== $value ) {
288 $configuration['mls-fields-map-taxonomy'][ $field_key ] = '';
289 }
290 if ( 'taxonomy' === $property && '' !== $value ) {
291 $configuration['mls-fields-map-postmeta'][ $field_key ] = '';
292 }
293 }
294
295 $changed = $configuration !== $this->reconciled_configuration( $stored, $metadata, $theme_schema, $taxonomies );
296 if ( ! $changed ) {
297 return $this->success_result( $configuration, $field_keys, false );
298 }
299
300 $failure = $this->persist_replacement( $stored, $configuration, $revision );
301 if ( null !== $failure ) {
302 return $failure;
303 }
304
305 return $this->success_result( $configuration, $field_keys, true );
306 }
307
308 /**
309 * Validate and persist one relative field move.
310 *
311 * The command identifies only the moving field, anchor field, and side. The
312 * server derives the complete order, keeps dormant fields behind active ones,
313 * and reorders every legacy parallel array together before the atomic save.
314 *
315 * @param array $stored Exact raw value read from storage.
316 * @param array $configuration Normalized working configuration.
317 * @param array $command Compact move command.
318 * @param array $metadata Current active MLS fields.
319 * @return array Field Configuration Result.
320 */
321 private function move_field( array $stored, array $configuration, array $command, array $metadata ): array {
322 $revision = $configuration[ self::REVISION_KEY ];
323 $baseline = $configuration;
324 $field = isset( $command['field'] ) && is_scalar( $command['field'] ) ? (string) $command['field'] : '';
325 $anchor = isset( $command['anchor'] ) && is_scalar( $command['anchor'] ) ? (string) $command['anchor'] : '';
326 $position = isset( $command['position'] ) && is_scalar( $command['position'] ) ? (string) $command['position'] : '';
327
328 if ( $field === $anchor || ! array_key_exists( $field, $metadata ) || ! array_key_exists( $anchor, $metadata ) ) {
329 return $this->error_result( 'unknown_field', 'Both move fields must be available in current MLS metadata.', $revision );
330 }
331 if ( ! in_array( $position, array( 'before', 'after' ), true ) ) {
332 return $this->error_result( 'invalid_position', 'A field may move only before or after its anchor.', $revision );
333 }
334
335 $ordered_keys = array_keys( $configuration['field_order'] );
336 $active_keys = array_values( array_intersect( $ordered_keys, array_keys( $metadata ) ) );
337 $dormant_keys = array_values( array_diff( $ordered_keys, array_keys( $metadata ) ) );
338 $active_keys = array_values( array_diff( $active_keys, array( $field ) ) );
339 $anchor_index = array_search( $anchor, $active_keys, true );
340 $insert_index = 'before' === $position ? $anchor_index : $anchor_index + 1;
341 array_splice( $active_keys, $insert_index, 0, array( $field ) );
342
343 $all_keys = array_merge( $active_keys, $dormant_keys );
344 $configuration['field_order'] = array();
345 foreach ( $all_keys as $index => $field_key ) {
346 $configuration['field_order'][ $field_key ] = $index;
347 }
348 $configuration = $this->order_parallel_arrays( $configuration, $all_keys );
349
350 if ( $baseline === $configuration ) {
351 $result = $this->success_result( $configuration, array( $field ), false );
352 $result['order'] = $active_keys;
353 return $result;
354 }
355
356 $failure = $this->persist_replacement( $stored, $configuration, $revision );
357 if ( null !== $failure ) {
358 return $failure;
359 }
360
361 $result = $this->success_result( $configuration, array( $field ), true );
362 $result['order'] = $active_keys;
363 return $result;
364 }
365
366 /**
367 * Rebuild every parallel legacy band in one authoritative sequence.
368 *
369 * The option schema stores the same field keys in five independent arrays.
370 * Reconstructing each band from the shared key list prevents a move from
371 * changing only one band and silently separating a field from its values.
372 *
373 * @param array $configuration Normalized Field Configuration arrays.
374 * @param array $ordered_keys Complete active-then-dormant field sequence.
375 * @return array Configuration with all parallel arrays in the same order.
376 */
377 private function order_parallel_arrays( array $configuration, array $ordered_keys ): array {
378 foreach ( array( 'mls-fields', 'mls-fields-admin', 'mls-fields-label', 'mls-fields-map-postmeta', 'mls-fields-map-taxonomy' ) as $band ) {
379 $ordered = array();
380 foreach ( $ordered_keys as $field_key ) {
381 $ordered[ $field_key ] = $configuration[ $band ][ $field_key ];
382 }
383 $configuration[ $band ] = $ordered;
384 }
385
386 return $configuration;
387 }
388
389 /**
390 * Build the authoritative browser result for the affected MLS fields.
391 *
392 * Legacy option band names stay inside this module. The result translates
393 * them into stable domain properties so the browser can replace optimistic
394 * input values with the exact values that actually reached storage.
395 *
396 * @param array $configuration Saved or normalized Field Configuration.
397 * @param array $field_keys Fields whose authoritative values are needed.
398 * @param bool $changed Whether persistence changed the stored option.
399 * @return array Successful Field Configuration Result.
400 */
401 private function success_result( array $configuration, array $field_keys, bool $changed ): array {
402 $fields = array();
403 foreach ( $field_keys as $field_key ) {
404 $fields[ $field_key ] = array(
405 'import' => $configuration['mls-fields'][ $field_key ],
406 'admin' => $configuration['mls-fields-admin'][ $field_key ],
407 'label' => $configuration['mls-fields-label'][ $field_key ],
408 'postmeta' => $configuration['mls-fields-map-postmeta'][ $field_key ],
409 'taxonomy' => $configuration['mls-fields-map-taxonomy'][ $field_key ],
410 );
411 }
412
413 return array(
414 'success' => true,
415 'changed' => $changed,
416 'revision' => $configuration[ self::REVISION_KEY ],
417 'fields' => $fields,
418 );
419 }
420
421 /**
422 * Build a stable rejected result without exposing storage implementation.
423 *
424 * Every validation and persistence failure uses the same public envelope.
425 * The browser can therefore decide between retry and reload from `code`
426 * without knowing whether WordPress options or another store is underneath.
427 *
428 * @param string $code Machine-readable failure code.
429 * @param string $message Administrator-facing explanation.
430 * @param int $revision Latest revision known to the module.
431 * @return array Failed Field Configuration Result.
432 */
433 private function error_result( string $code, string $message, int $revision ): array {
434 return array(
435 'success' => false,
436 'changed' => false,
437 'revision' => $revision,
438 'error' => array(
439 'code' => $code,
440 'message' => $message,
441 ),
442 );
443 }
444
445 /**
446 * Increment the revision and atomically persist one complete replacement.
447 *
448 * All mutation paths pass through this method. It advances the revision only
449 * for a real change, attempts compare-and-swap once, and converts a failed
450 * attempt into the shared stale-or-storage error contract.
451 *
452 * @param array $stored Exact raw option used as the CAS expectation.
453 * @param array $replacement Complete validated replacement, updated in place.
454 * @param int $prior_revision Revision associated with the attempted change.
455 * @return array|null Failure result, or null when persistence succeeds.
456 */
457 private function persist_replacement( array $stored, array &$replacement, int $prior_revision ) {
458 $replacement[ self::REVISION_KEY ]++;
459 if ( ! call_user_func( $this->compare_and_swap, $stored, $replacement ) ) {
460 return $this->storage_failure_result( $prior_revision );
461 }
462
463 return null;
464 }
465
466 /**
467 * Distinguish a concurrent replacement from an unchanged storage failure.
468 *
469 * A failed compare-and-swap is followed by one fresh read. A different
470 * revision means another writer won and the browser must reload; an unchanged
471 * revision means storage itself failed and the queued command may be retried.
472 *
473 * @param int $attempted_revision Revision observed before the failed write.
474 * @return array Failed Field Configuration Result.
475 */
476 private function storage_failure_result( int $attempted_revision ): array {
477 $current = call_user_func( $this->load );
478 $current_revision = is_array( $current ) ? max( 0, (int) ( $current[ self::REVISION_KEY ] ?? 0 ) ) : 0;
479
480 if ( $current_revision !== $attempted_revision ) {
481 return $this->error_result( 'stale_revision', 'Field Configuration changed in another browser. Reload before saving.', $current_revision );
482 }
483
484 return $this->error_result( 'persistence_failed', 'Field Configuration could not be saved.', $current_revision );
485 }
486
487 /**
488 * Normalize the saved arrays and reconcile them with current MLS metadata.
489 *
490 * Existing active fields retain their saved sequence. New fields are appended
491 * alphabetically using the same defaults as initial setup. Dormant fields stay
492 * after all active fields so a returning field reappears at the end instead of
493 * reclaiming a stale position.
494 *
495 * @param mixed $stored Raw option value.
496 * @param array $metadata Current MLS field map.
497 * @param array $theme_schema Theme mapping defaults.
498 * @param mixed $taxonomies Active taxonomy slug-to-label map, or null to skip registry cleanup.
499 * @return array Reconciled option array.
500 */
501 private function reconciled_configuration( $stored, array $metadata, array $theme_schema, $taxonomies = null ): array {
502 $stored = is_array( $stored ) ? $stored : array();
503 $bands = array(
504 'mls-fields',
505 'mls-fields-admin',
506 'mls-fields-label',
507 'mls-fields-map-postmeta',
508 'mls-fields-map-taxonomy',
509 );
510
511 $field_keys = array();
512 foreach ( array_merge( $bands, array( 'field_order' ) ) as $band ) {
513 if ( isset( $stored[ $band ] ) && is_array( $stored[ $band ] ) ) {
514 $field_keys = array_merge( $field_keys, array_keys( $stored[ $band ] ) );
515 }
516 }
517 $field_keys = array_values( array_unique( array_map( 'strval', $field_keys ) ) );
518
519 $order = isset( $stored['field_order'] ) && is_array( $stored['field_order'] )
520 ? $stored['field_order']
521 : array();
522 usort(
523 $field_keys,
524 static function ( $left, $right ) use ( $order ) {
525 $left_order = isset( $order[ $left ] ) && is_numeric( $order[ $left ] ) ? (int) $order[ $left ] : PHP_INT_MAX;
526 $right_order = isset( $order[ $right ] ) && is_numeric( $order[ $right ] ) ? (int) $order[ $right ] : PHP_INT_MAX;
527 return $left_order === $right_order ? strcmp( $left, $right ) : $left_order <=> $right_order;
528 }
529 );
530
531 $metadata_keys = array_values( array_map( 'strval', array_keys( $metadata ) ) );
532 $new_keys = array_values( array_diff( $metadata_keys, $field_keys ) );
533 sort( $new_keys, SORT_STRING );
534 $active_keys = array_values( array_intersect( $field_keys, $metadata_keys ) );
535 $dormant_keys = array_values( array_diff( $field_keys, $metadata_keys ) );
536 $ordered_keys = array_merge( $active_keys, $new_keys, $dormant_keys );
537
538 $configuration = array();
539 foreach ( $bands as $band ) {
540 $configuration[ $band ] = array();
541 }
542 // Ordering is filled per key below; seed it so a site with no fields yet
543 // still returns the complete band set instead of a missing field_order.
544 $configuration['field_order'] = array();
545
546 foreach ( $ordered_keys as $index => $field_key ) {
547 $is_new = ! in_array( $field_key, $field_keys, true );
548 $is_active = in_array( $field_key, $metadata_keys, true );
549 $schema = isset( $theme_schema[ $field_key ] ) && is_array( $theme_schema[ $field_key ] )
550 ? $theme_schema[ $field_key ]
551 : array();
552
553 $configuration['mls-fields'][ $field_key ] = $is_new
554 ? ( empty( $schema ) ? 0 : 1 )
555 : $this->boolean_value( $stored['mls-fields'][ $field_key ] ?? 0 );
556 $configuration['mls-fields-admin'][ $field_key ] = $is_new
557 ? 0
558 : $this->boolean_value( $stored['mls-fields-admin'][ $field_key ] ?? 0 );
559 $configuration['mls-fields-label'][ $field_key ] = $is_new
560 ? ''
561 : $this->plain_text( $stored['mls-fields-label'][ $field_key ] ?? '' );
562 $configuration['mls-fields-map-postmeta'][ $field_key ] = $is_new
563 ? ''
564 : $this->plain_text( $stored['mls-fields-map-postmeta'][ $field_key ] ?? '' );
565
566 $taxonomy_default = isset( $schema['type'], $schema['name'] ) && 'taxonomy' === $schema['type']
567 ? $this->plain_text( $schema['name'] )
568 : '';
569 $taxonomy_value = $is_new
570 ? $taxonomy_default
571 : $this->plain_text( $stored['mls-fields-map-taxonomy'][ $field_key ] ?? '' );
572 if ( $is_active && is_array( $taxonomies ) && '' !== $taxonomy_value && ! array_key_exists( $taxonomy_value, $taxonomies ) ) {
573 $taxonomy_value = '';
574 }
575 if ( '' !== $configuration['mls-fields-map-postmeta'][ $field_key ] ) {
576 $taxonomy_value = '';
577 }
578 $configuration['mls-fields-map-taxonomy'][ $field_key ] = $taxonomy_value;
579 $configuration['field_order'][ $field_key ] = $index;
580 }
581
582 $configuration[ self::REVISION_KEY ] = max( 0, (int) ( $stored[ self::REVISION_KEY ] ?? 0 ) );
583
584 return $configuration;
585 }
586
587 /**
588 * Normalize checkbox-like legacy values to the public integer schema.
589 *
590 * Historical settings forms saved several truthy spellings. Only the known
591 * spellings become 1; all other input becomes 0 so callers never receive a
592 * mixture of booleans, strings, and integers.
593 *
594 * @param mixed $value Raw legacy checkbox value.
595 * @return int Either 1 or 0.
596 */
597 private function boolean_value( $value ): int {
598 return in_array( $value, array( 1, '1', true, 'yes', 'true', 'on' ), true ) ? 1 : 0;
599 }
600
601 /**
602 * Normalize free text as trimmed, tag-free Unicode text.
603 *
604 * Scalar input is stripped of tags, line breaks are collapsed to spaces, and
605 * surrounding whitespace is removed. Non-scalar input becomes an empty
606 * string rather than leaking an array or object into the option schema.
607 *
608 * @param mixed $value Raw label or mapping destination.
609 * @return string Safe normalized text.
610 */
611 private function plain_text( $value ): string {
612 $value = is_scalar( $value ) ? (string) $value : '';
613 $value = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( $value, true ) : strip_tags( $value );
614
615 return trim( preg_replace( '/[\r\n\t]+/u', ' ', $value ) );
616 }
617 }
618