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
← All changes | admin/class-mlsimport-admin.php +860 -1191 7.0.47.2.1 View file →
@@ -18,8 +18,9 @@
18 18 * several mlsimport_admin_* option groups.
19 19 * - The mlsimport_item (Import Task) metaboxes: rendering the import-parameter
20 20 * form and saving its post meta.
21 21 * - The MLS connection test and SaaS token/metadata retrieval.
22 + * SaaS account identifiers accept username or email in the existing field.
22 23 * - Building the RESO listing-request arguments from an Import Task's meta.
23 24 * - The import engine: manual (AJAX), hourly cron per item, and the
24 25 * background/Action Scheduler batch processors.
25 26 * - The daily reconciliation sweep (delete/keep local listings vs. the MLS
@@ -78,10 +79,14 @@
78 79 // Active theme adapter object (e.g. ResidenceClass); stdClass when no theme.
79 80 public $env_data;
80 81 // Active MLS provider adapter object; stdClass when none configured.
81 82 public $mls_env_data;
83 + /** @var string Clear adapter error checked before the first listings request. */
84 + private $stored_listing_configuration_error = '';
82 85 // Reserved handle for a batch/queue processor (declared, assigned elsewhere).
83 86 protected $process_all;
87 + // One shared Import Task runner, created only when a caller needs it.
88 + private $import_task_execution;
84 89 // Field-import definition array (populated per request where used).
85 90 public $field_import;
86 91 // Map of supported theme_id => human name (990 standalone, 991-994 themes).
87 92 public $themes;
@@ -109,10 +114,10 @@
109 114 'InternetEntireListingDisplayYN',
110 115 'InternetAddressDisplayYN',
111 116 );
112 117
113 - // theme_id => adapter name. The class is derived by stripping "Wp" and
114 - // appending "Class" (e.g. WpResidence -> ResidenceClass).
118 + // theme_id => administrator-facing name. Adapter classes are selected by
119 + // Mlsimport_Stored_Listing_Adapter_Factory, never derived from these labels.
115 120 $this->themes = array(
116 121 990 => 'Standalone',
117 122 991 => 'WpResidence',
118 123 992 => 'Houzez',
@@ -119,57 +124,79 @@
119 124 993 => 'RealHomes',
120 125 994 => 'Wpestate',
121 126 );
122 127 }
128 +
123 129 /**
130 + * Return the one shared Import Task execution module for this request.
131 + *
132 + * Manual actions, setup, and hourly cron use this method instead of creating
133 + * separate runners. Lazy creation also keeps ordinary admin page requests
134 + * from allocating import objects when no import work is requested.
135 + *
136 + * @return Mlsimport_Import_Task_Execution Shared execution module.
137 + */
138 + public function mlsimport_import_task_execution(): Mlsimport_Import_Task_Execution {
139 + if ( ! $this->import_task_execution instanceof Mlsimport_Import_Task_Execution ) {
140 + $environment = new Mlsimport_Import_Task_Execution_WordPress_Environment( $this );
141 + $this->import_task_execution = new Mlsimport_Import_Task_Execution( $environment );
142 + }
143 +
144 + return $this->import_task_execution;
145 + }
146 + /**
124 147 * Wire up the theme and MLS provider adapter objects for this request.
125 148 *
126 - * Reads the configured theme_id, resolves the theme adapter class name, and
127 - * instantiates both the theme adapter (env_data) and the MLS provider adapter
128 - * (mls_env_data); missing config falls back to an empty stdClass.
149 + * Resolves the theme_id, asks the explicit factory for its adapter,
150 + * injects one Stored Listing Write into ThemeImport, and instantiates the Provider Family
151 + * adapter (mls_env_data). Provider selection comes from the saved type with
152 + * the numeric MLS ID used only for older configurations.
129 153 *
154 + * The theme id comes from mlsimport_resolve_theme_id(), the same resolver the
155 + * wizard and the Tools tab use to preselect the theme dropdown: a saved
156 + * choice wins, otherwise the active parent theme is detected, otherwise the
157 + * site is standalone (990). Reading the raw option here instead defaulted
158 + * to 0 on every fresh install, the factory threw, env_data became an empty
159 + * stdClass, and the Tools tab plus two onboarding steps fataled on
160 + * get_property_post_type() before the user had picked anything (#324).
161 + *
130 162 * @param string $plugin_name Plugin slug passed to ThemeImport.
131 - * @param string $mls_enviroment MLS provider adapter base name (e.g. BridgeReso).
132 - * @param string $theme_enviroment Ignored on input; recomputed from the saved theme_id.
163 + * @param string $mls_enviroment Legacy argument retained for call compatibility.
164 + * @param string $theme_enviroment Legacy ignored theme-environment name.
133 165 * @since 1.0.0
134 166 */
135 167 public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) {
136 168
137 - // Load saved options and resolve the configured theme id (0 when unset).
169 + // Load saved options (MLS id below) and resolve the theme id the site
170 + // already reports: saved choice, else detected theme, else standalone.
138 171 $options = get_option( $this->plugin_name . '_admin_options' );
139 - $theme_id = 0;
140 - if ( isset( $options['mlsimport_theme_used'] ) ) {
141 - $theme_id = intval( $options['mlsimport_theme_used'] );
172 + $theme_id = mlsimport_resolve_theme_id();
173 + unset( $theme_enviroment );
174 + $this->stored_listing_configuration_error = '';
175 + try {
176 + $factory = new Mlsimport_Stored_Listing_Adapter_Factory();
177 + $this->env_data = $factory->create( $theme_id );
178 + $environment = new Mlsimport_Stored_Listing_WordPress_Environment();
179 + $writer = new Mlsimport_Stored_Listing_Write( $environment, $this->env_data );
180 + $this->theme_importer = new ThemeImport( $plugin_name, $writer );
181 + } catch ( UnexpectedValueException $exception ) {
182 + // Keep ordinary settings screens usable, but expose the exact error to
183 + // Import Run execution before it requests or mutates a listing.
184 + $this->env_data = new stdClass();
185 + $this->theme_importer = new ThemeImport( $plugin_name );
186 + $this->stored_listing_configuration_error = $exception->getMessage();
142 187 }
143 - $themes = $this->themes;
144 188
145 - // Map the theme id to its adapter name; blank when the id is unknown.
146 - $theme_enviroment = '';
147 - if ( isset( $themes[ $theme_id ] ) ) {
148 - $theme_enviroment = $themes[ $theme_id ];
149 - }
150 -
151 - // Always create the API client.
152 - $this->theme_importer = new ThemeImport( $plugin_name );
153 -
154 - $options_api = get_option( $this->plugin_name . '_admin_options' );
155 -
156 - // Instantiate the theme adapter (WpResidence -> ResidenceClass), else stub.
157 - if ( '' !== $theme_enviroment ) {
158 - $classname = str_replace('Wp','',$theme_enviroment ). 'Class';
159 - $this->env_data = new $classname();
160 - } else {
161 - $this->env_data = new stdClass();
162 - }
163 -
164 - // Instantiate the MLS provider adapter (name + "Class"), else stub.
165 - if ( '' !== $mls_enviroment ) {
166 - $mls_classname = $mls_enviroment . 'Class';
167 -
168 - $this->mls_env_data = new $mls_classname( $this->theme_importer );
169 - } else {
170 - $this->mls_env_data = new stdClass();
171 - }
189 + // Resolve the same Provider Family adapter used by connection, Stored, and
190 + // Direct MLS callers. The legacy environment-name parameter is ignored.
191 + $mls_id = isset( $options['mlsimport_mls_name'] )
192 + ? sanitize_text_field( trim( (string) $options['mlsimport_mls_name'] ) )
193 + : '';
194 + $this->mls_env_data = Mlsimport_Provider_Family::adapter(
195 + Mlsimport_Provider_Family::saved_type( $mls_id ),
196 + $mls_id,
197 + $this->theme_importer
198 + );
172 199 }
173 200
174 201 /**
175 202 * Register the stylesheets for the admin area.
@@ -184,18 +211,40 @@
184 211 // Onboarding wizard styles.
185 212 wp_enqueue_style( 'mlsimport-onboarding', plugin_dir_url( __FILE__ ) . 'css/mlsimport-onboarding.css', array(), MLSIMPORT_VERSION, 'all' );
186 213 // Drag-and-drop field selector styles.
187 214 wp_enqueue_style( 'mlsimport-field-selector', plugin_dir_url( __FILE__ ) . 'css/mlsimport-field-selector.css', array(), MLSIMPORT_VERSION, 'all' );
215 + // Connections tab styles (#280) + drawer styles (#281) — on its
216 + // settings-page tab (shared gate with the scripts enqueue below).
217 + if ( $this->mlsimport_is_connections_tab_screen() ) {
218 + wp_enqueue_style( 'mlsimport-connections', plugin_dir_url( __FILE__ ) . 'css/mlsimport-connections.css', array( $this->plugin_name ), MLSIMPORT_VERSION, 'all' );
219 + wp_enqueue_style( 'mlsimport-connections-drawer', plugin_dir_url( __FILE__ ) . 'css/mlsimport-connections-drawer.css', array( 'mlsimport-connections' ), MLSIMPORT_VERSION, 'all' );
220 + }
188 221 }
189 222
223 + /**
224 + * Whether the current request renders the settings page's Connections tab.
225 + *
226 + * The single gate shared by the Connections styles and scripts enqueues.
227 + * Since the tab consolidation the Connections tab is also the page
228 + * DEFAULT (no ?tab=) and the retired display_options alias, so the check
229 + * must go through the tab resolver — a raw $_GET['tab'] comparison would
230 + * miss both of those URL forms.
231 + *
232 + * @return bool True when the Connections tab is being rendered.
233 + */
234 + private function mlsimport_is_connections_tab_screen(): bool {
235 + return isset( $_GET['page'] ) && 'mlsimport_plugin_options' === $_GET['page']
236 + && 'connections' === mlsimport_settings_active_tab( isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : '' );
237 + }
190 238
191 239
192 240
241 +
193 242 /**
194 243 * Register the JavaScript for the admin area.
195 244 *
196 - * Enqueues the core admin script, the field-selector + progressive-save
197 - * scripts, and conditionally (by page/hook) injects inline bootstraps for
245 + * Enqueues the core admin script, the single Field Configuration controller,
246 + * and conditionally (by page/hook) injects inline bootstraps for
198 247 * metadata fetch and MLS autocomplete, plus the searchable-select and
199 248 * deactivation-survey scripts on their respective screens.
200 249 *
201 250 * @param string $hook_suffix Current admin page hook suffix.
@@ -205,8 +254,38 @@
205 254 // jQuery UI autocomplete backs the MLS-name search box.
206 255 wp_enqueue_script( 'jquery-ui-autocomplete' );
207 256 // Pull the cached MLS list (used later for the autocomplete bootstrap).
208 257 $mls_import_list = mlsimport_saas_request_list();
258 + // Resolve every autocomplete MLS ID through PHP's Provider Family module.
259 + // The browser receives final credential field names and contains no ranges.
260 + $provider_mls_ids = array();
261 + $decoded_mls_list = is_string( $mls_import_list ) ? json_decode( $mls_import_list, true ) : array();
262 + if ( is_array( $decoded_mls_list ) ) {
263 + foreach ( $decoded_mls_list as $mls_row ) {
264 + if ( is_array( $mls_row ) && isset( $mls_row['value'] ) ) {
265 + $provider_mls_ids[] = (string) $mls_row['value'];
266 + }
267 + }
268 + }
269 + // Keep the currently saved MLS usable even when an older cached list no
270 + // longer contains it or the list request temporarily failed.
271 + $current_options = get_option( $this->plugin_name . '_admin_options', array() );
272 + if ( is_array( $current_options ) && ! empty( $current_options['mlsimport_mls_name'] ) ) {
273 + $provider_mls_ids[] = (string) $current_options['mlsimport_mls_name'];
274 + }
275 + // Every REGISTERED connection too: the drawer's edit mode injects that
276 + // connection's credential fields even when the cached list lacks it.
277 + foreach ( array_keys( Mlsimport_Connections::all() ) as $registered_mls_id ) {
278 + $provider_mls_ids[] = (string) $registered_mls_id;
279 + }
280 + $provider_mls_ids = array_values( array_unique( $provider_mls_ids ) );
281 + $saved_provider_type = (string) get_option( 'mlsimport_provider_type', '' );
282 + $saved_provider_mls_id = (string) get_option( 'mlsimport_provider_type_mls_id', '' );
283 + $provider_browser_config = Mlsimport_Provider_Family::browser_config_for_ids(
284 + $provider_mls_ids,
285 + $saved_provider_type,
286 + $saved_provider_mls_id
287 + );
209 288 // Core admin script + AJAX endpoint.
210 289 wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true );
211 290 wp_localize_script(
212 291 'mlsimport-admin',
@@ -211,33 +290,42 @@
211 290 wp_localize_script(
212 291 'mlsimport-admin',
213 292 'mlsimport_vars',
214 293 array(
215 - 'ajax_url' => admin_url( 'admin-ajax.php' )
294 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
295 + 'provider_families' => $provider_browser_config,
216 296 )
217 297 );
218 298
219 - // Field-selector UI depends on jQuery UI sortable + tooltip.
220 - wp_enqueue_script( 'mlsimport-field-selector', plugin_dir_url( ( __FILE__ ) ) . 'js/mlsimport-field-selector.js', array( 'jquery', 'jquery-ui-sortable', 'jquery-ui-tooltip' ), '1.0.0', true );
221 -
222 - // Pass AJAX parameters to script
223 - wp_localize_script( 'mlsimport-field-selector', 'mlsimport_params', array(
224 - 'ajax_url' => admin_url( 'admin-ajax.php' ),
225 - 'nonce' => wp_create_nonce( 'mlsimport_field_selector_nonce' )
226 - ));
299 + // One controller owns filtering, ordering, every mutation, and the ordered
300 + // save queue. No second script or cross-script window state is required.
301 + wp_enqueue_script( 'mlsimport-field-selector', plugin_dir_url( ( __FILE__ ) ) . 'js/mlsimport-field-selector.js', array( 'jquery', 'jquery-ui-sortable', 'jquery-ui-tooltip' ), MLSIMPORT_VERSION, true );
302 + wp_localize_script(
303 + 'mlsimport-field-selector',
304 + 'mlsimport_params',
305 + array(
306 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
307 + 'nonce' => wp_create_nonce( 'mlsimport_field_selector_nonce' ),
308 + 'action' => 'mlsimport_change_field_configuration',
309 + 'messages' => array(
310 + 'retry' => esc_html__( 'Retry save', 'mlsimport' ),
311 + 'reload' => esc_html__( 'Reload configuration', 'mlsimport' ),
312 + ),
313 + )
314 + );
227 315
228 316
229 - // Progressive save batches field-selector changes; depends on it.
230 - wp_enqueue_script( 'mlsimport-progressive-save', plugin_dir_url( ( __FILE__ ) ) . 'js/progressive-save.js', array('mlsimport-field-selector' ), '1.0.0', true );
231 317
232 -
233 -
234 318 // On the settings page Field Options tab: if metadata was never fetched,
235 - // auto-trigger the metadata pull on DOM ready.
319 + // auto-trigger the metadata pull on DOM ready. The check is scoped the
320 + // same way the tab itself is (?mls=<registered id> or the current
321 + // connection) — the JS posts the rendered scope back, so a scoped tab
322 + // whose seed gather failed retries for ITS connection.
236 323 if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
237 324 isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' &&
238 325 isset($_GET['tab']) && $_GET['tab'] === 'field_options') {
239 - $mlsimport_mls_metadata_populated = get_option( 'mlsimport_mls_metadata_populated', '' );
326 + $mlsimport_field_scope = mlsimport_field_mapping_request_scope( isset( $_GET['mls'] ) ? wp_unslash( $_GET['mls'] ) : null );
327 + $mlsimport_mls_metadata_populated = mlsimport_get_connection_option( 'mlsimport_mls_metadata_populated', '', $mlsimport_field_scope );
240 328 if ( 'yes' !== $mlsimport_mls_metadata_populated ) {
241 329 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
242 330 wp_add_inline_script('mlsimport-admin', $inline_script);
243 331 }
@@ -242,14 +330,21 @@
242 330 wp_add_inline_script('mlsimport-admin', $inline_script);
243 331 }
244 332 }
245 333
246 - // Same auto-metadata bootstrap, but for the onboarding wizard screen.
247 - if (
248 - 'admin_page_mlsimport-onboarding' === $hook_suffix &&
249 - isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding'
250 - ) {
251 - $mlsimport_mls_metadata_populated = get_option('mlsimport_mls_metadata_populated', '');
334 + /*
335 + * Same auto-metadata bootstrap, but for the onboarding wizard screen.
336 + *
337 + * Gated on the page slug only. The wizard's $hook_suffix is NOT
338 + * 'admin_page_mlsimport-onboarding': it is registered as a submenu of the
339 + * "MLS Import Settings" menu, and WordPress builds a submenu hook from the
340 + * sanitized parent menu TITLE, so the real hook is
341 + * 'mls-import-settings_page_mlsimport-onboarding'. Testing the old string
342 + * meant this block never ran, the metadata pull was never triggered, and the
343 + * wizard's Field Mapping step sat on "Please Stand By!" forever.
344 + */
345 + if ( isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding' ) {
346 + $mlsimport_mls_metadata_populated = mlsimport_get_connection_option( 'mlsimport_mls_metadata_populated', '' );
252 347 if ('yes' !== $mlsimport_mls_metadata_populated) {
253 348 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
254 349 wp_add_inline_script('mlsimport-admin', $inline_script);
255 350 }
@@ -257,21 +352,95 @@
257 352
258 353
259 354
260 355
261 - // On the settings Display Options tab (or the page with no tab), seed the
262 - // MLS-name autocomplete with the fetched list when it is not an array.
263 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
264 - ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') ||
265 - (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) {
266 -
267 - // Re-fetch the MLS list and, when it is a raw string payload,
268 - // hand it to the JS autocomplete initializer.
269 - $mls_import_list = mlsimport_saas_request_list();
270 - if(!is_array($mls_import_list)){
271 - $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });';
272 - wp_add_inline_script('mlsimport-admin', $inline_script);
273 - }
356 + // (The old Display Options tab's inline MLS-autocomplete seeding was
357 + // removed with the tab consolidation: the retired credentials form no
358 + // longer exists, and the drawer receives the MLS list via the
359 + // mlsimportConnections localization below. The onboarding wizard seeds
360 + // its own copy in includes/mlsimport-onboarding.php.)
361 +
362 + // Connections tab behavior (#280): drag-reorder (jQuery UI sortable),
363 + // per-row test, the account connect/disconnect — and the connection
364 + // drawer (#281 add / edit mode, jQuery UI autocomplete). Gated by the
365 + // same resolved-tab predicate as the styles enqueue.
366 + if ( $this->mlsimport_is_connections_tab_screen() ) {
367 + wp_enqueue_script( 'mlsimport-connections', plugin_dir_url( __FILE__ ) . 'js/mlsimport-connections.js', array( 'jquery', 'jquery-ui-sortable' ), MLSIMPORT_VERSION, true );
368 + // The drawer script reads the per-MLS provider credential fields
369 + // from mlsimport_vars.provider_families, localized above onto the
370 + // always-enqueued core admin script.
371 + wp_enqueue_script( 'mlsimport-connections-drawer', plugin_dir_url( __FILE__ ) . 'js/mlsimport-connections-drawer.js', array( 'jquery', 'jquery-ui-autocomplete', 'mlsimport-connections', 'mlsimport-admin' ), MLSIMPORT_VERSION, true );
372 + // Edit-mode prefill: each registered connection's display name and
373 + // stored credentials, keyed by the FLAT field names its provider
374 + // adapter declares (the same names the drawer injects inputs for).
375 + $drawer_connections = array();
376 + foreach ( Mlsimport_Connections::all() as $connection_mls_id => $connection_record ) {
377 + $connection_adapter = Mlsimport_Provider_Family::adapter(
378 + (string) $connection_record['provider_type'],
379 + (string) $connection_mls_id
380 + );
381 + $drawer_connections[ (string) $connection_mls_id ] = array(
382 + 'name' => (string) $connection_record['mls_name'],
383 + 'creds' => mlsimport_connections_credential_options( $connection_record, $connection_adapter->credential_fields() ),
384 + );
385 + }
386 + wp_localize_script(
387 + 'mlsimport-connections',
388 + 'mlsimportConnections',
389 + array(
390 + 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
391 + 'nonce' => wp_create_nonce( 'mlsimport_connections_screen' ),
392 + // The inline connect form posts to the existing
393 + // mlsimport_save_account action, which checks this nonce.
394 + 'accountNonce' => wp_create_nonce( 'mlsimport_onboarding_nonce' ),
395 + // Add-MLS drawer data (#281): the SaaS MLS list for the
396 + // picker (label/value pairs decoded above), the ids that
397 + // are already connections, and the generic credential
398 + // labels the injected inputs use.
399 + 'mlsList' => is_array( $decoded_mls_list ) ? $decoded_mls_list : array(),
400 + 'registered' => array_map( 'strval', array_keys( Mlsimport_Connections::all() ) ),
401 + // Edit-mode prefill map (built above).
402 + 'connections' => $drawer_connections,
403 + 'credLabels' => array(
404 + 'client_id' => esc_html__( 'API Client ID — provided by your MLS', 'mlsimport' ),
405 + 'client_secret' => esc_html__( 'API Client Secret — provided by your MLS', 'mlsimport' ),
406 + 'mls_token' => esc_html__( 'API Server token — provided by your MLS', 'mlsimport' ),
407 + 'username' => esc_html__( 'MLS Username — provided by your MLS', 'mlsimport' ),
408 + 'password' => esc_html__( 'MLS Password — provided by your MLS', 'mlsimport' ),
409 + ),
410 + 'i18n' => array(
411 + 'test' => esc_html__( 'Test', 'mlsimport' ),
412 + 'testing' => esc_html__( 'Testing…', 'mlsimport' ),
413 + 'connectFailed' => esc_html__( 'Could not connect — please check your username or email and password.', 'mlsimport' ),
414 + 'disconnectConfirm' => esc_html__( 'Disconnect this site from your mlsimport.com account? Imports will stop until you reconnect.', 'mlsimport' ),
415 + 'removeConfirm' => esc_html__( 'Remove this MLS connection? Its credentials and field mapping are deleted; already-imported listings and tasks stay.', 'mlsimport' ),
416 + /* translators: 1: step number, 2: step name, 3: slot being filled, 4: plan connection cap. */
417 + 'drawerStepNote' => esc_html__( 'Step %1$s of 3 — %2$s · slot %3$s of %4$s', 'mlsimport' ),
418 + 'drawerStepNames' => array(
419 + esc_html__( 'pick your MLS', 'mlsimport' ),
420 + esc_html__( 'credentials', 'mlsimport' ),
421 + esc_html__( 'connection test', 'mlsimport' ),
422 + ),
423 + /* translators: 1: MLS name. Provider type and id are internal and not shown. */
424 + 'drawerSummary' => esc_html__( '%1$s', 'mlsimport' ),
425 + 'drawerAddTitle' => esc_html__( 'Add MLS', 'mlsimport' ),
426 + 'drawerEditTitle' => esc_html__( 'Edit MLS connection', 'mlsimport' ),
427 + /* translators: 1: step number, 2: step name. */
428 + 'drawerStepNoteEdit' => esc_html__( 'Step %1$s of 3 — %2$s', 'mlsimport' ),
429 + 'drawerEditSaved' => esc_html__( '✓ Connection test passed — credentials updated.', 'mlsimport' ),
430 + 'drawerNextNoteEdit' => esc_html__( 'Next: a passed test saves the new credentials — imports use them right away.', 'mlsimport' ),
431 + // Painted the moment the add request answers (#325); the
432 + // separate seed request then settles it to one of the two below.
433 + 'drawerSeeding' => esc_html__( '✓ Connection test passed — connection saved. Seeding field mapping…', 'mlsimport' ),
434 + 'drawerSaved' => esc_html__( '✓ Connection test passed — connection saved.', 'mlsimport' ),
435 + 'drawerSavedNoSeed' => esc_html__( '✓ Connection saved — field mapping could not be seeded yet; open Field Options to retry.', 'mlsimport' ),
436 + // Server refusals arrive with their own message; this
437 + // string only covers a failed request itself.
438 + 'drawerFailed' => esc_html__( 'The request failed — please try again.', 'mlsimport' ),
439 + 'drawerNoFields' => esc_html__( 'We could not determine this MLS\'s credential fields — please contact us.', 'mlsimport' ),
440 + ),
441 + )
442 + );
274 443 }
275 444
276 445 // Searchable City/County multi-select — only on the Import Task edit screen.
277 446 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
@@ -578,8 +747,15 @@
578 747 * @return array Whitelisted, escaped options.
579 748 * @since 1.0.0
580 749 */
581 750 public function validate_admin_options( $input ) {
751 + // Compare the selected MLS before and after this save. Provider credentials
752 + // remain stored, but state belonging to a different MLS is invalidated.
753 + $previous_options = get_option( $this->plugin_name . '_admin_options', array() );
754 + $previous_options = is_array( $previous_options ) ? $previous_options : array();
755 + $previous_mls_id = isset( $previous_options['mlsimport_mls_name'] )
756 + ? (string) $previous_options['mlsimport_mls_name']
757 + : '';
582 758
583 759 // Whitelist of accepted option keys (value = label/help metadata, unused
584 760 // beyond documentation here); anything not listed is dropped on save.
585 761 $valid = array();
@@ -612,9 +788,9 @@
612 788 'name' => esc_html__( 'title_format', 'mlsimport' ),
613 789 'details' => 'to be added',
614 790 ),
615 791 'mlsimport_username' => array(
616 - 'name' => esc_html__( 'MLSImport.com Username (not your email)', 'mlsimport' ),
792 + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ),
617 793 'details' => 'to be added',
618 794 ),
619 795 'mlsimport_password' => array(
620 796 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
@@ -710,16 +886,35 @@
710 886 'details' => 'to be added',
711 887 ),
712 888 );
713 889
714 - // Copy each whitelisted key, escaping the value; missing/empty => ''.
890 + // Copy each whitelisted key; missing/empty => ''. Credential fields
891 + // (passwords/secrets/tokens) are stored verbatim — esc_attr() would
892 + // entity-encode & < > " ' and corrupt them on every re-save (#204).
893 + // Non-credential fields keep the historical esc_attr() treatment.
715 894 foreach ( $settings_list as $key => $setting ) {
716 - $valid[ $key ] = ( isset( $input[ $key ] ) && ! empty( $input[ $key ] ) ) ? esc_attr( $input[ $key ] ) : '';
895 + if ( ! isset( $input[ $key ] ) || empty( $input[ $key ] ) ) {
896 + $valid[ $key ] = '';
897 + } elseif ( mlsimport_is_credential_key( $key ) ) {
898 + $valid[ $key ] = trim( (string) $input[ $key ] );
899 + } else {
900 + $valid[ $key ] = esc_attr( $input[ $key ] );
901 + }
717 902 }
718 903
719 - // Credentials may have changed: force a fresh connection test + metadata pull.
904 + $new_mls_id = isset( $valid['mlsimport_mls_name'] ) ? (string) $valid['mlsimport_mls_name'] : '';
905 + if ( $previous_mls_id !== $new_mls_id ) {
906 + Mlsimport_Provider_Family::clear_active_state();
907 + } else {
908 + // Same MLS but possibly new credentials: force the next request to log in again.
909 + Mlsimport_Provider_Family::clear_access_tokens();
910 + }
911 +
912 + // Credentials may have changed: force a fresh connection test + metadata
913 + // pull. The populated flag is per-connection (#275) — clear it for the
914 + // MLS being SAVED, leaving other connections' gathered state isolated.
720 915 delete_option( 'mlsimport_connection_test' );
721 - delete_option( 'mlsimport_mls_metadata_populated' );
916 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
722 917
723 918 // Reset cached encoding and drop cached token/schema transients.
724 919 update_option( 'mlsimport_encoding_array', '' );
725 920 delete_transient( 'mlsimport_token_request' );
@@ -734,55 +929,8 @@
734 929
735 930
736 931
737 932 /**
738 - * Sanitize the selected-fields option on save (register_setting callback).
739 - *
740 - * Iterates the MLS metadata field list and copies, for each known field, the
741 - * display/admin flags, label, post-meta and taxonomy mappings and drag order.
742 - * On a first save it seeds sensible defaults via mlsimport_seed_field_defaults.
743 - *
744 - * @param array $input Raw submitted field-select data.
745 - * @return array Whitelisted field configuration.
746 - * @since 1.0.0
747 - */
748 - public function validate_admin_fields_select( $input ) {
749 - $valid = array();
750 -
751 - // The authoritative field list comes from the fetched MLS metadata.
752 - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
753 - $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true );
754 -
755 - // Only accept keys that exist in the MLS metadata.
756 - foreach ( $metadata_api_call as $key => $value ) {
757 - // Front-end display flag for this field.
758 - if ( isset( $input['mls-fields'][ $key ] ) ) {
759 - $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] );
760 - }
761 -
762 - // Admin-display flag + its associated label/mapping/order settings.
763 - if ( isset( $input['mls-fields-admin'][ $key ] ) ) {
764 - $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] );
765 - $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] );
766 - $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] );
767 - $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] );
768 - $valid['field_order'][ $key ] = esc_attr( $input['field_order'][ $key ] );
769 - }
770 - }
771 - //$valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] );
772 -
773 - // First save has nothing configured yet: seed the drag order and hide the
774 - // plumbing fields (keys, timestamps, coordinates) from visitors, so the
775 - // property page reads sensibly out of the box. Never overwrites a site the
776 - // user has already arranged.
777 - if ( function_exists( 'mlsimport_seed_field_defaults' ) ) {
778 - $valid = mlsimport_seed_field_defaults( $valid );
779 - }
780 -
781 - return $valid;
782 - }
783 -
784 - /**
785 933 * Validate the MLS-sync option group on save (register_setting callback).
786 934 *
787 935 * Copies a fixed whitelist of sync/import parameter keys straight through.
788 936 *
@@ -805,102 +953,23 @@
805 953 return $valid;
806 954 }
807 955
808 956
809 - /**
810 - * Validate the administrative options group on save (register_setting callback).
811 - *
812 - * Only carries the raw "import" payload through (a JSON blob of exported settings).
813 - *
814 - * @param array $input Raw submitted administrative options.
815 - * @return array Whitelisted administrative options.
816 - * @since 1.0.0
817 - */
818 - public function validate_administrative_options( $input ) {
819 957
820 - $valid = array();
821 -
822 - // Pass the single 'import' payload through.
823 - $field_import = array( 'import' );
824 - foreach ( $field_import as $key ) {
825 - $valid[ $key ] = $input[ $key ];
826 - }
827 -
828 - return $valid;
829 - }
830 -
831 958 /**
832 - * Validate the import-options group on save (register_setting callback).
833 - *
834 - * Casts import_number to int, and when an 'import' JSON payload is present it
835 - * restores the field-select / mls-sync / import-options / transients options
836 - * from it (used by the settings import/export feature).
837 - *
838 - * @param array $input Raw submitted import options.
839 - * @return array Whitelisted import options.
840 - * @since 1.0.0
841 - */
842 - public function validate_admin_import_options( $input ) {
843 - $valid = array();
844 -
845 - // import_number is numeric-only.
846 - $field_import = array( 'import_number' );
847 - foreach ( $field_import as $key ) {
848 - $valid[ $key ] = intval( $input[ $key ] );
849 - }
850 -
851 - // When an exported-settings JSON blob is supplied, decode it and restore
852 - // the four related option groups from it.
853 - if ( isset( $input['import'] ) && '' !== $input['import'] ) {
854 - $decode = json_decode( $input['import'] );
855 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
856 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
857 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
858 - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
859 - }
860 -
861 - return $valid;
862 - }
863 -
864 -
865 -
866 -
867 -
868 -
869 - /**
870 959 * Register all plugin option groups with the Settings API and bind each to
871 960 * its validation callback. Hooked on admin_init.
872 961 */
873 962 public function options_update() {
874 - // One register_setting per option group -> validate_* sanitizer above.
963 + // Field Configuration is intentionally absent: it is form-free and only the
964 + // deep module's compact command endpoint may mutate its option.
875 965 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
876 - register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) );
877 966 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
878 - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
879 - register_setting( $this->plugin_name . '_administrative_options', $this->plugin_name . '_administrative_options', array( $this, 'validate_administrative_options' ) );
880 967 // The standalone option is registered in class-mlsimport-standalone-settings.php
881 968 // (on init, with show_in_rest) so the dedicated React design page can read/write it.
882 969 }
883 970
884 971 /**
885 - * Update-option hook for the administrative options group.
886 - *
887 - * When the administrative options carry an 'import' JSON payload, decode it
888 - * and restore the field-select / mls-sync / import-options option groups.
889 - */
890 - public function update_option_mlsimport_administrative_options() {
891 - // Read the saved administrative options and, if present, restore the
892 - // three related option groups from the embedded JSON payload.
893 - $import = get_option( 'mlsimport_administrative_options' );
894 - if ( '' !== $import ) {
895 - $decode = json_decode( $import['import'], true );
896 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
897 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
898 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
899 - }
900 - }
901 -
902 - /**
903 972 * Update-option hook for the field-select group: ask the active theme
904 973 * adapter to (re)register its custom fields/taxonomies for the mapped fields.
905 974 */
906 975 public function update_option_mlsimport_admin_fields_select() {
@@ -931,15 +1000,15 @@
931 1000 */
932 1001 public function mlsimport_hidden_fields() {
933 1002 global $post;
934 1003
935 - // Field-select config drives which imported meta values to display.
936 - $options = get_option( $this->plugin_name . '_admin_fields_select' );
1004 + // The active projection keeps disappeared MLS fields out of the metabox.
1005 + $options = mlsimport_active_field_configuration();
937 1006
938 1007 // Which Import Task created / last updated this property, and its RESO key.
939 1008 $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true );
940 1009 $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true );
941 - $listing_key = get_post_meta( $post->ID, 'ListingKey', true );
1010 + $listing_key = get_post_meta( $post->ID, '_mlsimport_listing_key', true );
942 1011
943 1012 // Get the import task ID to retrieve protected statuses
944 1013 // (prefer the inserting task, fall back to the updating task).
945 1014 $import_task_id = !empty( $MLSimport_item_inserted ) ? $MLSimport_item_inserted : ( !empty( $MLSimport_item_updated ) ? $MLSimport_item_updated : null );
@@ -985,9 +1054,11 @@
985 1054 // is why hidden fields (e.g. ParcelNumber) showed here without a value.
986 1055 if ( function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode() && function_exists( 'mlsimport_property_field_value' ) ) {
987 1056 $field_value = mlsimport_property_field_value( (int) $post->ID, (string) $key );
988 1057 } else {
989 - $meta_key = ( 'ListingKey' !== $key ) ? strtolower( $key ) : $key;
1058 + // Issue #286: the identity is protected meta, never a lowercased
1059 + // theme custom field.
1060 + $meta_key = ( 'ListingKey' !== $key ) ? strtolower( $key ) : '_mlsimport_listing_key';
990 1061 $field_value = (string) get_post_meta( $post->ID, $meta_key, true );
991 1062 }
992 1063 ?>
993 1064
@@ -1030,10 +1101,10 @@
1030 1101 delete_transient( 'mlsimport_plugin_data_schema' );
1031 1102 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1032 1103 delete_transient( 'mlsimport_saas_token' );
1033 1104
1034 - // Force a fresh metadata pull next load.
1035 - delete_option( 'mlsimport_mls_metadata_populated' );
1105 + // Force a fresh metadata pull next load (current connection only, #275).
1106 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
1036 1107
1037 1108 die( 'deleted' );
1038 1109 }
1039 1110
@@ -1045,11 +1116,13 @@
1045 1116
1046 1117 // CSRF: Tools-page nonce.
1047 1118 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1048 1119
1049 - // Wipe the metadata flag and the saved field-select configuration.
1050 - delete_option( 'mlsimport_mls_metadata_populated' );
1051 - delete_option( 'mlsimport_admin_fields_select' );
1120 + // Wipe the metadata flag and the saved field-select configuration
1121 + // for the CURRENT connection only (#275) — other connections'
1122 + // mappings stay isolated.
1123 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
1124 + mlsimport_delete_connection_option( 'mlsimport_admin_fields_select' );
1052 1125
1053 1126 die( 'deleted' );
1054 1127 }
1055 1128
@@ -1294,13 +1367,11 @@
1294 1367
1295 1368 /**
1296 1369 * Test the configured MLS credentials against the SaaS API.
1297 1370 *
1298 - * Gathers every provider's stored credentials, short-circuits (returns
1299 - * early) when the mandatory credentials for the selected provider are
1300 - * blank, PATCHes them to the 'clients' endpoint, and stores/clears the
1301 - * 'mlsimport_connection_test' flag based on whether the API reports the
1302 - * connection tested successfully.
1371 + * Resolves the selected Provider Family, asks its adapter for the exact
1372 + * active credentials, PATCHes only those values to the 'clients' endpoint,
1373 + * and stores/clears the connection flag from the API result.
1303 1374 *
1304 1375 * @since 4.0.1
1305 1376 * @return array|void The API response, or void on an early return.
1306 1377 */
@@ -1305,227 +1376,89 @@
1305 1376 * @return array|void The API response, or void on an early return.
1306 1377 */
1307 1378 public function mlsimport_saas_check_mls_connection() {
1308 1379
1309 - // Load saved options; $values will accumulate the credentials to send.
1310 - $values = array();
1311 - $options = get_option( $this->plugin_name . '_admin_options' );
1380 + // Resolve the active Provider Family once. A saved type is authoritative;
1381 + // older settings without one use the module's numeric compatibility map.
1382 + $options = get_option( $this->plugin_name . '_admin_options' );
1383 + $options = is_array( $options ) ? $options : array();
1384 + $mls_id = isset( $options['mlsimport_mls_name'] )
1385 + ? sanitize_text_field( trim( $options['mlsimport_mls_name'] ) )
1386 + : '';
1387 + $saved_type = Mlsimport_Provider_Family::saved_type( $mls_id );
1388 + $provider = Mlsimport_Provider_Family::adapter( $saved_type, $mls_id, $this->theme_importer );
1312 1389
1313 - // --- Selected MLS id + generic token ---
1314 - $mls_id = '';
1315 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1316 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1317 - }
1318 -
1319 - $mls_token = '';
1320 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1321 - $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
1322 - }
1323 -
1324 - // Numeric MLS id drives the provider-family branching below.
1325 - $mls_id_int = intval( $mls_id );
1326 -
1327 - // --- Trestle credentials ---
1328 - $mlsimport_tresle_client_id = '';
1329 - if ( isset( $options['mlsimport_tresle_client_id'] ) ) {
1330 - $mlsimport_tresle_client_id = sanitize_text_field( trim( $options['mlsimport_tresle_client_id'] ) );
1390 + // Build one safe payload. Missing credentials and unsupported saved types
1391 + // stop here, before the network request, with the module's stable error.
1392 + if ( ! $provider->supported() ) {
1393 + delete_option( 'mlsimport_connection_test' );
1394 + return array(
1395 + 'success' => false,
1396 + 'error' => $provider->error(),
1397 + );
1331 1398 }
1332 1399
1333 - $mlsimport_tresle_client_secret = '';
1334 - if ( isset( $options['mlsimport_tresle_client_secret'] ) ) {
1335 - $mlsimport_tresle_client_secret = sanitize_text_field( trim( $options['mlsimport_tresle_client_secret'] ) );
1336 - }
1337 -
1338 - // --- ConnectMLS credentials ---
1339 - $mlsimport_connectmls_username = '';
1340 - if ( isset( $options['mlsimport_connectmls_username'] ) ) {
1341 - $mlsimport_connectmls_username = sanitize_text_field( trim( $options['mlsimport_connectmls_username'] ) );
1342 - }
1343 -
1344 - $mlsimport_connectmls_password = '';
1345 - if ( isset( $options['mlsimport_connectmls_password'] ) ) {
1346 - $mlsimport_connectmls_password = sanitize_text_field( trim( $options['mlsimport_connectmls_password'] ) );
1347 - }
1348 -
1349 - // rapattoni data
1350 - $mlsimport_rapattoni_client_id = '';
1351 - if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) {
1352 - $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_id'] ) );
1353 - }
1354 - $mlsimport_rapattoni_client_secret = '';
1355 - if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) {
1356 - $mlsimport_rapattoni_client_secret = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_secret'] ) );
1400 + $payload_result = $provider->connection_test_payload( $options, $mls_id );
1401 + if ( ! $payload_result['success'] ) {
1402 + delete_option( 'mlsimport_connection_test' );
1403 + return array(
1404 + 'success' => false,
1405 + 'error' => $payload_result['error'],
1406 + 'missing' => $payload_result['missing'],
1407 + );
1357 1408 }
1409 + $values = $payload_result['payload'];
1358 1410
1359 - $mlsimport_rapattoni_username = '';
1360 - if ( isset( $options['mlsimport_rapattoni_username'] ) ) {
1361 - $mlsimport_rapattoni_username = sanitize_text_field( trim( $options['mlsimport_rapattoni_username'] ) );
1411 + // PATCH the credentials to the SaaS 'clients' endpoint, which validates
1412 + // them against the live MLS and reports back whether it "tested".
1413 + // NOTE: mlsimport_connections_patch_test() (Connections screen —
1414 + // shared by the per-row Test #280 and the Add-MLS drawer #281) mirrors
1415 + // this PATCH + #276 contract sequence for record-scoped tests — a
1416 + // contract change must land in that helper and here.
1417 + $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
1418 + // Some clients responses include the authoritative MLS configuration. Save
1419 + // its type beside this MLS ID so later requests no longer need ID fallback.
1420 + if ( isset( $answer['mls_data']['type'] ) ) {
1421 + Mlsimport_Provider_Family::remember_type( $answer['mls_data']['type'], $mls_id );
1362 1422 }
1363 1423
1364 - $mlsimport_rapattoni_password = '';
1365 - if ( isset( $options['mlsimport_rapattoni_password'] ) ) {
1366 - $mlsimport_rapattoni_password = sanitize_text_field( trim( $options['mlsimport_rapattoni_password'] ) );
1424 + // The PATCH is mls_id-scoped (#276): apply the returned mls_data block to
1425 + // exactly this connection's registry record. The echo guard inside refuses
1426 + // a block that does not name this MLS, so a misrouted/legacy response can
1427 + // never overwrite another connection.
1428 + if ( isset( $answer['mls_data'] ) && is_array( $answer['mls_data'] ) ) {
1429 + mlsimport_apply_client_block( $answer['mls_data'], (int) $mls_id );
1367 1430 }
1368 -
1369 - // paragon data
1370 - $mlsimport_paragon_client_id = '';
1371 - if ( isset( $options['mlsimport_paragon_client_id'] ) ) {
1372 - $mlsimport_paragon_client_id = sanitize_text_field( trim( $options['mlsimport_paragon_client_id'] ) );
1431 + // The stable not_entitled rejection marks THIS connection only; the error
1432 + // itself still returns to the caller below, so it is never silent.
1433 + if ( mlsimport_response_not_entitled( $answer ) ) {
1434 + mlsimport_mark_connection_not_entitled( (int) $mls_id );
1373 1435 }
1374 - $mlsimport_paragon_client_secret = '';
1375 - if ( isset( $options['mlsimport_paragon_client_secret'] ) ) {
1376 - $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) );
1377 - }
1378 1436
1379 - // realtor.ca data
1380 - $mlsimport_realtorca_client_id = '';
1381 - if ( isset( $options['mlsimport_realtorca_client_id'] ) ) {
1382 - $mlsimport_realtorca_client_id = sanitize_text_field( trim( $options['mlsimport_realtorca_client_id'] ) );
1383 - }
1384 - $mlsimport_realtorca_client_secret = '';
1385 - if ( isset( $options['mlsimport_realtorca_client_secret'] ) ) {
1386 - $mlsimport_realtorca_client_secret = sanitize_text_field( trim( $options['mlsimport_realtorca_client_secret'] ) );
1387 - }
1388 1437
1389 - // brightmls data
1390 - $mlsimport_brightmls_client_id = '';
1391 - if ( isset( $options['mlsimport_brightmls_client_id'] ) ) {
1392 - $mlsimport_brightmls_client_id = sanitize_text_field( trim( $options['mlsimport_brightmls_client_id'] ) );
1393 - }
1394 - $mlsimport_brightmls_client_secret = '';
1395 - if ( isset( $options['mlsimport_brightmls_client_secret'] ) ) {
1396 - $mlsimport_brightmls_client_secret = sanitize_text_field( trim( $options['mlsimport_brightmls_client_secret'] ) );
1397 - }
1398 1438
1399 1439
1400 -
1401 -
1402 -
1403 -
1404 -
1405 - // When the generic token is blank, the selected provider's own
1406 - // credentials are mandatory: bail (no test) if any are missing.
1407 - // Each branch maps an mls_id range to one provider family.
1408 - if ( trim( $mls_token ) === '' ) {
1409 - if ( $this->mlsimport_is_brightmls_provider( $mls_id_int ) ) { // BrightMLS
1410 - if ( trim( $mlsimport_brightmls_client_id ) === '' || trim( $mlsimport_brightmls_client_secret ) === '' ) {
1411 - return;
1412 - }
1413 - } elseif ( $mls_id_int > 900 && $mls_id_int < 3000 ) { // Trestle
1414 - if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) {
1415 - return;
1416 - }
1417 - } elseif ( $this->mlsimport_is_connectmls_provider( $mls_id_int ) ) { // ConnectMLS
1418 - if (
1419 - trim( $mlsimport_connectmls_username ) === '' ||
1420 - trim( $mlsimport_connectmls_password ) === ''
1421 - ) {
1422 - return;
1423 - }
1424 - } elseif ( $mls_id_int >= 5000 && $mls_id_int < 6000 ) { // Rapattoni
1425 - if (
1426 - trim( $mlsimport_rapattoni_client_id ) === '' ||
1427 - trim( $mlsimport_rapattoni_client_secret ) === '' ||
1428 - trim( $mlsimport_rapattoni_username ) === '' ||
1429 - trim( $mlsimport_rapattoni_password ) === ''
1430 - ) {
1431 - return;
1432 - }
1433 - } elseif ( $mls_id_int >= 6000 && $mls_id_int < 7000 ) { // Paragon
1434 - if (
1435 - trim( $mlsimport_paragon_client_id ) === '' ||
1436 - trim( $mlsimport_paragon_client_secret ) === ''
1437 - ) {
1438 - return;
1439 - }
1440 - } elseif ( $mls_id_int >= 7000 && $mls_id_int < 8000 ) { // Realtor.ca
1441 - if (
1442 - trim( $mlsimport_realtorca_client_id ) === '' ||
1443 - trim( $mlsimport_realtorca_client_secret ) === ''
1444 - ) {
1445 - return;
1446 - }
1447 - } elseif ( mlsimport_is_proptx_provider( $mls_id_int ) ) { // PropTx / AMPRE - static bearer token is the only credential
1448 - return;
1449 - }
1450 - }
1451 -
1452 - // Assemble the full credential payload for every provider.
1453 - $values['mls_token'] = $mls_token;
1454 - $values['mls_id'] = $mls_id;
1455 - $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id;
1456 - $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret;
1457 - $values['mlsimport_connectmls_username'] = $mlsimport_connectmls_username;
1458 - $values['mlsimport_connectmls_password'] = $mlsimport_connectmls_password;
1459 -
1460 - $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id;
1461 - $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret;
1462 - $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username;
1463 - $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password;
1464 -
1465 - $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id;
1466 - $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret;
1467 -
1468 -
1469 - $values['mlsimport_realtorca_client_id'] = $mlsimport_realtorca_client_id;
1470 - $values['mlsimport_realtorca_client_secret'] = $mlsimport_realtorca_client_secret;
1471 -
1472 - $values['mlsimport_brightmls_client_id'] = $mlsimport_brightmls_client_id;
1473 - $values['mlsimport_brightmls_client_secret'] = $mlsimport_brightmls_client_secret;
1474 -
1475 -
1476 -
1477 -
1478 -
1479 -
1480 -
1481 - // PATCH the credentials to the SaaS 'clients' endpoint, which validates
1482 - // them against the live MLS and reports back whether it "tested".
1483 - $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
1484 -
1485 -
1486 -
1487 -
1488 1440 // Persist the connection-test flag only on a confirmed successful test;
1489 1441 // any other outcome clears it (and the metadata flag) so the UI re-tests.
1490 - if ( isset( $answer['success'] ) && true === $answer['success'] ) {
1491 - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
1492 - update_option( 'mlsimport_connection_test', 'yes' );
1493 - mlsimport_telemetry_set_once( 'mls_connected_at', time() );
1494 - } else {
1495 - delete_option( 'mlsimport_connection_test' );
1496 - delete_option( 'mlsimport_mls_metadata_populated' );
1497 - }
1442 + $mlsimport_tested_ok = isset( $answer['success'] ) && true === $answer['success']
1443 + && isset( $answer['tested'] ) && true === $answer['tested'];
1444 + if ( $mlsimport_tested_ok ) {
1445 + update_option( 'mlsimport_connection_test', 'yes' );
1446 + mlsimport_telemetry_set_once( 'mls_connected_at', time() );
1498 1447 } else {
1499 1448 delete_option( 'mlsimport_connection_test' );
1500 - delete_option( 'mlsimport_mls_metadata_populated' );
1449 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
1501 1450 }
1502 1451
1452 + // Mirror the outcome into this connection's registry record (#277):
1453 + // the cron gate reads record status for every non-current connection,
1454 + // so the record must stay truthful, not only the global flag above.
1455 + mlsimport_connection_record_test_result( (int) $mls_id, $mlsimport_tested_ok );
1456 +
1503 1457 return $answer;
1504 1458 }
1505 1459
1506 1460 /**
1507 - * Whether an MLS id belongs to the ConnectMLS family (8000-8999, but not
1508 - * 8001 which is BrightMLS).
1509 - *
1510 - * @param int $mls_id_int Numeric MLS id.
1511 - * @return bool
1512 - */
1513 - private function mlsimport_is_connectmls_provider( $mls_id_int ) {
1514 - return $mls_id_int >= 8000 && $mls_id_int < 9000 && 8001 !== (int) $mls_id_int;
1515 - }
1516 -
1517 - /**
1518 - * Whether an MLS id is BrightMLS (the single reserved id 8001).
1519 - *
1520 - * @param int $mls_id_int Numeric MLS id.
1521 - * @return bool
1522 - */
1523 - private function mlsimport_is_brightmls_provider( int $mls_id_int ): bool {
1524 - return 8001 === $mls_id_int;
1525 - }
1526 -
1527 - /**
1528 1461 * AJAX handler for the plugin-deactivation exit survey.
1529 1462 *
1530 1463 * Thin wrapper: it verifies the nonce and capability, sanitizes input,
1531 1464 * delegates the real work to mlsimport_exit_survey_record(), and POSTs
@@ -1724,9 +1657,11 @@
1724 1657 }
1725 1658
1726 1659 $password = '';
1727 1660 if ( isset( $options['mlsimport_password'] ) ) {
1728 - $password = sanitize_text_field( trim( $options['mlsimport_password'] ) );
1661 + // Credentials are sent verbatim: sanitize_text_field() strips
1662 + // %[hex][hex] sequences and would corrupt the password (#204).
1663 + $password = trim( (string) $options['mlsimport_password'] );
1729 1664 }
1730 1665 $mls_name = '';
1731 1666 if ( isset( $options['mlsimport_mls_name'] ) ) {
1732 1667 $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
@@ -1746,8 +1681,12 @@
1746 1681 delete_transient( 'mlsimport_plugin_data_schema' );
1747 1682 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1748 1683 delete_transient( 'mlsimport_saas_token' );
1749 1684
1685 + // Flat legacy copies only: per-connection "_{mls_id}" state (#275)
1686 + // deliberately survives an MLS switch so returning to a prior MLS
1687 + // restores its mapping. The save path clears the SAVED MLS's own
1688 + // populated flag, which is what forces the fresh gather.
1750 1689 delete_option( 'mlsimport_mls_metadata_populated' );
1751 1690
1752 1691 delete_option( 'mlsimport_admin_fields_select' );
1753 1692 }
@@ -1769,9 +1708,11 @@
1769 1708 // POST to the SaaS 'token' endpoint and return its response.
1770 1709 $theme_Start = new ThemeImport();
1771 1710 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
1772 1711
1773 -
1712 + // Remember WHY the server said no (403 no subscription vs 401 bad
1713 + // password) so the "not connected" screens can say the right thing.
1714 + mlsimport_account_status_record( $answer );
1774 1715
1775 1716 return $answer;
1776 1717 }
1777 1718
@@ -1831,8 +1772,16 @@
1831 1772 if ( ! current_user_can( 'edit_post', $post_id ) ) {
1832 1773 return;
1833 1774 }
1834 1775
1776 + // Connection binding (#277): stamp once at creation. The helper is
1777 + // immutable for a bound task, so the posted value can never re-bind;
1778 + // deliberately NOT in the generic allowed-keys loop below.
1779 + mlsimport_bind_task_connection(
1780 + (int) $post_id,
1781 + isset( $_POST['mlsimport_item_mls_id'] ) ? (int) $_POST['mlsimport_item_mls_id'] : 0
1782 + );
1783 +
1835 1784 // Every import-parameter meta key this metabox may write.
1836 1785 $allowed_keys = array(
1837 1786 'mlsimport_item_how_many',
1838 1787 'mlsimport_item_title_format',
@@ -1867,8 +1816,9 @@
1867 1816 'mlsimport_item_listofficekey',
1868 1817 'mlsimport_item_postalcode',
1869 1818 'mlsimport_item_listofficemlsid',
1870 1819 'mlsimport_item_listingid',
1820 + 'mlsimport_item_listingkey',
1871 1821 'mlsimport_item_extracity',
1872 1822 'mlsimport_item_extracounty',
1873 1823 'mlsimport_item_exclude_listofficemlsid',
1874 1824 'mlsimport_item_exclude_listofficekey',
@@ -1901,8 +1851,9 @@
1901 1851 'mlsimport_item_propertysubtype',
1902 1852 'mlsimport_item_propertytype',
1903 1853 'mlsimport_item_standardstatus',
1904 1854 'mlsimport_item_listingid',
1855 + 'mlsimport_item_listingkey',
1905 1856 'mlsimport_item_customparameters',
1906 1857 'mlsimport_item_mlsareamajor',
1907 1858 'mlsimport_item_subdivisionname',
1908 1859
@@ -1925,8 +1876,14 @@
1925 1876 * Ensures a live SaaS token + MLS connection, prints a warning and stops if
1926 1877 * either is missing, otherwise runs a listing count request and hands off to
1927 1878 * generateMetaOptionsHtml() to build the parameter form.
1928 1879 *
1880 + * Rendering this screen is a READ: when the count request fails, its own
1881 + * error is printed and nothing else happens (issue #295). It does not
1882 + * diagnose the failure for the user, and it never re-tests the MLS
1883 + * connection - that PATCHes credentials to the SaaS and rewrites the
1884 + * connection flags, which merely opening a task must not do.
1885 + *
1929 1886 * @param WP_Post $post The post object.
1930 1887 */
1931 1888 public function mlsimport_saas_display_meta_options($post) {
1932 1889 // Nonce for the metabox save.
@@ -1943,11 +1900,12 @@
1943 1900 $mlsimport->admin->mlsimport_saas_check_mls_connection();
1944 1901 $is_mls_connected = get_option('mlsimport_connection_test', '');
1945 1902 }
1946 1903
1947 - // No token -> account not authenticated; stop with a notice.
1904 + // No token -> account not authenticated; stop with a notice
1905 + // that names the reason (no subscription vs wrong password).
1948 1906 if (trim($token) === '') {
1949 - echo '<div class="mlsimport_warning">' . esc_html__('You are not connected to MlsImport - Please check your Username and Password.', 'mlsimport') . '</div>';
1907 + echo mlsimport_account_not_connected_html(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped by the builder.
1950 1908 return;
1951 1909 }
1952 1910
1953 1911 // Token OK but MLS connection failed -> stop with a notice.
@@ -1961,31 +1919,41 @@
1961 1919 $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1962 1920 $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1963 1921 $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1964 1922 $status = get_option('mlsimport_force_stop_' . $postId);
1965 - $fieldImport = $this->mlsimport_saas_return_mls_fields();
1966 - $options = get_option('mlsimport_admin_options');
1967 - $mlsimportMlsId = isset($options['mlsimport_mls_name']) && $options['mlsimport_mls_name'] !== ''
1923 + $fieldImport = $this->mlsimport_saas_return_mls_fields( mlsimport_task_mls_id( (int) $postId ) );
1924 + // The TASK's own connection (#277): a bound task displays and
1925 + // requests against its binding; a new/unstamped task resolves
1926 + // to the current connection inside the helper.
1927 + $mlsimportMlsId = mlsimport_task_mls_id((int) $postId);
1968 1928
1969 - ? intval($options['mlsimport_mls_name'])
1970 - : 0;
1971 -
1972 1929 // Ask the MLS how many listings currently match this task.
1973 1930 $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1974 1931 // print_r($mlsRequest);
1975 1932
1976 1933 // Surface any API error message inline.
1934 + // This warning is the screen's ONLY explanation of a failed count
1935 + // (issue #295), so it must never come out empty. A rejection the
1936 + // SaaS reports as an error OBJECT - notably the not_entitled 403
1937 + // - carries its reason there and no top-level message, and this
1938 + // request path does not normalize error objects the way
1939 + // globalApiRequestSaas() does.
1977 1940 $hasError = isset($mlsRequest['success']) && !$mlsRequest['success'];
1978 1941 if ($hasError) {
1979 - echo '<div class="mlsimport_warning">' . esc_html($mlsRequest['message']) . '</div>';
1942 + $errorMessage = $mlsRequest['message'] ?? $mlsRequest['error']['message'] ?? esc_html__('The MLS request failed.', 'mlsimport');
1943 + echo '<div class="mlsimport_warning">' . esc_html($errorMessage) . '</div>';
1980 1944 }
1981 1945
1982 - // 'none' means no results key -> likely an expired token; re-test.
1983 - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1984 - if ($foundItems === 'none') {
1985 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
1986 - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1987 - }
1946 + // The count is readable only when the response carries 'results'.
1947 + // Every successful listings response does; every failure - an
1948 + // upstream MLS error, an entitlement rejection, a rejected
1949 + // request built here - carries success=false instead, and its
1950 + // real cause was already printed above. So an unreadable count
1951 + // adds nothing to say (issue #295): no second, guessed
1952 + // explanation, and above all no connection re-test - that is a
1953 + // remote credential PATCH, scoped to the CURRENT connection
1954 + // rather than this task's, fired by merely opening a screen.
1955 + $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : null;
1988 1956
1989 1957 // Build and print the parameter form.
1990 1958 echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError);
1991 1959 }
@@ -1996,9 +1964,10 @@
1996 1964 /**
1997 1965 * Generate Meta Options HTML
1998 1966 *
1999 1967 * @param int $postId The post ID.
2000 - * @param int $foundItems The number of found items.
1968 + * @param int|null $foundItems The number of found items, or null when the
1969 + * count request failed and no count is known.
2001 1970 * @param string $lastDate The last date checked.
2002 1971 * @param string $mlsimportItemHowMany How many items to import.
2003 1972 * @param string $mlsimportItemStatCron The status of the cron job.
2004 1973 * @param int $mlsimportMlsId The MLS import ID.
@@ -2015,9 +1984,11 @@
2015 1984 // carry their human-readable labels alongside the raw values.
2016 1985 $metadata_api_call_city = array();
2017 1986 $metadata_api_call_county = array();
2018 1987 $metadata_api_call_property_type = array();
2019 - $mlsimport_mls_metadata_mls_enums = get_option('mlsimport_mls_metadata_mls_enums', '');
1988 + // Enums come from the TASK's connection (#277) so a task bound to a
1989 + // non-current MLS still offers ITS cities/counties/types.
1990 + $mlsimport_mls_metadata_mls_enums = mlsimport_get_connection_option( 'mlsimport_mls_metadata_mls_enums', '', mlsimport_task_mls_id( (int) $postId ) );
2020 1991 if ('' !== $mlsimport_mls_metadata_mls_enums) {
2021 1992 $metadata_api_call_full = json_decode($mlsimport_mls_metadata_mls_enums, true);
2022 1993 if (isset($metadata_api_call_full['global_array']['PropertyEnums'])) {
2023 1994 $property_enums = $metadata_api_call_full['global_array']['PropertyEnums'];
@@ -2050,12 +2021,53 @@
2050 2021 </div>
2051 2022 <?php endif; ?>
2052 2023
2053 2024 <div class="mlsimport_import_no">
2054 - <?php esc_html_e('We found', 'mlsimport'); ?>
2055 - <strong><?php echo esc_html($foundItems); ?></strong> listings. If you decide to import all of them make sure your server database can handle the load. Please do a database backup before initial import.
2025 + <?php if (null === $foundItems): ?>
2026 + <?php // The request failed; its real cause is in the warning above. ?>
2027 + <?php esc_html_e('We could not read a listing count. See the error above.', 'mlsimport'); ?>
2028 + <?php else: ?>
2029 + <?php esc_html_e('We found', 'mlsimport'); ?>
2030 + <strong><?php echo esc_html($foundItems); ?></strong> listings. If you decide to import all of them make sure your server database can handle the load. Please do a database backup before initial import.
2031 + <?php endif; ?>
2056 2032 </div>
2057 2033
2034 + <?php
2035 + // Connection binding (#277): every task belongs to ONE connection for
2036 + // life. Three render states, first field of the form:
2037 + // - already bound => locked (visible, not editable);
2038 + // - unbound, >1 connections => required picker of registered ones;
2039 + // - unbound, <=1 connections => nothing (auto-stamped on save).
2040 + $mlsimport_bound_mls = (int) get_post_meta( $postId, 'mlsimport_item_mls_id', true );
2041 + $mlsimport_connections = Mlsimport_Connections::all();
2042 + if ( $mlsimport_bound_mls > 0 ) :
2043 + // Label from the registry when available; a deleted connection
2044 + // still shows its raw id so the administrator sees what broke.
2045 + $mlsimport_bound_label = isset( $mlsimport_connections[ $mlsimport_bound_mls ] ) && '' !== $mlsimport_connections[ $mlsimport_bound_mls ]['mls_name']
2046 + ? $mlsimport_connections[ $mlsimport_bound_mls ]['mls_name']
2047 + : __( 'MLS', 'mlsimport' ) . ' ' . $mlsimport_bound_mls;
2048 + ?>
2049 + <fieldset class="mlsimport-fieldset" id="mlsimport_item_mls_binding">
2050 + <label class="mlsimport-label"><?php esc_html_e( 'MLS Connection', 'mlsimport' ); ?></label>
2051 + <select class="mlsimport-select mlsimport-2025-select" disabled data-bound-mls="<?php echo esc_attr( $mlsimport_bound_mls ); ?>">
2052 + <option selected><?php echo esc_html( $mlsimport_bound_label ); ?></option>
2053 + </select>
2054 + <p class="mlsimport-exp"><?php esc_html_e( 'This task is bound to its MLS connection for life. To import from another MLS, create a new task.', 'mlsimport' ); ?></p>
2055 + </fieldset>
2056 + <?php elseif ( count( $mlsimport_connections ) > 1 ) : ?>
2057 + <fieldset class="mlsimport-fieldset" id="mlsimport_item_mls_binding">
2058 + <label class="mlsimport-label" for="mlsimport_item_mls_id"><?php esc_html_e( 'MLS Connection', 'mlsimport' ); ?></label>
2059 + <select id="mlsimport_item_mls_id" name="mlsimport_item_mls_id" class="mlsimport-select mlsimport-2025-select" required>
2060 + <?php foreach ( $mlsimport_connections as $mlsimport_connection ) : ?>
2061 + <option value="<?php echo esc_attr( $mlsimport_connection['mls_id'] ); ?>">
2062 + <?php echo esc_html( '' !== $mlsimport_connection['mls_name'] ? $mlsimport_connection['mls_name'] : __( 'MLS', 'mlsimport' ) . ' ' . $mlsimport_connection['mls_id'] ); ?>
2063 + </option>
2064 + <?php endforeach; ?>
2065 + </select>
2066 + <p class="mlsimport-exp"><?php esc_html_e( 'Choose which MLS connection this task imports from. The choice is permanent after the task is saved.', 'mlsimport' ); ?></p>
2067 + </fieldset>
2068 + <?php endif; ?>
2069 +
2058 2070 <fieldset class="mlsimport-fieldset">
2059 2071 <label class="mlsimport-label" for="mlsimport_item_how_many">
2060 2072 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
2061 2073 </label>
@@ -2185,30 +2197,22 @@
2185 2197 <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input " id="mlsimport_item_max_price" name="mlsimport_item_max_price" value="<?php echo esc_attr($mlsimportItemMaxPrice); ?>">
2186 2198 </fieldset>
2187 2199
2188 2200 <?php
2189 - // Provider-specific tweaks to the field list before rendering.
2190 - $options = get_option($this->plugin_name . '_admin_options');
2201 + // Let the TASK's provider adjust only the Import Task fields it
2202 + // owns (#277): same resolution rule as the request builder — the
2203 + // connection record's provider type wins, the single-slot saved
2204 + // type / numeric map covers legacy configurations only.
2205 + $mlsimport_task_record = Mlsimport_Connections::get( $mlsimportMlsId );
2206 + $provider = Mlsimport_Provider_Family::adapter(
2207 + null !== $mlsimport_task_record && '' !== $mlsimport_task_record['provider_type']
2208 + ? $mlsimport_task_record['provider_type']
2209 + : Mlsimport_Provider_Family::saved_type( $mlsimportMlsId ),
2210 + $mlsimportMlsId,
2211 + $this->theme_importer
2212 + );
2213 + $fieldImport = $provider->prepare_import_task_fields( $fieldImport );
2191 2214
2192 - $mlsId = '';
2193 - if (isset($options['mlsimport_mls_name'])) {
2194 - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
2195 - }
2196 -
2197 - // Rapattoni (5000+): PropertyType becomes single-select.
2198 - if ($mlsId > 5000) {
2199 - $fieldImport['PropertyType']['multiple'] = 'no';
2200 - }
2201 -
2202 -
2203 - if ($mlsId >= 7000) {
2204 - // there is no such thing for realtor.ca
2205 - unset($fieldImport['PropertyType']);
2206 - }
2207 -
2208 -
2209 -
2210 -
2211 2215 // Render one fieldset per import parameter.
2212 2216 foreach ($fieldImport as $key => $field):
2213 2217 // Skip fields flagged hidden.
2214 2218 if (!empty($field['hidden'])) {
@@ -2253,8 +2257,9 @@
2253 2257 'ListOfficeKey',
2254 2258 'ListOfficeMlsId',
2255 2259 'StandardStatus',
2256 2260 'ListingId',
2261 + 'ListingKey',
2257 2262 'extraCity',
2258 2263 'extraCounty',
2259 2264 'Exclude_ListOfficeKey',
2260 2265 'Exclude_ListOfficeMlsId',
@@ -2264,9 +2269,13 @@
2264 2269 'MLSAreaMajor',
2265 2270 'SubdivisionName',
2266 2271 ];
2267 2272
2268 - if ($mlsId > 5000) {
2273 + // The TASK's own connection id (#277). This read used to be
2274 + // $mlsId, a variable that no longer exists in this scope —
2275 + // so the test was always false and PropertyType kept a
2276 + // Select All checkbox on providers that must not offer one.
2277 + if ((int) $mlsimportMlsId > 5000) {
2269 2278 $selectAllNone[] = 'PropertyType';
2270 2279 }
2271 2280
2272 2281 if (!in_array($key, $selectAllNone)): ?>
@@ -2322,16 +2331,19 @@
2322 2331 $option_value = $selectKey;
2323 2332 $option_label = $selectKey;
2324 2333 $comparison_values = array($option_value);
2325 2334
2335 + // Label = the enum's mapped name (identity for
2336 + // name=>name MLSs, city name for code=>name
2337 + // providers like Centris). Value stays the key.
2326 2338 if ('City' === $key && isset($metadata_api_call_city[$selectKey])) {
2327 - $option_label = $selectKey;
2339 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_city);
2328 2340 $comparison_values[] = $metadata_api_call_city[$selectKey];
2329 2341 } elseif ('CountyOrParish' === $key && isset($metadata_api_call_county[$selectKey])) {
2330 - $option_label = $selectKey;
2342 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_county);
2331 2343 $comparison_values[] = $metadata_api_call_county[$selectKey];
2332 2344 } elseif ('PropertyType' === $key && isset($metadata_api_call_property_type[$selectKey])) {
2333 - $option_label = $selectKey;
2345 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_property_type);
2334 2346 $comparison_values[] = $metadata_api_call_property_type[$selectKey];
2335 2347 }
2336 2348
2337 2349 $comparison_values = array_values(array_unique(array_filter($comparison_values, static function ($compare_value) {
@@ -2448,304 +2460,108 @@
2448 2460 *
2449 2461 * @param int $item_id
2450 2462 * @return int Number of listings found in the MLS feed, or 0 on failure.
2451 2463 */
2452 - public function mlsimport_saas_start_cron_links_per_item( int $item_id ): int {
2453 - // Log memory before start
2464 + public function mlsimport_saas_start_cron_links_per_item( int $item_id ): int {
2465 + // A task becomes eligible only after its first manual import completed.
2466 + // The rule lives in mlsimport_cron_task_is_eligible() so the Status
2467 + // badge applies the identical test (GitHub issue #330). The skip used to
2468 + // be a bare return: a task whose only manual run died sat unsynced for
2469 + // weeks with nothing recorded anywhere. Now it opens ONE deduplicated
2470 + // incident per task, resolved the first hour the task is eligible.
2471 + $eligible = mlsimport_cron_task_is_eligible(
2472 + (int) get_post_meta( $item_id, 'mlsimport_initial_import_completed', true ),
2473 + (string) get_post_meta( $item_id, 'mlsimport_spawn_status', true )
2474 + );
2475 + $incident = 'task_initial_import_incomplete:' . $item_id;
2476 + if ( ! $eligible ) {
2477 + mlsimport_alert_open( $incident, 'task_initial_import_incomplete', array( 'task_id' => $item_id ) );
2478 + return 0;
2479 + }
2480 + mlsimport_alert_resolve( $incident );
2454 2481
2455 - $found_items = 0;
2482 + $start = $this->mlsimport_import_task_execution()->start(
2483 + array(
2484 + 'task_id' => $item_id,
2485 + 'source' => 'automatic',
2486 + )
2487 + );
2488 + // Hourly work is already in the background. If another import owns the
2489 + // site-wide slot, this task simply waits for the next normal hourly run.
2490 + if ( true !== ( $start['accepted'] ?? false ) ) {
2491 + return 0;
2492 + }
2456 2493
2457 - // The cron only runs on a task that has fully completed a previous
2458 - // import. That excludes a task never imported by hand (no
2459 - // 'mlsimport_spawn_status' meta) AND a task with a manual import in
2460 - // flight ('started'): starting a second loop there would make both
2461 - // runs write the same task/progress meta and corrupt each other.
2462 - if ( ! mlsimport_cron_should_process_task( get_post_meta( $item_id, 'mlsimport_spawn_status', true ) ) ) {
2463 - return 0;
2464 - }
2494 + // This task now holds the slot: that is an attempt, whatever happens
2495 + // next. The stamp is the hourly queue's second key (issue #330), so a
2496 + // task that eats its hour goes to the back of the line even when the
2497 + // run never completes and the success watermark never moves.
2498 + update_post_meta( $item_id, 'mlsimport_last_attempt', wp_date( 'Y-m-d\TH:i' ) );
2465 2499
2466 - // Only pull listings modified since the task's watermark.
2467 - $last_date = $this->mlsimport_saas_get_last_date( $item_id );
2468 - print 'MLSitem id: ' . $item_id . ' - ';
2469 - esc_html_e('date to consider: ','mlsimport');
2470 - print esc_html($last_date) . '. ';
2500 + // Same rules as the manual worker: a large hourly sync must not be
2501 + // killed by the web/cron request time limit mid-run, and term counts
2502 + // are recomputed once after the run instead of per assignment.
2503 + if ( function_exists( 'set_time_limit' ) ) {
2504 + @set_time_limit( 0 ); // phpcs:ignore
2505 + }
2506 + wp_defer_term_counting( true );
2507 + $result = $this->mlsimport_import_task_execution()->execute( (string) $start['run_id'] );
2508 + wp_defer_term_counting( false );
2509 + // A 'running' result is a chunk hand-off (issue #330): the cron request
2510 + // spent its 45-second budget on this task and a background worker now
2511 + // carries the run to the end, keeping the site-wide slot. The hourly
2512 + // loop moves on; tasks behind this one are refused by that slot and
2513 + // get their turn on the next run, ordered by last attempt.
2514 + mlsimport_saas_single_write_import_custom_logs(
2515 + 'running' === (string) $result['state']
2516 + ? 'Automatic import for task ' . $item_id . ' handed off at ' . (int) ( $result['saved'] + $result['failed'] ) . ' listings; background worker queued.' . PHP_EOL
2517 + : 'Automatic import for task ' . $item_id . ' finished with state ' . (string) $result['state'] . '.' . PHP_EOL,
2518 + 'cron'
2519 + );
2520 + gc_collect_cycles();
2471 2521
2472 - // Make request to MLS API
2473 - $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date, '', '', true );
2522 + return (int) $result['found'];
2523 + }
2474 2524
2475 - // Success: record the count. Failure: drop the token (force refresh
2476 - // next run) and record the failure telemetry.
2477 - if ( isset( $mlsrequest['results'] ) ) {
2478 - $found_items = intval( $mlsrequest['results'] );
2479 - } else {
2480 - delete_transient( 'mlsimport_saas_token' );
2481 - mlsimport_telemetry_set( 'last_sync_failed', time() );
2482 - mlsimport_telemetry_set( 'last_sync_failed_code', (string) ( $mlsrequest['error_code'] ?? 'unknown' ) );
2483 - }
2484 - print esc_html__('We found ','mlsimport') . esc_html( $found_items ) . ' listings.</br>' . PHP_EOL;
2485 2525
2486 - // Only process if items found
2487 - if ( $found_items > 0 ) {
2488 2526
2489 - $item_id_array = array(
2490 - 'item_id' => $item_id,
2491 - 'how_many' => 0,
2492 - 'max_number' => $found_items,
2493 - 'batch_counter' => 1,
2494 - );
2495 2527
2496 - // Build the paginated batch of request-argument sets to run.
2497 - // Potentially large array, log memory before/after
2498 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date, true );
2499 2528
2500 - // Persist the batch + mark the cron job started.
2501 - // Store in post meta (beware if array is huge)
2502 - update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' );
2503 - update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move );
2504 2529
2505 - // Save last date for next run
2506 - $this->mlsimport_saas_update_last_date( $item_id );
2507 -
2508 - // Prepare and pass only necessary arguments to background process
2509 - $attachments_to_send = array(
2510 - 'args' => array(
2511 - 'attachments_to_move' => $item_id,
2512 - 'item_id_array' => $item_id_array,
2513 - ),
2514 - );
2515 -
2516 - // Run the batch synchronously (this is already inside cron).
2517 - $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] );
2518 -
2519 - // Unset large arrays/objects after use
2520 - unset($attachments_to_move, $attachments_to_send, $mlsrequest, $item_id_array);
2521 - gc_collect_cycles();
2522 - }
2523 -
2524 - return $found_items;
2525 - }
2526 -
2527 -
2528 -
2529 -
2530 -
2531 -
2532 2530 /**
2533 - * Daily reconciliation: delete local listings that should no longer exist.
2531 + * Backward-compatible entry point for the deep reconciliation module.
2534 2532 *
2535 - * Fetches the full set of ListingKeys currently in the MLS feed, guards against
2536 - * a truncated feed (which would trigger mass deletion), then walks every local
2537 - * listing in batches of 1000 and, per the status rules, deletes those that are
2538 - * gone from the feed (or present but in a delete-worthy status). Batched with
2539 - * explicit GC to keep memory bounded. Optimized, batched, memory logged.
2533 + * Cron now calls the per-connection runner directly. This method has no
2534 + * in-plugin callers and is retained only as a compatibility shim for
2535 + * third-party code; it delegates to the same runner (#279), so no caller
2536 + * can reach an unscoped destructive run once connections exist.
2540 2537 *
2541 - * @return void
2538 + * @return array<int, array<string, int|string>> Reconciliation Outcome per
2539 + * connection (key 0 = the single legacy unscoped run).
2542 2540 */
2543 2541 public function mlsimport_saas_start_doing_reconciliation() {
2544 - global $mlsimport, $wpdb;
2545 -
2546 -
2547 - // Pull every ListingKey the MLS currently reports; free the wrapper array.
2548 - // Get all MLS keys in memory (we assume this is necessary for lookup)
2549 - $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data();
2550 - $listingKey_in_MLS = $mls_data['all_data'] ?? [];
2551 -
2552 - unset($mls_data);
2553 - gc_collect_cycles();
2554 -
2555 - // Empty feed -> nothing to reconcile; never delete on an empty response.
2556 - if (empty($listingKey_in_MLS)) {
2557 - return;
2558 - }
2559 -
2560 - // Sanity guard against a well-formed but truncated feed. Reconciliation
2561 - // deletes every local listing missing from this list, so a short feed
2562 - // would become a mass deletion. Compare the feed size against the local
2563 - // ListingKey count (same status filter as the delete loop below) and bail
2564 - // if the feed is implausibly small. Log both numbers either way so a
2565 - // future incident is diagnosable.
2566 - $feed_count = count($listingKey_in_MLS);
2567 - $local_count = (int) $wpdb->get_var(
2568 - $wpdb->prepare(
2569 - "SELECT COUNT(*)
2570 - FROM {$wpdb->posts} p
2571 - INNER JOIN {$wpdb->postmeta} pm
2572 - ON p.ID = pm.post_id AND pm.meta_key = %s
2573 - WHERE p.post_status NOT IN ('draft', 'trash')",
2574 - 'ListingKey'
2575 - )
2576 - );
2577 -
2578 - error_log(sprintf('MLSimport reconciliation: feed=%d local=%d', $feed_count, $local_count));
2579 -
2580 - if (!mlsimport_reconciliation_feed_is_plausible($feed_count, $local_count)) {
2581 - error_log(sprintf(
2582 - 'MLSimport reconciliation ABORTED: feed of %d is below %d%% of %d local listings; no deletions performed.',
2583 - $feed_count,
2584 - (int) round(MLSIMPORT_RECONCILIATION_MIN_FEED_FRACTION * 100),
2585 - $local_count
2586 - ));
2587 - return;
2588 - }
2589 -
2590 - // Flip so isset($listingKey_in_MLS[$key]) is an O(1) membership test.
2591 - // Flip for fast lookup
2592 - $listingKey_in_MLS = array_flip($listingKey_in_MLS);
2593 -
2594 - // Batch fetch local listings
2595 - $batch = 1000;
2596 - $offset = 0;
2597 - $to_delete = 0;
2598 - $counter = 0;
2599 -
2600 - // One-query preload of status/protect meta for every Import Task, keyed by id.
2601 - $mlsimport_preload_all_mls_item_status_meta = $this->mlsimport_preload_all_mls_item_status_meta();
2602 - //print_r($mlsimport_preload_all_mls_item_status_meta);
2603 -
2604 - // Page through all local listings 1000 at a time.
2605 - do {
2606 - $local = $wpdb->get_results(
2607 - $wpdb->prepare(
2608 - "SELECT
2609 - p.ID,
2610 - listingkey_meta.meta_value AS listingkey,
2611 - inserted_meta.meta_value AS mlsimport_item_inserted
2612 - FROM {$wpdb->posts} p
2613 - INNER JOIN {$wpdb->postmeta} listingkey_meta
2614 - ON p.ID = listingkey_meta.post_id
2615 - AND listingkey_meta.meta_key = %s
2616 - LEFT JOIN {$wpdb->postmeta} inserted_meta
2617 - ON p.ID = inserted_meta.post_id
2618 - AND inserted_meta.meta_key = %s
2619 - WHERE p.post_status NOT IN ('draft', 'trash')
2620 - LIMIT %d OFFSET %d",
2621 - 'ListingKey',
2622 - 'MLSimport_item_inserted',
2623 - $batch,
2624 - $offset
2625 - ),
2626 - ARRAY_A
2627 - );
2628 -
2629 - $count = count($local);
2630 -
2631 -
2632 - // Decide keep-vs-delete for each local listing in this page.
2633 - foreach ($local as $item) {
2634 - $listingkey = $item['listingkey']; // not 'meta_value' anymore
2635 - $property_id = $item['ID'];
2636 - $mlsimportItemId = $item['mlsimport_item_inserted'];
2637 - ++$counter;
2638 - // IN MLS
2639 - if (isset($listingKey_in_MLS[$listingkey])) {
2640 -
2641 - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
2642 - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
2643 - $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null;
2644 - } else {
2645 - $mlsimport_item_standardstatus = null;
2646 - $mlsimport_item_standardstatusprotect = null;
2647 - }
2648 -
2649 - // Still in the feed: delete only if its status rules say so.
2650 - $keep_when_in_mls = $mlsimport->admin->theme_importer->check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect);
2651 - if (!$keep_when_in_mls) {
2652 - ++$to_delete;
2653 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
2654 - }
2655 - } else {
2656 - // NOT IN MLS
2657 -
2658 - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
2659 - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
2660 - $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null;
2661 - } else {
2662 - $mlsimport_item_standardstatus = null;
2663 - $mlsimport_item_standardstatusprotect = null;
2664 - }
2665 - // Gone from the feed: delete unless a protected status keeps it.
2666 - $keep = $mlsimport->admin->theme_importer->check_if_delete_when_status($property_id, $mlsimport_item_standardstatus, null, $mlsimport_item_standardstatusprotect);
2667 - if (!$keep) {
2668 - ++$to_delete;
2669 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
2670 - }
2671 - }
2672 -
2673 - // Memory housekeeping
2674 - unset($listingkey, $property_id, $mlsimportItemId, $mlsImportItemStatus, $mlsimport_item_standardstatusprotect, $keep_when_in_mls, $keep);
2675 - if ($counter % 250 == 0) {
2676 - gc_collect_cycles();
2677 - }
2678 - }
2679 -
2680 - unset($local);
2681 - gc_collect_cycles();
2682 -
2683 - // Advance; loop until a short page signals the last batch.
2684 - $offset += $batch;
2685 - } while ($count === $batch);
2686 -
2687 -
2688 - print esc_html(' to delete:' . $to_delete);
2689 -
2690 - // Final cleanup
2691 - unset($listingKey_in_MLS);
2692 - gc_collect_cycles();
2693 -
2694 -
2695 - return;
2542 + // Backward-compatible entry point for callers outside the cron hook. The
2543 + // complete destructive decision path lives behind the deep module seam,
2544 + // sequenced per connection by the runner.
2545 + return mlsimport_reconciliation_run_connections();
2696 2546 }
2697 2547
2698 -
2699 -
2700 -/**
2701 - * Preload all status meta for ALL mlsimport_item posts in ONE QUERY.
2702 - * Returns: [mlsimport_item_id => ['mlsimport_item_standardstatus' => ..., 'mlsimport_item_standardstatusprotect' => ...], ...]
2703 - *
2704 - * Avoids per-listing get_post_meta() calls during reconciliation.
2705 - *
2706 - * @return array<int,array<string,mixed>>
2707 - */
2708 -function mlsimport_preload_all_mls_item_status_meta() {
2709 - global $wpdb;
2710 -
2711 - // Fetch the status + protect meta rows for every Import Task in one pass.
2712 - $sql = "
2713 - SELECT p.ID as post_id, pm.meta_key, pm.meta_value
2714 - FROM {$wpdb->posts} p
2715 - LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
2716 - WHERE p.post_type = 'mlsimport_item'
2717 - AND pm.meta_key IN ('mlsimport_item_standardstatus', 'mlsimport_item_standardstatusprotect')
2718 - ";
2719 -
2720 - $rows = $wpdb->get_results($sql);
2721 -
2722 - // Index by post id; unserialize each stored value.
2723 - $meta = [];
2724 - foreach ($rows as $row) {
2725 - if (!isset($meta[$row->post_id])) {
2726 - $meta[$row->post_id] = [
2727 - 'mlsimport_item_standardstatus' => null,
2728 - 'mlsimport_item_standardstatusprotect' => null
2729 - ];
2730 - }
2731 - $meta[$row->post_id][$row->meta_key] = maybe_unserialize($row->meta_value);
2732 - }
2733 - return $meta;
2734 -}
2735 -
2736 -
2737 -
2738 2548 /**
2739 2549 * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
2740 2550 *
2551 + * A positive mls_id scopes the request to one connection (#279):
2552 + * GET reconciliation?mls_id=X, whose response must echo the mls_id back
2553 + * before the caller may use it (the #276 echo guard). With 0 (default)
2554 + * the request stays the legacy unscoped account snapshot.
2555 + *
2556 + * @param int $mls_id Connection to scope the snapshot to; 0 = unscoped.
2741 2557 * @return array The API response, expected to carry an 'all_data' key.
2742 2558 */
2743 - public function mlsimport_saas_get_mls_reconciliation_data() {
2559 + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) {
2744 2560
2745 - // GET /reconciliation with no arguments.
2746 - $arguments = array();
2747 - $answer = $this->theme_importer->globalApiRequestCurlSaas( 'reconciliation', $arguments, 'GET' );
2561 + // GET /reconciliation, query-scoped to one connection when requested.
2562 + $method = 'reconciliation' . ( (int) $mls_id > 0 ? '?mls_id=' . (int) $mls_id : '' );
2563 + $answer = $this->theme_importer->globalApiRequestCurlSaas( $method, array(), 'GET' );
2748 2564 return $answer;
2749 2565 }
2750 2566
2751 2567 /**
@@ -2789,32 +2605,19 @@
2789 2605 * @param bool $is_hourly_sync Whether this call is from the hourly cron.
2790 2606 * @return array The (normalized) API response.
2791 2607 */
2792 2608 public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2793 - // Resolve the configured MLS id (drives provider-specific validation).
2794 - $options = get_option( $this->plugin_name . '_admin_options' );
2795 - $mls_id = '';
2796 - if ( isset( $options['mlsimport_mls_name'] ) ) {
2797 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
2798 - }
2799 -
2800 2609 // Build the full RESO query argument set from the task's meta.
2801 2610 $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top, $is_hourly_sync );
2802 2611
2803 -
2804 - // Rapattoni (5000-5999) requires a property_type; bail with a clear
2805 - // message when it is unset/empty (checks the scalar and first element).
2806 - if (
2807 - $mls_id > 5000 && $mls_id < 6000 &&
2808 - ( ! isset( $arguments['property_type'] ) or
2809 - ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or
2810 - ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] )
2811 - )
2812 - ) {
2612 + // The Provider Family module returns a private marker when its request
2613 + // rules reject the inputs. Convert that into the existing public error shape.
2614 + if ( isset( $arguments['mlsimport_provider_error'] ) ) {
2615 + $error = $arguments['mlsimport_provider_error'];
2813 2616 return array(
2814 2617 'success' => false,
2815 - 'type' => 'rapattoni',
2816 - 'message' => esc_html__( 'This MLS requires to have one item selected from "Property Action Category" dropdown', 'mlsimport' ),
2618 + 'type' => isset( $error['code'] ) ? $error['code'] : 'provider_error',
2619 + 'message' => isset( $error['message'] ) ? $error['message'] : esc_html__( 'The MLS request could not be prepared.', 'mlsimport' ),
2817 2620 );
2818 2621 }
2819 2622
2820 2623 // Guard against an over-long query string (too many parameters selected).
@@ -2826,9 +2629,20 @@
2826 2629 'message' => esc_html__( 'You have too many parameters selected. Split the import beween multiple MLS Import Tasks: For ex : Import per County instead of selecting 10 cities or import listing between certain price range.', 'mlsimport' ),
2827 2630 );
2828 2631 }
2829 2632
2830 - //print_r($arguments);
2633 + // A connection the SaaS rejected with the stable not_entitled code skips
2634 + // its imports until it is re-entitled (#276) — no request is sent, the
2635 + // caller gets a visible failure, and every other connection is unaffected.
2636 + if ( is_array( $arguments ) && mlsimport_connection_not_entitled( (int) ( $arguments['mls_id'] ?? 0 ) ) ) {
2637 + return array(
2638 + 'success' => false,
2639 + 'type' => 'not_entitled',
2640 + 'message' => esc_html__( 'Your account is not entitled to this MLS. Imports for it are paused.', 'mlsimport' ),
2641 + );
2642 + }
2643 +
2644 + //print_r($arguments);
2831 2645 //print '----------------------------'.PHP_EOL;
2832 2646 // POST the query to the SaaS 'listings' endpoint.
2833 2647 $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
2834 2648
@@ -2841,8 +2655,15 @@
2841 2655 'message' => is_string( $answer ) ? $answer : esc_html__( 'The request to the MLS could not be completed.', 'mlsimport' ),
2842 2656 );
2843 2657 }
2844 2658
2659 + // The server rejected this mls_id against the account's entitlements
2660 + // (#276): mark this one connection so its later imports skip. The error
2661 + // response itself still flows back to the caller — never a silent fallback.
2662 + if ( mlsimport_response_not_entitled( $answer ) && is_array( $arguments ) ) {
2663 + mlsimport_mark_connection_not_entitled( (int) ( $arguments['mls_id'] ?? 0 ) );
2664 + }
2665 +
2845 2666 // Echo the computed argument length back on the response for diagnostics.
2846 2667 $answer['potential_leght'] = $potential_leght;
2847 2668
2848 2669 // Record the pre-filter MLS feed count for telemetry. Every import path
@@ -2851,8 +2672,15 @@
2851 2672 if ( isset( $answer['results'] ) ) {
2852 2673 mlsimport_telemetry_set( 'last_feed_found', (int) $answer['results'] );
2853 2674 }
2854 2675
2676 + // Record the request outcome into sync-health telemetry (issue #207):
2677 + // success stamps last_sync_success; failure stamps last_sync_failed plus
2678 + // a real failure class instead of the former always-"unknown" code.
2679 + // The pull's connection id (#283) scopes the per-connection stamps and
2680 + // the syncs counter — it is the task's bound mls_id from the arguments.
2681 + mlsimport_telemetry_record_sync_result( $answer, (int) ( $arguments['mls_id'] ?? 0 ) );
2682 +
2855 2683 return ( $answer );
2856 2684 }
2857 2685
2858 2686
@@ -2878,17 +2706,19 @@
2878 2706 * @return array|string The argument array, or '' when core options are missing.
2879 2707 */
2880 2708 public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2881 2709
2882 - // MLS id is mandatory.
2710 + // MLS id is mandatory — resolved through the TASK's own connection
2711 + // binding (#277), never the global selection. Unstamped legacy tasks
2712 + // fall back to the current connection inside the resolver.
2883 2713 $options = get_option( $this->plugin_name . '_admin_options' );
2884 - if ( isset( $options['mlsimport_mls_name'] ) ) {
2885 - $mls_id = intval( $options['mlsimport_mls_name'] );
2886 - } else {
2714 + $mls_id = mlsimport_task_mls_id( (int) $item_id );
2715 + if ( $mls_id <= 0 ) {
2887 2716 return '';
2888 2717 }
2889 2718
2890 - // Theme id is mandatory (selects the server-side field schema).
2719 + // Theme id is mandatory (selects the server-side field schema; the
2720 + // theme schema is GLOBAL per decision #263, so this stays flat).
2891 2721 if ( isset( $options['mlsimport_theme_used'] ) ) {
2892 2722 $theme_id = intval( $options['mlsimport_theme_used'] );
2893 2723 } else {
2894 2724 return '';
@@ -2934,12 +2764,11 @@
2934 2764 $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values );
2935 2765
2936 2766 // add status
2937 2767
2938 - // Edmonton (111) has no StandardStatus field, so skip it there.
2939 - if ( 111 !== $mls_id ) { // edmonton check
2940 - $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
2941 - }
2768 + // Add the shared status input. The active adapter removes it when that MLS
2769 + // has no status field, keeping the exception out of this request builder.
2770 + $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
2942 2771
2943 2772 // add property_subtype
2944 2773 $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values );
2945 2774
@@ -2945,21 +2774,8 @@
2945 2774
2946 2775 // add property_type
2947 2776 $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values );
2948 2777
2949 - // Rapattoni exception: property_type must be a single, space-stripped,
2950 - // single-element array rather than the multi-select list.
2951 - // rapattoni exception
2952 - if ( $mls_id > 5000 &&
2953 - ( isset($values['property_type']) && $values['property_type'] !='' ) ) {
2954 -
2955 - $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values );
2956 - $temp = $values['property_type'];
2957 - $temp = str_replace( ' ', '', $temp );
2958 - $values['property_type'] = array();
2959 - $values['property_type'][] = $temp;
2960 - }
2961 -
2962 2778 // add internet_entirelisting_displayyn
2963 2779 $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values );
2964 2780
2965 2781 // add internet_address_displayyn
@@ -2978,8 +2794,12 @@
2978 2794
2979 2795 // add ListingId
2980 2796 $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values );
2981 2797
2798 + // add ListingKey - single-property filter for MLS APIs (e.g. PropTx/AMPRE)
2799 + // that do not support filtering by ListingId (GitHub issue #198).
2800 + $values = $this->mls_import_saas_add_to_parms_input( 'ListingKey', $item_id, 'listingkey', $values );
2801 +
2982 2802 //add Exclude_ListOfficeKey
2983 2803 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values );
2984 2804 // add Exclude_ListOfficeMlsId
2985 2805 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values );
@@ -2993,27 +2813,26 @@
2993 2813 // add CustomParameters
2994 2814 $values = $this->mls_import_saas_add_to_parms_input( 'CustomParameters', $item_id, 'custom_parameters', $values );
2995 2815
2996 2816
2997 - // Realtor.ca (7000-7999) expects an ISO UTC timestamp with seconds/Z.
2998 - // if we have realtorca
2999 - if ($mls_id >= 7000 && $mls_id < 8000 && $last_date!=='') {
3000 - $dateTime_realtorca = new DateTime($last_date, new DateTimeZone('UTC'));
3001 - // Format with seconds and UTC timezone marker
3002 - $last_date = $dateTime_realtorca->format('Y-m-d\TH:i:s.000\Z');
2817 + // Hand only provider-specific request preparation to the active adapter.
2818 + // The task's connection record carries its own provider type (#277); the
2819 + // single-slot saved type / numeric map covers legacy configurations only.
2820 + $mlsimport_connection_record = Mlsimport_Connections::get( $mls_id );
2821 + $saved_type = null !== $mlsimport_connection_record && '' !== $mlsimport_connection_record['provider_type']
2822 + ? $mlsimport_connection_record['provider_type']
2823 + : Mlsimport_Provider_Family::saved_type( $mls_id );
2824 + $provider = Mlsimport_Provider_Family::adapter( $saved_type, $mls_id, $this->theme_importer );
2825 + if ( ! $provider->supported() ) {
2826 + return array( 'mlsimport_provider_error' => $provider->error() );
3003 2827 }
3004 2828
3005 - // PropTx / AMPRE requires a full OData DateTimeOffset literal for the ModificationTimestamp filter.
3006 - if ( mlsimport_is_proptx_provider( $mls_id ) && $last_date !== '' ) {
3007 - $last_date = mlsimport_format_odata_modification_time( $last_date );
2829 + $prepared = $provider->prepare_stored_request( $values, $last_date );
2830 + if ( ! $prepared['success'] ) {
2831 + return array( 'mlsimport_provider_error' => $prepared['error'] );
3008 2832 }
3009 2833
3010 - // Attach the (possibly reformatted) modification-time watermark.
3011 - if ( '' !== $last_date ) {
3012 - $values['modification_time'] = $last_date;
3013 - }
3014 -
3015 - return( $values );
2834 + return $prepared['arguments'];
3016 2835 }
3017 2836
3018 2837
3019 2838
@@ -3127,14 +2946,16 @@
3127 2946 * definition array the metabox renders from. Falls back StandardStatus to
3128 2947 * MlsStatus when the MLS has no StandardStatus enum. Emits a warning when no
3129 2948 * metadata has been fetched yet.
3130 2949 *
2950 + * @param int $mls_id Connection whose enums to read (#277); 0 = current.
3131 2951 * @return array Field key => definition (label, description, type, multiple, values).
3132 2952 */
3133 - public function mlsimport_saas_return_mls_fields() {
2953 + public function mlsimport_saas_return_mls_fields( int $mls_id = 0 ) {
3134 2954
3135 - // Saved MLS enum metadata (JSON); empty until fields have been fetched.
3136 - $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' );
2955 + // Saved MLS enum metadata (JSON) for the requested connection (#275/
2956 + // #277); empty until fields have been fetched.
2957 + $mlsimport_mls_metadata_mls_enums = mlsimport_get_connection_option( 'mlsimport_mls_metadata_mls_enums', '', $mls_id );
3137 2958
3138 2959 // Warn the user when no metadata is available yet.
3139 2960 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
3140 2961 ?>
@@ -3334,8 +3155,14 @@
3334 3155 'description' => esc_html__( 'Import One Property Only via parameter ListingID. If this does not work for you, please contact us to check if the field exists in your MLS.', 'mlsimport'),
3335 3156 'type' => 'input',
3336 3157 'multiple' => 'no',
3337 3158 ),
3159 + 'ListingKey' => array(
3160 + 'label' => esc_html__( 'ListingKey', 'mlsimport' ),
3161 + 'description' => esc_html__( 'Import One Property Only via parameter ListingKey. Use this when your MLS does not support filtering by ListingId (for example PropTx/AMPRE).', 'mlsimport'),
3162 + 'type' => 'input',
3163 + 'multiple' => 'no',
3164 + ),
3338 3165 'Exclude_ListOfficeMlsId' => array(
3339 3166 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ),
3340 3167 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ),
3341 3168 'type' => 'input',
@@ -3389,374 +3216,196 @@
3389 3216 *
3390 3217 * @return void Emits JSON.
3391 3218 */
3392 3219 public function mlsimport_move_files_per_item() {
3393 - // CSRF.
3394 3220 check_ajax_referer( 'mlsimport_item_actions', 'security' );
3395 - // Read + default the task id and count inputs.
3396 - $post_id = 0;
3397 - $how_many = 0;
3398 - $max_number = 0;
3399 - if(isset( $_POST['post_id'] )){
3400 - $post_id = intval( $_POST['post_id'] );
3401 - }
3402 - if(isset( $_POST['how_many'] )){
3403 - $how_many = intval( $_POST['how_many'] );
3404 - }
3405 - if(isset( $_POST['post_number'] )){
3406 - $max_number = intval( $_POST['post_number'] );
3407 - }
3408 3221
3409 - // Admin boundary: the target must be an Import Task the user can edit.
3222 + $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
3223 + $how_many = isset( $_POST['how_many'] ) ? intval( $_POST['how_many'] ) : 0;
3224 + $max_number = isset( $_POST['post_number'] ) ? intval( $_POST['post_number'] ) : 0;
3225 + $is_onboard = isset( $_POST['is_onboard'] ) ? intval( $_POST['is_onboard'] ) : 0;
3226 +
3227 + // Reject the request before the shared runner or any task state changes.
3410 3228 if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
3411 3229 wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 );
3412 3230 }
3413 3231
3414 - // Whether the request comes from the onboarding wizard.
3415 - $is_onboard=intval($_POST['is_onboard']);
3416 -
3417 - // Clear any prior stop flag so this run proceeds (autoload off).
3418 - update_option( 'mlsimport_force_stop_' . $post_id, 'no', false );
3419 -
3420 - $item_id_array = array(
3421 - 'item_id' => $post_id,
3422 - 'how_many' => $how_many,
3423 - 'max_number' => $max_number,
3424 - 'batch_counter' => 1,
3232 + // The page already counted the matching listings. Keep that exact count
3233 + // and selected limit so the background worker does not count them again.
3234 + $start = $this->mlsimport_import_task_execution()->start(
3235 + array(
3236 + 'task_id' => $post_id,
3237 + 'source' => 'manual',
3238 + 'found' => $max_number,
3239 + 'limit' => $how_many,
3240 + 'is_onboard' => $is_onboard,
3241 + )
3425 3242 );
3243 + if ( true !== ( $start['accepted'] ?? false ) ) {
3244 + wp_send_json(
3245 + array(
3246 + 'success' => false,
3247 + 'reason' => (string) ( $start['reason'] ?? 'already_running' ),
3248 + 'message' => esc_html__( 'Another import is already running. Please wait for it to finish.', 'mlsimport' ),
3249 + )
3250 + );
3251 + }
3426 3252
3253 + mlsimport_saas_single_write_import_custom_logs( 'Manual import queued for task ' . $post_id . '.' . PHP_EOL, 'manual' );
3254 + mlsimport_debuglogs_per_plugin( 'Manual import queued for task ' . $post_id . '.' . PHP_EOL );
3427 3255
3428 - // Clear any stale batch, then build the fresh batch of requests.
3429 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' );
3256 + $this->mlsimport_enqueue_import_worker( (string) $start['run_id'] );
3430 3257
3431 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array );
3432 -
3433 - // If an error was returned from the API, sanitize and send it back to the client and stop further processing.
3434 - if ( isset( $attachments_to_move['success'] ) && false === $attachments_to_move['success'] ) {
3435 - if ( isset( $attachments_to_move['message'] ) ) {
3436 - $attachments_to_move['message'] = wp_strip_all_tags( $attachments_to_move['message'] );
3437 - }
3438 - wp_send_json( $attachments_to_move );
3439 - wp_die();
3440 - }
3441 -
3442 - // Persist the batch for the background worker to consume.
3443 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move );
3444 -
3445 - // net stat data
3446 - // Reset progress counters shown in the UI.
3447 - update_post_meta( $post_id, 'mlsimport_progress_properties', 0 );
3448 - update_post_meta( $post_id, 'mlsimport_progress_batches', 0 );
3449 - update_post_meta( $post_id, 'mlsimport_progress_memory', 0 );
3450 -
3451 -
3452 -
3453 - $attachments_to_send = array(
3454 - 'args' => array(
3455 - 'attachments_to_move' => $post_id,
3456 - 'item_id_array' => $item_id_array,
3457 - 'is_onboard' =>$is_onboard,
3458 - ),
3258 + wp_send_json(
3259 + array(
3260 + 'success' => true,
3261 + 'run_id' => (string) $start['run_id'],
3262 + )
3459 3263 );
3264 + }
3460 3265
3461 - mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL );
3462 - mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL );
3463 -
3464 - // Mark the task started, then enqueue the async import job.
3465 - update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' );
3466 -
3467 - // old
3468 - as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send );
3469 -
3470 - // Remove any pending async jobs for this item and enqueue a unique one
3471 - //bad ideea
3472 - // as_unschedule_all_actions( 'mlsimport_background_process_per_item', $attachments_to_send );
3473 - //as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send, '', true );
3474 -
3475 -
3476 - // Nudge WP-Cron so the queued action runs promptly.
3477 - spawn_cron();
3478 -
3479 - unset( $attachments_to_send );
3480 -
3481 - // Return success response to the AJAX caller.
3482 - wp_send_json( array( 'success' => true ) );
3483 - }
3484 -
3485 3266 /**
3486 - * Process MLS Import attachments via background cron.
3487 - * Memory-optimized with detailed memory usage logging.
3488 - *
3489 - * @param array $input_arg
3490 - * @return void
3491 - */
3492 - public function mlsimport_background_process_per_item_cron_function( $input_arg ) {
3493 - global $mlsimport;
3494 -
3495 - $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL;
3496 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3497 - mlsimport_saas_single_write_import_custom_logs( '[Memory] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
3498 -
3499 - // Load attachments to move from post meta
3500 - $attachments_to_move = get_post_meta(
3501 - $input_arg['item_id_array']['item_id'],
3502 - 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'],
3503 - true
3504 - );
3505 - $log = '[Memory] After loading attachments: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3506 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3507 -
3508 - // Run each request batch: call the API and parse the results, freeing
3509 - // memory between iterations.
3510 - if (!empty($attachments_to_move) && is_array($attachments_to_move)) {
3511 - foreach ($attachments_to_move as $key => $import_arguments) {
3512 - // Optionally clear any cache for this batch
3513 - if ( isset($GLOBALS['wp_object_cache']) ) {
3514 - $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
3515 - }
3516 -
3517 - $log = '[Memory] Before API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3518 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3519 -
3520 - // API call
3521 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas('listings', $import_arguments, 'POST');
3522 -
3523 - $log = '[Memory] After API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3524 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3525 -
3526 - // Parse/process response
3527 - $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem(
3528 - $api_call_array, $input_arg['item_id_array'], $key
3529 - );
3530 -
3531 - // Free per-iteration memory
3532 - unset($api_call_array, $import_arguments);
3533 - gc_collect_cycles();
3534 -
3535 - $log = '[Memory] After cleanup batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
3536 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
3537 - }
3538 - }
3539 -
3540 - mlsimport_saas_single_write_import_custom_logs('[Memory] End: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
3541 - mlsimport_saas_single_write_import_custom_logs('CRON JOB Import Completed ' . PHP_EOL, 'cron');
3542 - mlsimport_debuglogs_per_plugin('CRON JOB Import Completed ' . PHP_EOL);
3543 - update_post_meta($input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed');
3544 -
3545 - unset($attachments_to_move, $input_arg, $log);
3546 - gc_collect_cycles();
3547 - }
3548 -
3549 -
3550 -
3551 -
3552 - /**
3553 - * Build the paginated list of request-argument sets for an import run.
3267 + * Queue the background import worker for an accepted Import Run.
3554 3268 *
3555 - * Resolves how many listings to fetch (0 => all found, capped at max_found
3556 - * and a hard 10000 ceiling), then produces one argument set per page of 25.
3557 - * Propagates an API error set immediately instead of a batch list.
3269 + * Called only after start() accepted the run, which means this request now
3270 + * holds the one site-wide run lock. Therefore every worker action already
3271 + * sitting in Action Scheduler belongs to a dead or replaced run: an
3272 + * in-progress action is a worker that died mid-run (a crash, a memory
3273 + * fatal), and its leftover claim blocks Action Scheduler — which runs one
3274 + * claim at a time — from ever dispatching a new worker; a pending action is
3275 + * a superseded start that would only wake, discover it lost the lock, and
3276 + * exit. Both are cleared here so the queue always self-heals on client
3277 + * sites, with no manual database intervention.
3558 3278 *
3559 - * @param array $item_id_array {item_id, how_many, max_number, batch_counter}.
3560 - * @param string $last_date Modification-time watermark (optional).
3561 - * @param bool $is_hourly_sync Whether this is an hourly-cron run.
3562 - * @return array Array of argument sets, or an API error array.
3279 + * @param string $run_id Accepted run identity to hand to the worker.
3280 + * @return void
3563 3281 */
3564 - public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '', $is_hourly_sync = false ) {
3565 - // Page size for each batched listings request.
3566 - $import_step = 25;
3567 -
3568 - // Resolve the effective count: 0 means "all found".
3569 - $prop_id = $item_id_array['item_id'];
3570 - $max_found = $item_id_array['max_number'];
3571 - $how_many = $item_id_array['how_many'];
3572 - if ( 0=== intval($how_many) ) {
3573 - $how_many = $max_found;
3282 + public function mlsimport_enqueue_import_worker( string $run_id ): void {
3283 + try {
3284 + $store = ActionScheduler::store();
3285 + $dead = $store->query_actions(
3286 + array(
3287 + 'hook' => 'mlsimport_background_process_per_item',
3288 + 'status' => ActionScheduler_Store::STATUS_RUNNING,
3289 + 'per_page' => 20,
3290 + )
3291 + );
3292 + foreach ( $dead as $dead_action_id ) {
3293 + $store->mark_failure( $dead_action_id );
3294 + mlsimport_debuglogs_per_plugin( 'Failed dead import worker action ' . $dead_action_id . ' before queueing a new worker.' . PHP_EOL );
3295 + }
3296 + as_unschedule_all_actions( 'mlsimport_background_process_per_item' );
3297 + } catch ( Throwable $exception ) {
3298 + // Queue cleanup must never block starting the new worker.
3299 + mlsimport_debuglogs_per_plugin( 'Import queue cleanup failed: ' . $exception->getMessage() . PHP_EOL );
3574 3300 }
3575 - // Never request more than actually exist.
3576 - if ( $how_many > $max_found ) {
3577 - $how_many = $max_found;
3578 - }
3579 3301
3580 - $search_url_step = '';
3581 - $urls_array = array();
3302 + // Only the small run identity crosses the HTTP/background boundary. The
3303 + // runner reads the request and progress from WordPress when it wakes.
3304 + as_enqueue_async_action(
3305 + 'mlsimport_background_process_per_item',
3306 + array( 'args' => array( 'run_id' => $run_id ) )
3307 + );
3308 + spawn_cron();
3309 + }
3582 3310
3583 - // Hard ceiling of 10000 per task; record the planned total for progress.
3584 - $skip = 0;
3585 - if ( $how_many > 10000 ) {
3586 - $how_many = 10000;
3587 - }
3588 - update_post_meta($prop_id,'mlsimport_task_to_import', intval($how_many) );
3311 + /**
3312 + * Return the adapter configuration error that blocks Stored mode imports.
3313 + *
3314 + * @return string Empty when a supported adapter was composed.
3315 + */
3316 + public function mlsimport_stored_listing_configuration_error(): string {
3317 + return $this->stored_listing_configuration_error;
3318 + }
3589 3319
3590 - // Shrink the page size if fewer than one page remain.
3591 - if ( $how_many < $import_step ) {
3592 - $import_step = $how_many;
3593 - }
3594 3320
3595 - // Emit one argument set per page until the target count is reached.
3596 - while ( $skip < $how_many ) {
3597 3321
3598 - // Determine how many items to request for this batch.
3599 - $batch_step = min( $import_step, $how_many - $skip );
3600 3322
3601 - // Build the request arguments using the remaining count.
3602 - $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $batch_step, $is_hourly_sync );
3603 3323
3604 - // If the API returned an error, propagate it immediately.
3605 - if ( isset( $search_url_step['success'] ) && false === $search_url_step['success'] ) {
3606 - return $search_url_step;
3607 - }
3608 3324
3609 - $skip += $batch_step;
3610 - $urls_array[] = $search_url_step;
3611 3325
3612 3326
3613 - }
3614 - return $urls_array;
3615 - }
3616 3327
3617 3328
3618 3329
3619 3330
3620 -
3621 -
3622 -
3623 3331 /**
3624 - * Action Scheduler worker: run a manual import's batches for one task.
3332 + * Action Scheduler adapter for the shared Import Task runner.
3625 3333 *
3626 - * Loads the pre-built batch and the task's option meta once, then iterates
3627 - * each batch — calling the listings API and parsing results into posts —
3628 - * while honoring the force-stop flag and flushing memory between batches.
3629 - * Marks the task 'completed' and clears the batch meta when finished.
3334 + * The queue carries only a run id. Fetching, saving, stopping, progress, and
3335 + * completion all remain inside the shared execution module used by cron too.
3630 3336 *
3631 - * @param array $input_arg {item_id_array:{item_id,...}, ...}.
3337 + * @param array|string $input_arg Run payload, or the run id for direct callers.
3632 3338 * @return void
3633 3339 */
3634 3340 public function mlsimport_background_process_per_item_function( $input_arg ) {
3635 -
3636 - // Task id + a log prefix identifying it.
3637 - $mlsimportItemId = $input_arg['item_id_array']['item_id'];
3638 - $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> ';
3639 - mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL );
3640 -
3641 -
3642 - // Get from MLS Import the big argument array only once
3643 - $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true );
3644 -
3645 - // No batch payload: the meta is '' (stale clear, failed start, or already
3646 - // deleted by a completed run). count()/foreach on a string throws TypeError
3647 - // on PHP 8, so bail out and release the task instead.
3648 - if ( ! is_array( $attachments_to_move ) || empty( $attachments_to_move ) ) {
3649 - mlsimport_saas_single_write_import_custom_logs( $log_prefix . 'No batch payload found - nothing to import.' . PHP_EOL );
3650 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3341 + $run_id = is_array( $input_arg ) ? (string) ( $input_arg['run_id'] ?? '' ) : (string) $input_arg;
3342 + if ( '' === $run_id ) {
3343 + mlsimport_saas_single_write_import_custom_logs( 'Import worker received no run id.' . PHP_EOL, 'manual' );
3651 3344 return;
3652 3345 }
3653 3346
3654 - // Retrieve all meta data in one go to reduce database queries
3655 - $mlsimport_item_option_data = array(
3656 - 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ),
3657 - 'mlsimport_item_standardstatusprotect' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusprotect', true ),
3658 - 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ),
3659 - 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ),
3660 - 'mlsimport_item_use_mls_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_use_mls_agent', true ),
3661 - 'mlsimport_item_property_status' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_status', true ),
3347 + // A host-killed worker dies without any PHP-level trace: the fatal only
3348 + // lands in the server error log the administrator may not have. This
3349 + // shutdown hook writes the real cause (timeout, memory, fatal) into the
3350 + // plugin's own import log, so one failed run is enough to diagnose.
3351 + register_shutdown_function(
3352 + static function () use ( $run_id ) {
3353 + $last_error = error_get_last();
3354 + if ( null === $last_error
3355 + || ! in_array( $last_error['type'], array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ), true ) ) {
3356 + return;
3357 + }
3358 + mlsimport_saas_single_write_import_custom_logs(
3359 + 'Import worker FATAL (run ' . substr( $run_id, 0, 8 ) . '): ' . $last_error['message']
3360 + . ' in ' . $last_error['file'] . ':' . $last_error['line']
3361 + . '. Memory ' . round( memory_get_usage( true ) / 1048576 ) . 'MB, peak '
3362 + . round( memory_get_peak_usage( true ) / 1048576 ) . 'MB.' . PHP_EOL,
3363 + 'manual'
3364 + );
3365 + }
3662 3366 );
3367 + $worker_started_at = microtime( true );
3368 + mlsimport_saas_single_write_import_custom_logs(
3369 + 'Import worker start (run ' . substr( $run_id, 0, 8 ) . '). Memory '
3370 + . round( memory_get_usage( true ) / 1048576 ) . 'MB.' . PHP_EOL,
3371 + 'manual'
3372 + );
3663 3373
3664 - $total_batches = count( $attachments_to_move );
3665 -
3666 - // removed because $this
3667 - global $mlsimport;
3668 -
3669 - $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
3670 - mlsimport_saas_single_write_import_custom_logs( $log );
3671 -
3672 -
3673 - // Process each batch unless the user has requested a stop.
3674 - foreach ( $attachments_to_move as $key => $import_arguments ) {
3675 - // reconsider use
3676 - // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
3677 - // Re-read the stop flag each batch so a stop takes effect mid-run.
3678 - $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId );
3679 - if ( 'no' === $status ) {
3680 - // Clear memory before processing each batch
3681 - wp_cache_flush();
3682 - gc_collect_cycles();
3683 -
3684 - // wp_cache_flush();
3685 - $mem_usage = memory_get_usage( true );
3686 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
3687 -
3688 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $key + 1 );
3689 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $mem_usage_show );
3690 -
3691 -
3692 -
3693 - mlsimport_saas_single_write_import_custom_logs( $log );
3694 - $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL;
3695 -
3696 -
3697 - // Combine logs and reduce function calls
3698 - mlsimport_saas_single_write_import_custom_logs( $log );
3699 - mlsimport_debuglogs_per_plugin( $log );
3700 - print esc_html($log);
3701 -
3702 - // Fetch this batch of listings and turn them into posts.
3703 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
3704 -
3705 - $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data );
3706 -
3707 -
3708 -
3709 - // Explicitly unset large variables after each batch
3710 - unset($api_call_array);
3711 -
3712 - // Force garbage collection again after processing
3713 - wp_cache_flush();
3714 - gc_collect_cycles();
3715 -
3716 -
3717 - // Add a small delay to allow memory to be freed
3718 - if (($key + 1) < $total_batches) {
3719 - usleep(100000); // 100ms pause between batches
3720 - }
3721 -
3722 - } else {
3723 -
3724 - // Force-stop requested: finalize as completed and break out.
3725 - $final_mem_usage = memory_get_usage( true );
3726 - $final_mem_usage_show = round( $final_mem_usage / 1048576, 2 );
3727 -
3728 -
3729 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3730 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
3731 -
3732 - //new stats
3733 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $total_batches );
3734 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $final_mem_usage_show );
3735 -
3736 -
3737 - mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' );
3738 - mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' );
3739 - break; // Exit the loop if forced to stop
3740 - }
3374 + // A long import must not be bound by the web request time limit: at
3375 + // ~2.3s per listing, PHP's max_execution_time (1200s here) hard-kills
3376 + // the worker around listing 516 of a 1000+ run. The proven previous
3377 + // version called set_time_limit(0) in its import path for this reason.
3378 + if ( function_exists( 'set_time_limit' ) ) {
3379 + @set_time_limit( 0 ); // phpcs:ignore
3741 3380 }
3381 + // Proven-previous-version parity: defer term counting for the whole
3382 + // run. WordPress then skips the count queries fired on every term
3383 + // assignment (7 taxonomies x every listing) and recounts once when
3384 + // deferral is switched back off after the run.
3385 + wp_defer_term_counting( true );
3742 3386
3743 - // All batches done: mark completed and drop the stored batch payload.
3744 - mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL );
3745 - mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL );
3746 -
3747 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
3748 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
3749 -
3750 - // Final cleanup
3751 - unset($attachments_to_move);
3752 - unset($mlsimport_item_option_data);
3753 - unset($input_arg);
3754 - unset($log);
3755 -
3756 - // One final garbage collection
3757 - wp_cache_flush();
3758 - gc_collect_cycles();
3387 + $result = $this->mlsimport_import_task_execution()->execute( $run_id );
3388 + // Recount the deferred term totals now that this worker is done. A
3389 + // hand-off recounts per chunk, which keeps counts correct even if a
3390 + // later chunk in the chain dies.
3391 + wp_defer_term_counting( false );
3392 + // A 'running' result is a chunk hand-off (issue #199): this worker
3393 + // spent its time budget and already queued the follow-up worker. Every
3394 + // exit line carries timing, totals, and memory so one run's log is a
3395 + // complete health trace of the whole worker chain.
3396 + $worker_trace = ' Elapsed ' . round( microtime( true ) - $worker_started_at, 1 ) . 's,'
3397 + . ' saved ' . (int) $result['saved'] . ', failed ' . (int) $result['failed'] . ','
3398 + . ' memory ' . round( memory_get_usage( true ) / 1048576 ) . 'MB,'
3399 + . ' peak ' . round( memory_get_peak_usage( true ) / 1048576 ) . 'MB.'
3400 + . ( '' !== (string) $result['error'] ? ' Error: ' . (string) $result['error'] : '' );
3401 + mlsimport_saas_single_write_import_custom_logs(
3402 + 'running' === (string) $result['state']
3403 + ? 'Import chunk handed off at ' . (int) ( $result['saved'] + $result['failed'] ) . ' listings; next worker queued.' . $worker_trace . PHP_EOL
3404 + : 'Import worker finished with state ' . (string) $result['state'] . '.' . $worker_trace . PHP_EOL,
3405 + 'manual'
3406 + );
3407 + gc_collect_cycles();
3759 3408 }
3760 3409
3761 3410
3762 3411
@@ -3791,66 +3440,46 @@
3791 3440 if(isset($_POST['post_id'] )){
3792 3441 $post_id = intval( $_POST['post_id'] );
3793 3442 }
3794 3443
3795 - // Current spawn status + the shared status log file contents.
3796 - $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true );
3797 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
3798 - $logs = file_get_contents( $path );
3799 -
3800 - //get neww status data
3801 - // Progress counters written by the background worker.
3802 - $current = intval( get_post_meta( $post_id, 'mlsimport_progress_properties', true ) );
3803 - $total = intval( get_post_meta( $post_id, 'mlsimport_progress_batches', true ) );
3804 - $memory = get_post_meta( $post_id, 'mlsimport_progress_memory', true );
3805 - $mlsimport_task_to_import = intval( get_post_meta($post_id, 'mlsimport_task_to_import',true));
3806 -
3807 -
3808 -
3809 - // Stop flag: the option is authoritative (second assignment wins).
3810 - $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) );
3811 - $force_status = get_option( 'mlsimport_force_stop_' . $post_id );
3812 -
3813 - // Stop requested -> report done.
3814 - if ( 'no' !== $force_status ) {
3815 - echo wp_json_encode(
3816 - array(
3817 - 'is_done' => 'done',
3818 - 'status' => $status,
3819 - 'logs' => $logs,
3820 - )
3821 - );
3822 - die();
3444 + // Watchdog (issue #199): chunked manual imports depend on each worker
3445 + // queueing its follow-up; a host-killed worker breaks that chain. This
3446 + // poll fires every few seconds while an administrator watches the
3447 + // progress screen, making it the natural revival trigger. The call is
3448 + // a cheap no-op unless the run has been silent long enough. Both real
3449 + // outcomes — a revival and the stalled-run failure — are logged, since
3450 + // each one means a worker died.
3451 + $revive = $this->mlsimport_import_task_execution()->revive( $post_id );
3452 + if ( true === ( $revive['revived'] ?? false ) ) {
3453 + mlsimport_saas_single_write_import_custom_logs( 'Watchdog revived the import worker chain for task ' . $post_id . '.' . PHP_EOL, 'manual' );
3454 + } elseif ( 'stalled' === ( $revive['reason'] ?? '' ) ) {
3455 + mlsimport_saas_single_write_import_custom_logs( 'Watchdog declared the import run for task ' . $post_id . ' stalled and failed it.' . PHP_EOL, 'manual' );
3823 3456 }
3824 3457
3825 - // Empty/completed status -> report done; otherwise report wip.
3826 - if ( '' === $status || 'completed' === $status ) {
3827 - echo wp_json_encode(
3828 - array(
3829 - 'is_done' => 'done',
3830 - 'status' => $status,
3831 - 'logs' => $logs,
3832 - 'mlsimport_progress_properties' => $current,
3833 - 'mlsimport_task_to_import' => $total,
3834 - )
3835 - );
3836 - } else {
3837 - // return from log
3838 - echo wp_json_encode(
3839 - array(
3840 - 'is_done' => 'wip',
3841 - 'status' => $status,
3842 - 'logs' => $logs,
3843 - 'mlsimport_progress_properties' => $current,
3844 - 'mlsimport_progress_batches' => $total,
3845 - 'memory' => $memory,
3846 - 'mlsimport_task_to_import'=>$mlsimport_task_to_import,
3847 - 'post_id'=>$post_id
3458 + $progress = $this->mlsimport_import_task_execution()->status( $post_id );
3459 + $status = (string) ( $progress['state'] ?? '' );
3460 + $done = '' === $status || in_array( $status, array( 'completed', 'stopped', 'failed' ), true );
3461 + $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
3462 + $logs = is_readable( $path ) ? (string) file_get_contents( $path ) : '';
3848 3463
3849 - )
3850 - );
3851 - }
3852 - die();
3464 + // Keep the old JSON field names so the current browser code continues to
3465 + // work while their values now come from the one shared progress record.
3466 + wp_send_json(
3467 + array(
3468 + 'is_done' => $done ? 'done' : 'wip',
3469 + 'status' => $status,
3470 + 'logs' => $logs,
3471 + 'mlsimport_progress_properties' => (int) ( $progress['handled'] ?? 0 ),
3472 + 'mlsimport_progress_batches' => (int) ( $progress['handled'] ?? 0 ),
3473 + 'mlsimport_task_to_import' => (int) ( $progress['expected'] ?? 0 ),
3474 + // The import worker records its own memory with each progress
3475 + // update; showing this AJAX request's memory instead would only
3476 + // mislead (it grows with the log file it returns).
3477 + 'memory' => $progress['memory'] ?? round( memory_get_usage( true ) / 1048576, 2 ),
3478 + 'post_id' => $post_id,
3479 + 'result' => $progress['result'] ?? array(),
3480 + )
3481 + );
3853 3482 }
3854 3483
3855 3484
3856 3485
@@ -3877,17 +3506,15 @@
3877 3506 // Admin boundary: the target must be an Import Task the user can edit.
3878 3507 if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
3879 3508 wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 );
3880 3509 }
3881 - // Flip the stop flag (autoload off).
3510 + $stop = $this->mlsimport_import_task_execution()->stop( $post_id );
3511 + // Preserve the old option for third-party callers that inspect it. The
3512 + // shared runner itself uses the run-scoped Stop request above.
3882 3513 update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false );
3883 - // ensure caches are cleared so running processes see the update immediately
3884 - if ( function_exists( 'wp_cache_delete' ) ) {
3885 - wp_cache_delete( 'mlsimport_force_stop_' . $post_id, 'options' );
3886 - }
3887 3514 mlsimport_saas_single_write_import_custom_logs( 'Stopped for ' . $post_id . PHP_EOL );
3888 3515 mlsimport_debuglogs_per_plugin( 'Stopped for ' . $post_id . PHP_EOL );
3889 - wp_send_json_success();
3516 + wp_send_json_success( array( 'accepted' => (bool) $stop['accepted'] ) );
3890 3517 }
3891 3518
3892 3519
3893 3520
@@ -3894,28 +3521,70 @@
3894 3521 /**
3895 3522 * AJAX: fetch the MLS metadata (theme schema + field data + enums) for the
3896 3523 * configured theme and cache it in options, marking metadata as populated.
3897 3524 *
3525 + * Thin wrapper since #281: nonce + capability here, the actual gather /
3526 + * persist / reconcile sequence lives in the shared connection-scoped core
3527 + * mlsimport_gather_connection_metadata() (includes/mlsimport-metadata-
3528 + * gather.php). An optional posted mls_id scopes the gather to one
3529 + * registered connection (per-connection field mapping UI); without one the
3530 + * request resolves to the CURRENT connection — exactly the historic
3531 + * behavior of this handler, including response shapes and status codes.
3532 + *
3533 + * A NON-current scope re-runs that connection's record-scoped credential
3534 + * test first: the SaaS 'GET clients' returns the metadata of the MLS the
3535 + * account record last held, so without the PATCH the scoped connection
3536 + * would be seeded with another MLS's metadata (the step-1 caveat in
3537 + * mlsimport-metadata-gather.php).
3538 + *
3898 3539 * @return void
3899 3540 */
3900 3541 public function mlsimport_saas_get_metadata_function() {
3901 3542 // CSRF.
3902 3543 check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
3903 - $theme_Start = new ThemeImport();
3544 + if ( ! current_user_can( 'manage_options' ) ) {
3545 + wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to gather MLS metadata.', 'mlsimport' ) ), 403 );
3546 + }
3904 3547
3905 - // GET /clients?theme_id=<id> to retrieve the schema + MLS metadata.
3906 - $values = array();
3907 - $options = get_option( $this->plugin_name . '_admin_options' );
3908 - $url = 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] );
3548 + // Resolve the requested scope; absent/unknown => current connection.
3549 + $mls_id = mlsimport_field_mapping_request_scope( isset( $_POST['mls_id'] ) && is_scalar( $_POST['mls_id'] ) ? wp_unslash( $_POST['mls_id'] ) : null );
3909 3550
3910 - $answer = $theme_Start::globalApiRequestSaas( $url, $values, 'GET' );
3551 + // Non-current scope: point the SaaS account record at THIS connection's
3552 + // MLS before gathering, or the gather would fetch the wrong metadata.
3553 + if ( $mls_id > 0 && $mls_id !== mlsimport_current_mls_id() ) {
3554 + $record = mlsimport_connections_run_test( $mls_id );
3555 + if ( null === $record || 'yes' !== ( $record['status'] ?? '' ) ) {
3556 + wp_send_json_error(
3557 + array(
3558 + 'message' => esc_html__( 'Gathering MLS metadata failed. Nothing was changed - it will retry on the next page load.', 'mlsimport' ),
3559 + 'detail' => esc_html__( 'The connection test for this MLS failed - fix its credentials on the Connections tab first.', 'mlsimport' ),
3560 + ),
3561 + 502
3562 + );
3563 + }
3564 + }
3911 3565
3912 - // Mark populated and cache the three metadata blobs in options.
3913 - update_option( 'mlsimport_mls_metadata_populated', 'yes' );
3566 + // One shared gather core, scoped to the resolved connection.
3567 + $gather = mlsimport_gather_connection_metadata( $mls_id );
3914 3568
3915 - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
3916 - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
3917 - update_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'] );
3569 + // A response without the metadata shape changed nothing — retryable 502.
3570 + if ( 'request_failed' === $gather['code'] ) {
3571 + wp_send_json_error(
3572 + array(
3573 + 'message' => $gather['message'],
3574 + 'detail' => $gather['detail'],
3575 + ),
3576 + 502
3577 + );
3578 + }
3579 +
3580 + // Reconcile failure returns the raw Field Configuration result (500),
3581 + // matching the historic response body for this case.
3582 + if ( ! $gather['success'] ) {
3583 + wp_send_json_error( $gather['result'], 500 );
3584 + }
3585 +
3586 + wp_send_json_success( array( 'revision' => $gather['revision'] ) );
3918 3587 }
3919 3588
3920 3589
3921 3590