PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.2 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 All 37 releases
← All changes | includes/mlsimport-progressive-save.php +253 -629 7.0.67.2.1 View file →
@@ -1,697 +1,321 @@
1 1 <?php
2 2 /**
3 - * MLS Import Progressive Save Handlers
3 + * WordPress persistence adapter for the Field Configuration module.
4 4 *
5 - * Server-side handlers for the progressive save system:
6 - * - Initial save detection and chunked saving
7 - * - Individual field option saving
8 - * - Optimized field order saving
5 + * The browser exposes one mutation endpoint with four fixed POST variables:
6 + * action, nonce, revision, and one JSON command. This file translates that
7 + * request into the domain module, performs an exact database compare-and-swap,
8 + * refreshes WordPress's option cache, and emits the authoritative result. The
9 + * former chunk, individual, bulk, and position handlers intentionally do not
10 + * survive as alternate mutation paths.
9 11 *
10 - * @package MLSImport
11 - * @subpackage MLSImport/includes
12 + * @package MLSImport
12 13 */
13 14
14 -// Exit if accessed directly
15 15 if ( ! defined( 'ABSPATH' ) ) {
16 - exit;
16 + exit;
17 17 }
18 18
19 -
20 19 /**
21 - * AJAX handler for saving field order
22 - * This stores the order directly in the mlsimport_admin_fields_select option
20 + * Decode one connection's MLS metadata into the domain module's field map.
21 + *
22 + * Metadata can be stored as the original JSON string or as an already decoded
23 + * array. Both shapes are accepted here; malformed or absent metadata becomes
24 + * an empty map so every caller reaches the same normalization path. Since
25 + * multi-MLS (#275) the blob is per-connection: the resolver returns the
26 + * "_{mls_id}"-suffixed option for the given (or current) connection.
27 + *
28 + * @param int $mls_id Connection whose metadata to read; 0 = current connection.
29 + * @return array That connection's MLS metadata keyed by RESO field name.
23 30 */
24 -function mlsimport_ajax_save_field_order() {
25 - // Add detailed logging for debugging
31 +function mlsimport_field_configuration_metadata( int $mls_id = 0 ): array {
32 + $metadata = mlsimport_get_connection_option( 'mlsimport_mls_metadata_mls_data', '', $mls_id );
33 + $metadata = is_string( $metadata ) ? json_decode( $metadata, true ) : $metadata;
26 34
27 - // Check nonce
28 - if (!isset($_POST['security']) || !wp_verify_nonce($_POST['security'], 'mlsimport_field_selector_nonce')) {
29 - //error_log('Invalid security token in field order save');
30 - wp_send_json_error('Invalid security token');
31 - return;
32 - }
33 -
34 - // Check for both parameter formats
35 - $field_order = array();
36 -
37 - // Check for standard array format (field_order[])
38 - if (isset($_POST['field_order']) && is_array($_POST['field_order'])) {
39 - $field_order = $_POST['field_order'];
40 - //error_log('Using field_order parameter: ' . count($field_order) . ' items');
41 - }
42 - // Check for older format (fields[])
43 - else if (isset($_POST['fields']) && is_array($_POST['fields'])) {
44 - $field_order = $_POST['fields'];
45 - //error_log('Using fields parameter: ' . count($field_order) . ' items');
46 - }
47 - // Otherwise, try to get values directly by key pattern
48 - else {
49 - //error_log('No direct array found, checking for field_order[] pattern');
50 - // Check if we need to manually extract from the POST data (for field_order[])
51 - foreach ($_POST as $key => $value) {
52 - if (preg_match('/^field_order(\[.*\])?$/', $key)) {
53 - if (is_array($value)) {
54 - $field_order = $value;
55 - //error_log('Found field_order as array with ' . count($field_order) . ' items');
56 - break;
57 - }
58 - }
59 - }
60 -
61 - // If we still don't have field order data, try to extract it directly from POST
62 - if (empty($field_order)) {
63 - //error_log('Trying to manually build array from field_order[]');
64 - foreach ($_POST as $key => $value) {
65 - if (strpos($key, 'field_order[') === 0) {
66 - $field_order[] = $value;
67 - }
68 - }
69 - //error_log('Manually built array with ' . count($field_order) . ' items');
70 - }
71 - }
72 -
73 - // Check if we have any field order data
74 - if (empty($field_order)) {
75 - //error_log('No field order data found in request');
76 - wp_send_json_error('No field order provided');
77 - return;
78 - }
79 -
80 - // Log field count for debugging
81 - //error_log('Saving ' . count($field_order) . ' fields in order');
82 -
83 - // Get current options
84 - $options = get_option('mlsimport_admin_fields_select', array());
85 -
86 - // Make sure $options is an array
87 - if (!is_array($options)) {
88 - $options = array();
89 - }
90 -
91 - // Store the field order directly in the options
92 - $options['field_order'] = array_map('sanitize_text_field', $field_order);
93 -
94 - // Save the updated options
95 - $result = update_option('mlsimport_admin_fields_select', $options);
96 -
97 - if ($result) {
98 - global $mlsimport;
99 - if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'enviroment_custom_fields' ) ) {
100 - $mlsimport->admin->env_data->enviroment_custom_fields( $mlsimport->get_plugin_name() );
101 - }
102 - //error_log('Field order saved successfully');
103 - wp_send_json_success('Field order saved successfully');
104 - } else {
105 - //error_log('Failed to save field order - update_option returned false');
106 - wp_send_json_error('Failed to save field order - update_option returned false');
107 - }
35 + return is_array( $metadata ) ? $metadata : array();
108 36 }
109 37
110 -
111 -
112 38 /**
113 - * Add this function to your mlsimport-admin-fields-select.php file
114 - * Outputs the nonce field needed for field ordering
39 + * Return taxonomy destinations registered for the active property post type.
40 + *
41 + * The adapter is resolved on demand because metadata gathering and AJAX calls
42 + * run after the plugin has created its active theme environment.
43 + *
44 + * @return array Taxonomy slug-to-label map accepted by mutation validation.
115 45 */
116 -function mlsimport_add_field_selector_nonce() {
117 - wp_nonce_field('mlsimport_field_selector_nonce', 'mlsimport_field_selector_nonce');
118 -}
46 +function mlsimport_field_configuration_taxonomies(): array {
47 + global $mlsimport;
119 48
49 + $post_type = '';
50 + if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'get_property_post_type' ) ) {
51 + $post_type = $mlsimport->admin->env_data->get_property_post_type();
52 + }
120 53
54 + $taxonomies = mlsimport_get_custom_post_type_taxonomies( $post_type );
121 55
56 + return is_array( $taxonomies ) ? $taxonomies : array();
57 +}
122 58
123 -
124 -
125 59 /**
126 - * Register AJAX handlers for progressive saving
60 + * Atomically replace the one Field Configuration option.
61 + *
62 + * WordPress's update_option() has no expected-value condition, so two tabs can
63 + * both pass an application-level revision check and the slower request can
64 + * overwrite the newer one. The UPDATE below includes the exact serialized old
65 + * value in its WHERE clause. One request wins; the other updates zero rows and
66 + * is reported by the module as stale. Cache and public option hooks are updated
67 + * once only after the database confirms the replacement.
68 + *
69 + * @param array $expected Exact option value previously loaded.
70 + * @param array $replacement Complete normalized replacement.
71 + * @param string $option_name Concrete (per-connection) option to swap; ''
72 + * resolves the current connection's option (#275).
73 + * @return bool True only when this caller won the compare-and-swap.
127 74 */
128 -function mlsimport_register_progressive_save_handlers() {
129 - // Check if initial save is needed
130 - add_action('wp_ajax_mlsimport_check_initial_save_needed', 'mlsimport_ajax_check_initial_save_needed');
131 -
132 - // Save a chunk of fields
133 - add_action('wp_ajax_mlsimport_save_field_chunk', 'mlsimport_ajax_save_field_chunk');
75 +function mlsimport_field_configuration_compare_and_swap( array $expected, array $replacement, string $option_name = '' ): bool {
76 + global $wpdb;
134 77
135 - // Save an individual field option
136 - add_action('wp_ajax_mlsimport_save_field_option', 'mlsimport_ajax_save_field_option');
78 + if ( '' === $option_name ) {
79 + $option_name = mlsimport_connection_option_name( 'mlsimport_admin_fields_select' );
80 + }
81 + $row = $wpdb->get_row(
82 + $wpdb->prepare(
83 + "SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
84 + $option_name
85 + ),
86 + ARRAY_A
87 + );
137 88
138 - // Bulk save import selections
139 - add_action('wp_ajax_mlsimport_save_bulk_import', 'mlsimport_ajax_save_bulk_import');
89 + // add_option() uses the option_name unique key as the atomic first-write gate.
90 + if ( null === $row ) {
91 + if ( array() !== $expected ) {
92 + return false;
93 + }
140 94
141 - // Bulk save admin visibility selections
142 - add_action('wp_ajax_mlsimport_save_bulk_admin', 'mlsimport_ajax_save_bulk_admin');
143 -}
144 -add_action('init', 'mlsimport_register_progressive_save_handlers');
95 + $added = add_option( $option_name, $replacement, '', false );
96 + if ( ! $added ) {
97 + wp_cache_delete( $option_name, 'options' );
98 + wp_cache_delete( 'alloptions', 'options' );
99 + wp_cache_delete( 'notoptions', 'options' );
100 + } else {
101 + if ( function_exists( 'mlsimport_active_field_configuration' ) ) {
102 + mlsimport_active_field_configuration( true );
103 + }
104 + // The theme-adapter resync listens on the BASE option's hook name
105 + // (loader binds add/update_option_mlsimport_admin_fields_select).
106 + // Per-connection storage (#275) uses a suffixed option name, so
107 + // WordPress fires "add_option_{$option_name}" instead — announce the
108 + // field-selection change on the base name explicitly.
109 + if ( 'mlsimport_admin_fields_select' !== $option_name ) {
110 + do_action( 'update_option_mlsimport_admin_fields_select', $expected, $replacement, $option_name );
111 + }
112 + }
145 113
114 + return $added;
115 + }
146 116
117 + $old_serialized = maybe_serialize( $expected );
118 + $new_serialized = maybe_serialize( $replacement );
119 + $updated = $wpdb->query(
120 + $wpdb->prepare(
121 + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s",
122 + $new_serialized,
123 + $option_name,
124 + $old_serialized
125 + )
126 + );
127 + if ( 1 !== $updated ) {
128 + // Another request won after this process loaded its option. Discard every
129 + // local option-cache route so the module can report the winner's revision.
130 + wp_cache_delete( $option_name, 'options' );
131 + wp_cache_delete( 'alloptions', 'options' );
132 + wp_cache_delete( 'notoptions', 'options' );
133 + return false;
134 + }
147 135
136 + // Mirror update_option() cache behavior while preserving the existing
137 + // autoload choice. Large 1,000-field configurations are not newly autoloaded.
138 + $alloptions = wp_load_alloptions( true );
139 + if ( isset( $alloptions[ $option_name ] ) ) {
140 + $alloptions[ $option_name ] = $new_serialized;
141 + wp_cache_set( 'alloptions', $alloptions, 'options' );
142 + } else {
143 + wp_cache_set( $option_name, $new_serialized, 'options' );
144 + }
148 145
146 + mlsimport_active_field_configuration( true );
147 + // Announce on the BASE hook name: the theme-adapter resync listener is
148 + // bound to update_option_mlsimport_admin_fields_select regardless of which
149 + // connection's suffixed option (#275) actually stored the change.
150 + do_action( 'update_option_mlsimport_admin_fields_select', $expected, $replacement, 'mlsimport_admin_fields_select' );
151 + do_action( 'updated_option', $option_name, $expected, $replacement );
149 152
153 + return true;
154 +}
150 155
151 156 /**
152 - * AJAX handler to save one chunk of the field-selection grid.
157 + * Construct the authoritative module around one connection's option store.
153 158 *
154 - * Verifies the 'mlsimport_field_selector_nonce' nonce AND requires the
155 - * 'administrator' capability. Merges the posted chunk of fields (import/admin/
156 - * label/postmeta/taxonomy flags) into mlsimport_admin_fields_select, assigning
157 - * each field an incrementing field_order index, then refreshes the theme's
158 - * custom-field registration.
159 + * Step by step (#275 — per-connection field mapping):
160 + * 1. Resolve the concrete option name once: the given connection's suffixed
161 + * 'mlsimport_admin_fields_select_{mls_id}', or the current connection's
162 + * when no mls_id is passed (every legacy call site).
163 + * 2. Bind BOTH the loader and the compare-and-swap writer to that one name,
164 + * so load and persistence can never address different connections. The
165 + * revision key and CAS semantics are unchanged — just scoped per option.
166 + *
167 + * @param int $mls_id Connection to bind to; 0 = current connection.
168 + * @return Mlsimport_Field_Configuration Configured domain service.
159 169 */
160 -function mlsimport_ajax_save_field_chunk() {
161 - // Check nonce
162 - if (!isset($_POST['security']) || !wp_verify_nonce($_POST['security'], 'mlsimport_field_selector_nonce')) {
163 - wp_send_json_error('Invalid security token');
164 - return;
165 - }
170 +function mlsimport_field_configuration( int $mls_id = 0 ): Mlsimport_Field_Configuration {
171 + // Step 1: one resolution, shared by both storage callbacks.
172 + $option_name = mlsimport_connection_option_name( 'mlsimport_admin_fields_select', $mls_id );
166 173
167 - // Capability check — the settings page is gated by 'administrator'; a nonce
168 - // is not authorization.
169 - if ( ! current_user_can( 'administrator' ) ) {
170 - wp_send_json_error( 'Insufficient permissions' );
171 - return;
172 - }
173 -
174 - // Check if fields were provided
175 - if (!isset($_POST['fields']) || !is_array($_POST['fields'])) {
176 - wp_send_json_error('No field data provided');
177 - return;
178 - }
179 -
180 - // Get chunk info
181 - $chunk_index = isset($_POST['chunk_index']) ? intval($_POST['chunk_index']) : 0;
182 - $total_chunks = isset($_POST['total_chunks']) ? intval($_POST['total_chunks']) : 1;
183 - $is_last_chunk = ($chunk_index + 1) == $total_chunks;
184 -
185 - // Get current options
186 - $options = get_option('mlsimport_admin_fields_select', array());
187 -
188 - // Initialize arrays if they don't exist
189 - if (!is_array($options)) {
190 - $options = array();
191 - }
192 -
193 - if (!isset($options['mls-fields'])) {
194 - $options['mls-fields'] = array();
195 - }
196 -
197 - if (!isset($options['mls-fields-admin'])) {
198 - $options['mls-fields-admin'] = array();
199 - }
200 -
201 - if (!isset($options['mls-fields-label'])) {
202 - $options['mls-fields-label'] = array();
203 - }
204 -
205 - if (!isset($options['mls-fields-map-postmeta'])) {
206 - $options['mls-fields-map-postmeta'] = array();
207 - }
208 -
209 - if (!isset($options['mls-fields-map-taxonomy'])) {
210 - $options['mls-fields-map-taxonomy'] = array();
211 - }
212 -
213 - // Seed the order counter: start at 0 for a new order map, otherwise continue
214 - // after the fields already recorded so this chunk appends to the sequence.
215 - if (!isset($options['field_order'])) {
216 - $options['field_order'] = array();
217 - $i = 0;
218 - }else{
219 - $i = count($options['field_order'] );
220 - }
221 -
222 -
223 -
224 - // Process fields in the chunk
225 - // For each posted field, copy whichever sub-values are present into their
226 - // parallel arrays, then stamp its position in field_order.
227 - foreach ($_POST['fields'] as $field_key => $field_data) {
228 -
229 -
230 - // Process field data as before
231 - if (isset($field_data['import'])) {
232 - $options['mls-fields'][$field_key] = intval($field_data['import']);
233 - }
234 -
235 - if (isset($field_data['admin'])) {
236 - $options['mls-fields-admin'][$field_key] = intval($field_data['admin']);
237 - }
238 -
239 - if (isset($field_data['label'])) {
240 - $options['mls-fields-label'][$field_key] = $field_data['label'];
241 - }
242 -
243 - if (isset($field_data['postmeta'])) {
244 - $options['mls-fields-map-postmeta'][$field_key] = $field_data['postmeta'];
245 - }
246 -
247 - if (isset($field_data['taxonomy'])) {
248 - $options['mls-fields-map-taxonomy'][$field_key] = $field_data['taxonomy'];
249 - }
250 -
251 -
252 - $options['field_order'][$field_key] = $i++;
253 -
254 - }
255 -
256 -
257 -
258 -
259 - // Save the options
260 - $saved = update_option('mlsimport_admin_fields_select', $options);
261 - if ( $saved ) {
262 - global $mlsimport;
263 - if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'enviroment_custom_fields' ) ) {
264 - $mlsimport->admin->env_data->enviroment_custom_fields( $mlsimport->get_plugin_name() );
265 - }
266 - }
267 -
268 - wp_send_json_success(array(
269 -
270 - 'field_order'=> $options['field_order'],
271 - 'message' => 'Field chunk ' . ($chunk_index + 1) . ' of ' . $total_chunks . ' saved successfully',
272 - 'chunkIndex' => $chunk_index,
273 - 'totalChunks' => $total_chunks,
274 - 'isLastChunk' => $is_last_chunk,
275 -
276 - ));
174 + // Step 2: loader and CAS are closures over the same resolved name.
175 + return new Mlsimport_Field_Configuration(
176 + static function () use ( $option_name ) {
177 + $value = get_option( $option_name, array() );
178 + return is_array( $value ) ? $value : array();
179 + },
180 + static function ( array $expected, array $replacement ) use ( $option_name ) {
181 + return mlsimport_field_configuration_compare_and_swap( $expected, $replacement, $option_name );
182 + }
183 + );
277 184 }
278 185
279 186 /**
280 - * AJAX handler to save import selections in bulk
187 + * Return normalized durable state for consumers that must remove dormant data.
188 + *
189 + * Theme custom-field registries need both active fields to add and dormant
190 + * fields to remove from theme-owned display definitions. This read still enters
191 + * through the module, preserving normalization and taxonomy rules without
192 + * exposing a direct option read as an alternate persistence boundary.
193 + *
194 + * @return array Normalized active-and-dormant Field Configuration.
281 195 */
282 -function mlsimport_ajax_save_bulk_import() {
283 - if (!isset($_POST['security']) || !wp_verify_nonce($_POST['security'], 'mlsimport_field_selector_nonce')) {
284 - wp_send_json_error('Invalid security token');
285 - }
286 -
287 - // Capability check — the settings page is gated by 'administrator'; a nonce
288 - // is not authorization.
289 - if ( ! current_user_can( 'administrator' ) ) {
290 - wp_send_json_error( 'Insufficient permissions' );
291 - }
292 -
293 - if (!isset($_POST['fields']) || !is_array($_POST['fields'])) {
294 - wp_send_json_error('No field data provided');
295 - }
296 -
297 - $options = get_option('mlsimport_admin_fields_select', array());
298 - if (!isset($options['mls-fields']) || !is_array($options['mls-fields'])) {
299 - $options['mls-fields'] = array();
300 - }
301 -
302 - foreach ($_POST['fields'] as $key => $value) {
303 - $options['mls-fields'][sanitize_text_field($key)] = intval($value);
304 - }
305 -
306 - $result = update_option('mlsimport_admin_fields_select', $options);
307 - if ($result) {
308 - global $mlsimport;
309 - if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'enviroment_custom_fields' ) ) {
310 - $mlsimport->admin->env_data->enviroment_custom_fields( $mlsimport->get_plugin_name() );
311 - }
312 - wp_send_json_success('Bulk import selections saved');
313 - } else {
314 - wp_send_json_error('Failed to save selections');
315 - }
196 +function mlsimport_normalized_field_configuration(): array {
197 + return mlsimport_field_configuration()->read(
198 + mlsimport_field_configuration_metadata(),
199 + mlsimport_hardocde_theme_schema(),
200 + mlsimport_field_configuration_taxonomies()
201 + );
316 202 }
317 203
318 204 /**
319 - * AJAX handler to save admin visibility selections in bulk
205 + * Return the active-only configuration for import and display consumers.
206 + *
207 + * The durable option retains Dormant MLS Fields so their choices can return.
208 + * Runtime consumers use this projection to exclude those fields consistently
209 + * without each theme reimplementing metadata intersection and array ordering.
210 + *
211 + * The projection is cached for the request because import adapters consult it
212 + * once per listing. Rebuilding and sorting 1,000 parallel fields for every
213 + * listing would turn schema safety into an avoidable import bottleneck. The
214 + * cache is keyed by connection (#277 — a task-bound import may read another
215 + * connection's projection); a refresh drops EVERY cached projection so the
216 + * existing compare-and-swap invalidation stays a single call.
217 + *
218 + * @param bool $refresh Rebuild after this request has changed the option.
219 + * @param int $mls_id Connection to read; 0 = the current connection.
220 + * @return array Normalized configuration containing current metadata fields only.
320 221 */
321 -function mlsimport_ajax_save_bulk_admin() {
322 - if (!isset($_POST['security']) || !wp_verify_nonce($_POST['security'], 'mlsimport_field_selector_nonce')) {
323 - wp_send_json_error('Invalid security token');
324 - }
222 +function mlsimport_active_field_configuration( bool $refresh = false, int $mls_id = 0 ): array {
223 + static $configurations = array();
325 224
326 - // Capability check — the settings page is gated by 'administrator'; a nonce
327 - // is not authorization.
328 - if ( ! current_user_can( 'administrator' ) ) {
329 - wp_send_json_error( 'Insufficient permissions' );
330 - }
225 + // One cache slot per resolved connection; 0 resolves to the current one so
226 + // legacy callers and task-scoped callers share a slot when they coincide.
227 + $slot = $mls_id > 0 ? $mls_id : mlsimport_current_mls_id();
331 228
332 - if (!isset($_POST['fields']) || !is_array($_POST['fields'])) {
333 - wp_send_json_error('No field data provided');
334 - }
229 + if ( $refresh ) {
230 + $configurations = array();
231 + }
232 + if ( isset( $configurations[ $slot ] ) ) {
233 + return $configurations[ $slot ];
234 + }
335 235
336 - $options = get_option('mlsimport_admin_fields_select', array());
337 - if (!isset($options['mls-fields-admin']) || !is_array($options['mls-fields-admin'])) {
338 - $options['mls-fields-admin'] = array();
339 - }
236 + $configurations[ $slot ] = mlsimport_field_configuration( $mls_id )->read_active(
237 + mlsimport_field_configuration_metadata( $mls_id ),
238 + mlsimport_hardocde_theme_schema(),
239 + mlsimport_field_configuration_taxonomies()
240 + );
340 241
341 - foreach ($_POST['fields'] as $key => $value) {
342 - $options['mls-fields-admin'][sanitize_text_field($key)] = intval($value);
343 - }
344 -
345 - $result = update_option('mlsimport_admin_fields_select', $options);
346 - if ($result) {
347 - global $mlsimport;
348 - if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'enviroment_custom_fields' ) ) {
349 - $mlsimport->admin->env_data->enviroment_custom_fields( $mlsimport->get_plugin_name() );
350 - }
351 - wp_send_json_success('Bulk admin selections saved');
352 - } else {
353 - wp_send_json_error('Failed to save selections');
354 - }
242 + return $configurations[ $slot ];
355 243 }
356 244
357 -
358 -
359 -
360 -
361 -
362 -
363 -
364 -
365 -
366 -
367 -
368 -
369 -
370 -
371 -
372 -
373 -
374 -
375 -
376 -
377 -
378 -
379 245 /**
380 - * AJAX handler to check if initial save is needed
246 + * Persist metadata initialization/reconciliation once on the server.
247 + *
248 + * @param array $metadata Newly gathered MLS metadata.
249 + * @param array $theme_schema Active theme defaults.
250 + * @param int $mls_id Connection to reconcile; 0 = current connection.
251 + * @return array Field Configuration Result.
381 252 */
382 -function mlsimport_ajax_check_initial_save_needed() {
383 - // Check nonce
384 - if ( ! isset( $_POST['security'] ) || ! wp_verify_nonce( $_POST['security'], 'mlsimport_field_selector_nonce' ) ) {
385 - wp_send_json_error( 'Invalid security token' );
386 - }
387 -
388 - // Capability check — this returns the full options blob; restrict it to
389 - // administrators (the capability gating the settings page).
390 - if ( ! current_user_can( 'administrator' ) ) {
391 - wp_send_json_error( 'Insufficient permissions' );
392 - }
393 -
394 - // Check if option exists and has content
395 - $options = get_option( 'mlsimport_admin_fields_select' );
396 - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
397 -
398 - // Decode MLS data
399 - $mls_data = json_decode( $mlsimport_mls_metadata_mls_data, true );
400 -
401 - // Check if we need an initial save
402 - $initial_save_needed = false;
403 -
404 - // If MLS data exists but option is empty/incomplete
405 - // had !empty( $mls_data ) &&
406 - if ( (
407 - empty( $options ) ||
408 - empty( $options['mls-fields'] )
409 - ) ) {
410 - $initial_save_needed = true;
411 - }
412 -
413 - wp_send_json_success( array(
414 - 'initialSaveNeeded' => $initial_save_needed,
415 -
416 - 'options'=>$options,
417 - 'fieldsInOption' => is_array( $options ) && isset( $options['mls-fields'] ) ? count( $options['mls-fields'] ) : 0,
418 - 'fieldsInMlsData' => is_array( $mls_data ) ? count( $mls_data ) : 0
419 - ) );
253 +function mlsimport_reconcile_field_configuration( array $metadata, array $theme_schema, int $mls_id = 0 ): array {
254 + return mlsimport_field_configuration( $mls_id )->reconcile( $metadata, $theme_schema, mlsimport_field_configuration_taxonomies() );
420 255 }
421 256
422 257 /**
423 - * AJAX handler to save an individual field option.
258 + * Import an exported configuration through the same schema and storage owner.
424 259 *
425 - * NOTE: this function's name is garbled ('ssswsd2option') and it is never
426 - * registered with add_action(), so it is unreachable dead code — an apparent
427 - * earlier copy of mlsimport_ajax_save_field_option() below. Unlike that live
428 - * handler it verifies the nonce but performs no capability check.
260 + * The target connection's own metadata blob drives normalization; fields the
261 + * blob does not know become dormant until that MLS's metadata is gathered.
262 + *
263 + * @param array $incoming Exported legacy-compatible option array.
264 + * @param int $mls_id Connection to import into; 0 = current connection.
265 + * @return array Field Configuration Result.
429 266 */
430 -function mlsimport_ajax_save_field_ssswsd2option() {
431 - // Check nonce
432 - if ( ! isset( $_POST['security'] ) || ! wp_verify_nonce( $_POST['security'], 'mlsimport_field_selector_nonce' ) ) {
433 - wp_send_json_error( 'Invalid security token' );
434 - }
435 -
436 - // Check if field key and option type were provided
437 - if ( ! isset( $_POST['field_key'] ) || ! isset( $_POST['option_type'] ) ) {
438 - wp_send_json_error( 'Missing field key or option type' );
439 - }
440 -
441 - // Sanitize inputs
442 - $field_key = sanitize_text_field( $_POST['field_key'] );
443 - $option_type = sanitize_text_field( $_POST['option_type'] );
444 - $value = isset( $_POST['value'] ) ? $_POST['value'] : '';
445 -
446 - // Validate option type
447 - $valid_option_types = array( 'import', 'admin', 'label', 'postmeta', 'taxonomy' );
448 -
449 - if ( ! in_array( $option_type, $valid_option_types ) ) {
450 - wp_send_json_error( 'Invalid option type' );
451 - }
452 -
453 - // Get current options
454 - $options = get_option( 'mlsimport_admin_fields_select', array() );
455 - // Keep a copy of the original options to detect changes
456 - $original_options = $options;
457 -
458 - // Map option type to options array key
459 - $option_map = array(
460 - 'import' => 'mls-fields',
461 - 'admin' => 'mls-fields-admin',
462 - 'label' => 'mls-fields-label',
463 - 'postmeta' => 'mls-fields-map-postmeta',
464 - 'taxonomy' => 'mls-fields-map-taxonomy'
465 - );
466 -
467 - $option_key = $option_map[ $option_type ];
468 -
469 - // Initialize array if it doesn't exist
470 - if ( ! isset( $options[ $option_key ] ) ) {
471 - $options[ $option_key ] = array();
472 - }
473 -
474 - // Sanitize and update value based on option type
475 - switch ( $option_type ) {
476 - case 'import':
477 - case 'admin':
478 - $options[ $option_key ][ $field_key ] = intval( $value );
479 - break;
480 -
481 - case 'label':
482 - case 'postmeta':
483 - case 'taxonomy':
484 - $options[ $option_key ][ $field_key ] = sanitize_text_field( $value );
485 - break;
486 - }
487 -
488 - // If nothing changed, treat as success without calling update_option
489 - if ( $options === $original_options ) {
490 - wp_send_json_success( array(
491 - 'message' => 'Field option saved successfully',
492 - 'fieldKey' => $field_key,
493 - 'optionType' => $option_type,
494 - 'value' => $value,
495 - ) );
496 - }
497 -
498 - // Save updated options
499 - $result = update_option( 'mlsimport_admin_fields_select', $options );
500 -
501 - if ( false !== $result ) {
502 - wp_send_json_success( array(
503 - 'message' => 'Field option saved successfully',
504 - 'fieldKey' => $field_key,
505 - 'optionType' => $option_type,
506 - 'value' => $value,
507 - ) );
508 - } else {
509 - wp_send_json_error( 'Failed to save field option' );
510 - }
267 +function mlsimport_import_field_configuration( array $incoming, int $mls_id = 0 ): array {
268 + return mlsimport_field_configuration( $mls_id )->import_configuration(
269 + $incoming,
270 + mlsimport_field_configuration_metadata( $mls_id ),
271 + mlsimport_hardocde_theme_schema(),
272 + mlsimport_field_configuration_taxonomies()
273 + );
511 274 }
512 -function mlsimport_ajax_save_field_option() {
513 - // Check nonce
514 - if ( ! isset( $_POST['security'] ) || ! wp_verify_nonce( $_POST['security'], 'mlsimport_field_selector_nonce' ) ) {
515 - //error_log( '[mlsimport] Invalid security token' );
516 - wp_send_json_error( 'Invalid security token' );
517 - }
518 275
519 - // Capability check — the settings page is gated by 'administrator'; a nonce
520 - // is not authorization.
521 - if ( ! current_user_can( 'administrator' ) ) {
522 - wp_send_json_error( 'Insufficient permissions' );
523 - }
524 -
525 - // Check if field key and option type were provided
526 - if ( ! isset( $_POST['field_key'] ) || ! isset( $_POST['option_type'] ) ) {
527 - //error_log( '[mlsimport] Missing field key or option type' );
528 - wp_send_json_error( 'Missing field key or option type' );
529 - }
530 -
531 - // Sanitize inputs
532 - $field_key = sanitize_text_field( $_POST['field_key'] );
533 - $option_type = sanitize_text_field( $_POST['option_type'] );
534 - $value = isset( $_POST['value'] ) ? $_POST['value'] : '';
535 -
536 - // Error log for debugging
537 - //error_log( '[mlsimport] Field Key: ' . $field_key );
538 - //error_log( '[mlsimport] Option Type: ' . $option_type );
539 - //error_log( '[mlsimport] Raw Value: ' . print_r( $value, true ) );
540 -
541 - // Validate option type
542 - $valid_option_types = array( 'import', 'admin', 'label', 'postmeta', 'taxonomy' );
543 - if ( ! in_array( $option_type, $valid_option_types ) ) {
544 - //error_log( '[mlsimport] Invalid option type: ' . $option_type );
545 - wp_send_json_error( 'Invalid option type' );
546 - }
547 -
548 - // Get current options
549 - $options = get_option( 'mlsimport_admin_fields_select', array() );
550 - $original_options = $options;
551 -
552 - // Map option type to options array key
553 - $option_map = array(
554 - 'import' => 'mls-fields',
555 - 'admin' => 'mls-fields-admin',
556 - 'label' => 'mls-fields-label',
557 - 'postmeta' => 'mls-fields-map-postmeta',
558 - 'taxonomy' => 'mls-fields-map-taxonomy'
559 - );
560 -
561 - $option_key = $option_map[ $option_type ];
562 -
563 - if ( ! isset( $options[ $option_key ] ) ) {
564 - $options[ $option_key ] = array();
565 - //error_log( '[mlsimport] Initialized option key: ' . $option_key );
566 - }
567 -
568 - // Sanitize and update value
569 - switch ( $option_type ) {
570 - case 'import':
571 - case 'admin':
572 - $options[ $option_key ][ $field_key ] = intval( $value );
573 - break;
574 -
575 - case 'label':
576 - case 'postmeta':
577 - case 'taxonomy':
578 - $options[ $option_key ][ $field_key ] = sanitize_text_field( $value );
579 - break;
580 - }
581 -
582 - // //error_log( '[mlsimport] Updated Options: ' . print_r( $options, true ) );
583 -
584 - // No changes
585 - if ( $options === $original_options ) {
586 - //error_log( '[mlsimport] No changes detected for option update.' );
587 - wp_send_json_success( array(
588 - 'message' => 'Field option saved successfully',
589 - 'fieldKey' => $field_key,
590 - 'optionType' => $option_type,
591 - 'value' => $value,
592 - ) );
593 - }
594 - //error_log('saving mls-fields '.count($options['mls-fields']));
595 - //error_log('saving mls-fields-admin '.count($options['mls-fields-admin']));
596 - //error_log('saving mls-fields-label '.count($options['mls-fields-label']));
597 - //error_log('saving mls-fields-map-postmeta '.count($options['mls-fields-map-postmeta']));
598 - //error_log('saving mls-fields-map-taxonomy '.count($options['mls-fields-map-taxonomy']));
599 - //error_log('saving field_order '.count($options['field_order']));
600 -
601 - // Save updated options
602 - $result = update_option( 'mlsimport_admin_fields_select', $options );
603 -
604 - if ( false !== $result ) {
605 - //error_log( '[mlsimport] Options updated successfully.' );
606 - wp_send_json_success( array(
607 - 'message' => 'Field option saved successfully',
608 - 'fieldKey' => $field_key,
609 - 'optionType' => $option_type,
610 - 'value' => $value,
611 - ) );
612 - } else {
613 - //error_log( '[mlsimport] Failed to update options.' );
614 - wp_send_json_error( 'Failed to save field option' );
615 - }
616 -}
617 -
618 -
619 -
620 276 /**
621 - * AJAX handler to move a field to a new position in the order.
277 + * Handle the sole browser Field Configuration mutation endpoint.
622 278 *
623 - * Verifies the 'mlsimport_field_selector_nonce' nonce AND requires the
624 - * 'administrator' capability. Takes a moving index, a target index and a
625 - * before/after position, recomputes field_order via
626 - * mlsimport_compute_field_order_after_move(), reorders every parallel field
627 - * array through mlsimport_sort_all_fields_by_order(), and persists the result.
279 + * Security and request-shape validation happen before decoding the compact
280 + * command. The module then validates domain rules and either returns an
281 + * authoritative saved result or a stable error code used by the queue UI.
282 + *
283 + * The handler terminates through WordPress JSON helpers. It intentionally has
284 + * no return type because those helpers stop execution after sending a response.
628 285 */
629 -function mlsimport_ajax_save_field_position() {
630 - // Verify nonce
631 - if (!isset($_POST['security']) || !wp_verify_nonce($_POST['security'], 'mlsimport_field_selector_nonce')) {
632 - wp_send_json_error('Invalid security token');
633 - }
286 +function mlsimport_ajax_change_field_configuration() {
287 + check_ajax_referer( 'mlsimport_field_selector_nonce', 'security' );
634 288
635 - // Capability check — the settings page is gated by 'administrator'; a nonce
636 - // is not authorization.
637 - if ( ! current_user_can( 'administrator' ) ) {
638 - wp_send_json_error( 'Insufficient permissions' );
639 - }
289 + if ( ! current_user_can( 'manage_options' ) ) {
290 + wp_send_json_error( array( 'error' => array( 'code' => 'forbidden', 'message' => 'You are not allowed to change Field Configuration.' ) ), 403 );
291 + }
640 292
641 - // Validate required fields
642 - if (!isset($_POST['moving_index'], $_POST['target_index'], $_POST['position'])) {
643 - wp_send_json_error('Missing parameters');
644 - }
293 + if ( ! isset( $_POST['revision'], $_POST['command'] ) || ! is_scalar( $_POST['revision'] ) || ! is_scalar( $_POST['command'] ) ) {
294 + wp_send_json_error( array( 'error' => array( 'code' => 'invalid_request', 'message' => 'Revision and command are required.' ) ), 400 );
295 + }
645 296
646 - $moving_index = intval($_POST['moving_index']);
647 - $target_index = intval($_POST['target_index']);
648 - $position = sanitize_text_field($_POST['position']); // 'before' or 'after'
297 + $revision = max( 0, (int) wp_unslash( $_POST['revision'] ) );
298 + $command = json_decode( wp_unslash( (string) $_POST['command'] ), true );
299 + if ( ! is_array( $command ) ) {
300 + wp_send_json_error( array( 'error' => array( 'code' => 'invalid_json', 'message' => 'The Field Configuration command is not valid JSON.' ) ), 400 );
301 + }
649 302
650 - // Load current options
651 - $options = get_option('mlsimport_admin_fields_select', []);
652 -
653 - if (empty($options['field_order']) || !is_array($options['field_order'])) {
654 - wp_send_json_error('Field order not found');
655 - }
303 + // Per-connection scope: the tab posts the mls_id it was rendered for; a
304 + // request without one (legacy) resolves to the current connection.
305 + $mls_id = mlsimport_field_mapping_request_scope( isset( $_POST['mls_id'] ) && is_scalar( $_POST['mls_id'] ) ? wp_unslash( $_POST['mls_id'] ) : null );
656 306
307 + $result = mlsimport_field_configuration( $mls_id )->change(
308 + $revision,
309 + $command,
310 + mlsimport_field_configuration_metadata( $mls_id ),
311 + mlsimport_field_configuration_taxonomies(),
312 + mlsimport_hardocde_theme_schema()
313 + );
314 + if ( ! $result['success'] ) {
315 + $status = 'stale_revision' === $result['error']['code'] ? 409 : ( 'persistence_failed' === $result['error']['code'] ? 500 : 422 );
316 + wp_send_json_error( $result, $status );
317 + }
657 318
658 -
659 -
660 - // Validate the requested indexes against the current order.
661 - // asort keeps key=>index pairs but reorders them by index value; the
662 - // resulting key list is the field sequence the two indexes point into.
663 - $order_map = $options['field_order'];
664 - asort($order_map); // Sort by index.
665 - $ordered_keys = array_keys($order_map);
666 - // Both the moving and target positions must exist in the current sequence.
667 - if (!isset($ordered_keys[$moving_index]) || !isset($ordered_keys[$target_index])) {
668 - wp_send_json_error('Invalid indexes');
669 - }
670 -
671 - // Compute the new field order, then reorder EVERY parallel field array via
672 - // the shared helper. The previous inline loop named non-existent target
673 - // keys ('mls-fields-map-admin' / 'mls-fields-map-label'), so the admin and
674 - // label arrays were left in stale order. mlsimport_sort_all_fields_by_order()
675 - // uses the correct key list.
676 - $options['field_order'] = mlsimport_compute_field_order_after_move(
677 - $options['field_order'],
678 - $moving_index,
679 - $target_index,
680 - $position
681 - );
682 - $options = mlsimport_sort_all_fields_by_order($options);
683 -
684 - // Save the updated options
685 - $saved = update_option('mlsimport_admin_fields_select', $options);
686 - if ( $saved ) {
687 - global $mlsimport;
688 - if ( isset( $mlsimport->admin->env_data ) && method_exists( $mlsimport->admin->env_data, 'enviroment_custom_fields' ) ) {
689 - $mlsimport->admin->env_data->enviroment_custom_fields( $mlsimport->get_plugin_name() );
690 - }
691 - }
692 -
693 - wp_send_json_success('Field order updated');
319 + wp_send_json_success( $result );
694 320 }
695 -
696 -// Register the AJAX handler
697 -add_action('wp_ajax_mlsimport_save_field_position', 'mlsimport_ajax_save_field_position');
321 +add_action( 'wp_ajax_mlsimport_change_field_configuration', 'mlsimport_ajax_change_field_configuration' );