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.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 / mlsimport-progressive-save.php

mlsimport-progressive-save.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 7.2.1, at includes/mlsimport-progressive-save.php

322 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress persistence adapter for the Field Configuration module.
4 *
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.
11 *
12 * @package MLSImport
13 */
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
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.
30 */
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;
34
35 return is_array( $metadata ) ? $metadata : array();
36 }
37
38 /**
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.
45 */
46 function mlsimport_field_configuration_taxonomies(): array {
47 global $mlsimport;
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 }
53
54 $taxonomies = mlsimport_get_custom_post_type_taxonomies( $post_type );
55
56 return is_array( $taxonomies ) ? $taxonomies : array();
57 }
58
59 /**
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.
74 */
75 function mlsimport_field_configuration_compare_and_swap( array $expected, array $replacement, string $option_name = '' ): bool {
76 global $wpdb;
77
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 );
88
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 }
94
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 }
113
114 return $added;
115 }
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 }
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 }
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 );
152
153 return true;
154 }
155
156 /**
157 * Construct the authoritative module around one connection's option store.
158 *
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.
169 */
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 );
173
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 );
184 }
185
186 /**
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.
195 */
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 );
202 }
203
204 /**
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.
221 */
222 function mlsimport_active_field_configuration( bool $refresh = false, int $mls_id = 0 ): array {
223 static $configurations = array();
224
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();
228
229 if ( $refresh ) {
230 $configurations = array();
231 }
232 if ( isset( $configurations[ $slot ] ) ) {
233 return $configurations[ $slot ];
234 }
235
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 );
241
242 return $configurations[ $slot ];
243 }
244
245 /**
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.
252 */
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() );
255 }
256
257 /**
258 * Import an exported configuration through the same schema and storage owner.
259 *
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.
266 */
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 );
274 }
275
276 /**
277 * Handle the sole browser Field Configuration mutation endpoint.
278 *
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.
285 */
286 function mlsimport_ajax_change_field_configuration() {
287 check_ajax_referer( 'mlsimport_field_selector_nonce', 'security' );
288
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 }
292
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 }
296
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 }
302
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 );
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 }
318
319 wp_send_json_success( $result );
320 }
321 add_action( 'wp_ajax_mlsimport_change_field_configuration', 'mlsimport_ajax_change_field_configuration' );
322