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 +414 -265 7.1.17.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
@@ -144,13 +145,21 @@
144 145 }
145 146 /**
146 147 * Wire up the theme and MLS provider adapter objects for this request.
147 148 *
148 - * Reads the configured theme_id, asks the explicit factory for its adapter,
149 + * Resolves the theme_id, asks the explicit factory for its adapter,
149 150 * injects one Stored Listing Write into ThemeImport, and instantiates the Provider Family
150 151 * adapter (mls_env_data). Provider selection comes from the saved type with
151 152 * the numeric MLS ID used only for older configurations.
152 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 + *
153 162 * @param string $plugin_name Plugin slug passed to ThemeImport.
154 163 * @param string $mls_enviroment Legacy argument retained for call compatibility.
155 164 * @param string $theme_enviroment Legacy ignored theme-environment name.
156 165 * @since 1.0.0
@@ -156,14 +165,12 @@
156 165 * @since 1.0.0
157 166 */
158 167 public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) {
159 168
160 - // 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.
161 171 $options = get_option( $this->plugin_name . '_admin_options' );
162 - $theme_id = 0;
163 - if ( isset( $options['mlsimport_theme_used'] ) ) {
164 - $theme_id = intval( $options['mlsimport_theme_used'] );
165 - }
172 + $theme_id = mlsimport_resolve_theme_id();
166 173 unset( $theme_enviroment );
167 174 $this->stored_listing_configuration_error = '';
168 175 try {
169 176 $factory = new Mlsimport_Stored_Listing_Adapter_Factory();
@@ -204,13 +211,35 @@
204 211 // Onboarding wizard styles.
205 212 wp_enqueue_style( 'mlsimport-onboarding', plugin_dir_url( __FILE__ ) . 'css/mlsimport-onboarding.css', array(), MLSIMPORT_VERSION, 'all' );
206 213 // Drag-and-drop field selector styles.
207 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 + }
208 221 }
209 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 + }
210 238
211 239
212 240
241 +
213 242 /**
214 243 * Register the JavaScript for the admin area.
215 244 *
216 245 * Enqueues the core admin script, the single Field Configuration controller,
@@ -242,8 +271,13 @@
242 271 $current_options = get_option( $this->plugin_name . '_admin_options', array() );
243 272 if ( is_array( $current_options ) && ! empty( $current_options['mlsimport_mls_name'] ) ) {
244 273 $provider_mls_ids[] = (string) $current_options['mlsimport_mls_name'];
245 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 + }
246 280 $provider_mls_ids = array_values( array_unique( $provider_mls_ids ) );
247 281 $saved_provider_type = (string) get_option( 'mlsimport_provider_type', '' );
248 282 $saved_provider_mls_id = (string) get_option( 'mlsimport_provider_type_mls_id', '' );
249 283 $provider_browser_config = Mlsimport_Provider_Family::browser_config_for_ids(
@@ -281,13 +315,17 @@
281 315
282 316
283 317
284 318 // On the settings page Field Options tab: if metadata was never fetched,
285 - // 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.
286 323 if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
287 324 isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' &&
288 325 isset($_GET['tab']) && $_GET['tab'] === 'field_options') {
289 - $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 );
290 328 if ( 'yes' !== $mlsimport_mls_metadata_populated ) {
291 329 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
292 330 wp_add_inline_script('mlsimport-admin', $inline_script);
293 331 }
@@ -304,9 +342,9 @@
304 342 * meant this block never ran, the metadata pull was never triggered, and the
305 343 * wizard's Field Mapping step sat on "Please Stand By!" forever.
306 344 */
307 345 if ( isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding' ) {
308 - $mlsimport_mls_metadata_populated = get_option('mlsimport_mls_metadata_populated', '');
346 + $mlsimport_mls_metadata_populated = mlsimport_get_connection_option( 'mlsimport_mls_metadata_populated', '' );
309 347 if ('yes' !== $mlsimport_mls_metadata_populated) {
310 348 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
311 349 wp_add_inline_script('mlsimport-admin', $inline_script);
312 350 }
@@ -314,21 +352,95 @@
314 352
315 353
316 354
317 355
318 - // On the settings Display Options tab (or the page with no tab), seed the
319 - // MLS-name autocomplete with the fetched list when it is not an array.
320 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
321 - ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') ||
322 - (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) {
323 -
324 - // Re-fetch the MLS list and, when it is a raw string payload,
325 - // hand it to the JS autocomplete initializer.
326 - $mls_import_list = mlsimport_saas_request_list();
327 - if(!is_array($mls_import_list)){
328 - $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });';
329 - wp_add_inline_script('mlsimport-admin', $inline_script);
330 - }
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 + );
331 443 }
332 444
333 445 // Searchable City/County multi-select — only on the Import Task edit screen.
334 446 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
@@ -676,9 +788,9 @@
676 788 'name' => esc_html__( 'title_format', 'mlsimport' ),
677 789 'details' => 'to be added',
678 790 ),
679 791 'mlsimport_username' => array(
680 - 'name' => esc_html__( 'MLSImport.com Username (not your email)', 'mlsimport' ),
792 + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ),
681 793 'details' => 'to be added',
682 794 ),
683 795 'mlsimport_password' => array(
684 796 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
@@ -796,11 +908,13 @@
796 908 // Same MLS but possibly new credentials: force the next request to log in again.
797 909 Mlsimport_Provider_Family::clear_access_tokens();
798 910 }
799 911
800 - // Credentials may have changed: force a fresh connection test + metadata pull.
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.
801 915 delete_option( 'mlsimport_connection_test' );
802 - delete_option( 'mlsimport_mls_metadata_populated' );
916 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
803 917
804 918 // Reset cached encoding and drop cached token/schema transients.
805 919 update_option( 'mlsimport_encoding_array', '' );
806 920 delete_transient( 'mlsimport_token_request' );
@@ -839,77 +953,10 @@
839 953 return $valid;
840 954 }
841 955
842 956
843 - /**
844 - * Validate the administrative options group on save (register_setting callback).
845 - *
846 - * Only carries the raw "import" payload through (a JSON blob of exported settings).
847 - *
848 - * @param array $input Raw submitted administrative options.
849 - * @return array Whitelisted administrative options.
850 - * @since 1.0.0
851 - */
852 - public function validate_administrative_options( $input ) {
853 957
854 - $valid = array();
855 -
856 - // Pass the single 'import' payload through.
857 - $field_import = array( 'import' );
858 - foreach ( $field_import as $key ) {
859 - $valid[ $key ] = $input[ $key ];
860 - }
861 -
862 - return $valid;
863 - }
864 -
865 958 /**
866 - * Validate the import-options group on save (register_setting callback).
867 - *
868 - * Casts import_number to int, and when an 'import' JSON payload is present it
869 - * restores the field-select / mls-sync / import-options / transients options
870 - * from it (used by the settings import/export feature).
871 - *
872 - * @param array $input Raw submitted import options.
873 - * @return array Whitelisted import options.
874 - * @since 1.0.0
875 - */
876 - public function validate_admin_import_options( $input ) {
877 - $valid = array();
878 -
879 - // import_number is numeric-only.
880 - $field_import = array( 'import_number' );
881 - foreach ( $field_import as $key ) {
882 - $valid[ $key ] = intval( $input[ $key ] );
883 - }
884 -
885 - // When an exported-settings JSON blob is supplied, decode it and restore
886 - // the four related option groups from it.
887 - if ( isset( $input['import'] ) && '' !== $input['import'] ) {
888 - $decode = json_decode( $input['import'], true );
889 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_fields_select'] ) && is_array( $decode['mlsimport_admin_fields_select'] ) ) {
890 - mlsimport_import_field_configuration( $decode['mlsimport_admin_fields_select'] );
891 - }
892 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_mls_sync'] ) ) {
893 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
894 - }
895 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_import_options'] ) ) {
896 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
897 - }
898 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_use_transients'] ) ) {
899 - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
900 - }
901 - }
902 -
903 - return $valid;
904 - }
905 -
906 -
907 -
908 -
909 -
910 -
911 - /**
912 959 * Register all plugin option groups with the Settings API and bind each to
913 960 * its validation callback. Hooked on admin_init.
914 961 */
915 962 public function options_update() {
@@ -916,39 +963,13 @@
916 963 // Field Configuration is intentionally absent: it is form-free and only the
917 964 // deep module's compact command endpoint may mutate its option.
918 965 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
919 966 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
920 - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
921 - register_setting( $this->plugin_name . '_administrative_options', $this->plugin_name . '_administrative_options', array( $this, 'validate_administrative_options' ) );
922 967 // The standalone option is registered in class-mlsimport-standalone-settings.php
923 968 // (on init, with show_in_rest) so the dedicated React design page can read/write it.
924 969 }
925 970
926 971 /**
927 - * Update-option hook for the administrative options group.
928 - *
929 - * When the administrative options carry an 'import' JSON payload, decode it
930 - * and restore the field-select / mls-sync / import-options option groups.
931 - */
932 - public function update_option_mlsimport_administrative_options() {
933 - // Read the saved administrative options and, if present, restore the
934 - // three related option groups from the embedded JSON payload.
935 - $import = get_option( 'mlsimport_administrative_options' );
936 - if ( '' !== $import ) {
937 - $decode = json_decode( $import['import'], true );
938 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_fields_select'] ) && is_array( $decode['mlsimport_admin_fields_select'] ) ) {
939 - mlsimport_import_field_configuration( $decode['mlsimport_admin_fields_select'] );
940 - }
941 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_mls_sync'] ) ) {
942 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
943 - }
944 - if ( is_array( $decode ) && isset( $decode['mlsimport_admin_import_options'] ) ) {
945 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
946 - }
947 - }
948 - }
949 -
950 - /**
951 972 * Update-option hook for the field-select group: ask the active theme
952 973 * adapter to (re)register its custom fields/taxonomies for the mapped fields.
953 974 */
954 975 public function update_option_mlsimport_admin_fields_select() {
@@ -1080,10 +1101,10 @@
1080 1101 delete_transient( 'mlsimport_plugin_data_schema' );
1081 1102 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1082 1103 delete_transient( 'mlsimport_saas_token' );
1083 1104
1084 - // Force a fresh metadata pull next load.
1085 - 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' );
1086 1107
1087 1108 die( 'deleted' );
1088 1109 }
1089 1110
@@ -1095,11 +1116,13 @@
1095 1116
1096 1117 // CSRF: Tools-page nonce.
1097 1118 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1098 1119
1099 - // Wipe the metadata flag and the saved field-select configuration.
1100 - delete_option( 'mlsimport_mls_metadata_populated' );
1101 - 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' );
1102 1125
1103 1126 die( 'deleted' );
1104 1127 }
1105 1128
@@ -1386,8 +1409,12 @@
1386 1409 $values = $payload_result['payload'];
1387 1410
1388 1411 // PATCH the credentials to the SaaS 'clients' endpoint, which validates
1389 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.
1390 1417 $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
1391 1418 // Some clients responses include the authoritative MLS configuration. Save
1392 1419 // its type beside this MLS ID so later requests no longer need ID fallback.
1393 1420 if ( isset( $answer['mls_data']['type'] ) ) {
@@ -1393,26 +1420,41 @@
1393 1420 if ( isset( $answer['mls_data']['type'] ) ) {
1394 1421 Mlsimport_Provider_Family::remember_type( $answer['mls_data']['type'], $mls_id );
1395 1422 }
1396 1423
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 );
1430 + }
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 );
1435 + }
1397 1436
1398 1437
1399 1438
1439 +
1400 1440 // Persist the connection-test flag only on a confirmed successful test;
1401 1441 // any other outcome clears it (and the metadata flag) so the UI re-tests.
1402 - if ( isset( $answer['success'] ) && true === $answer['success'] ) {
1403 - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
1404 - update_option( 'mlsimport_connection_test', 'yes' );
1405 - mlsimport_telemetry_set_once( 'mls_connected_at', time() );
1406 - } else {
1407 - delete_option( 'mlsimport_connection_test' );
1408 - delete_option( 'mlsimport_mls_metadata_populated' );
1409 - }
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() );
1410 1447 } else {
1411 1448 delete_option( 'mlsimport_connection_test' );
1412 - delete_option( 'mlsimport_mls_metadata_populated' );
1449 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
1413 1450 }
1414 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 +
1415 1457 return $answer;
1416 1458 }
1417 1459
1418 1460 /**
@@ -1639,8 +1681,12 @@
1639 1681 delete_transient( 'mlsimport_plugin_data_schema' );
1640 1682 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1641 1683 delete_transient( 'mlsimport_saas_token' );
1642 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.
1643 1689 delete_option( 'mlsimport_mls_metadata_populated' );
1644 1690
1645 1691 delete_option( 'mlsimport_admin_fields_select' );
1646 1692 }
@@ -1662,9 +1708,11 @@
1662 1708 // POST to the SaaS 'token' endpoint and return its response.
1663 1709 $theme_Start = new ThemeImport();
1664 1710 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
1665 1711
1666 -
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 );
1667 1715
1668 1716 return $answer;
1669 1717 }
1670 1718
@@ -1724,8 +1772,16 @@
1724 1772 if ( ! current_user_can( 'edit_post', $post_id ) ) {
1725 1773 return;
1726 1774 }
1727 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 +
1728 1784 // Every import-parameter meta key this metabox may write.
1729 1785 $allowed_keys = array(
1730 1786 'mlsimport_item_how_many',
1731 1787 'mlsimport_item_title_format',
@@ -1820,8 +1876,14 @@
1820 1876 * Ensures a live SaaS token + MLS connection, prints a warning and stops if
1821 1877 * either is missing, otherwise runs a listing count request and hands off to
1822 1878 * generateMetaOptionsHtml() to build the parameter form.
1823 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 + *
1824 1886 * @param WP_Post $post The post object.
1825 1887 */
1826 1888 public function mlsimport_saas_display_meta_options($post) {
1827 1889 // Nonce for the metabox save.
@@ -1838,11 +1900,12 @@
1838 1900 $mlsimport->admin->mlsimport_saas_check_mls_connection();
1839 1901 $is_mls_connected = get_option('mlsimport_connection_test', '');
1840 1902 }
1841 1903
1842 - // 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).
1843 1906 if (trim($token) === '') {
1844 - 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.
1845 1908 return;
1846 1909 }
1847 1910
1848 1911 // Token OK but MLS connection failed -> stop with a notice.
@@ -1856,31 +1919,41 @@
1856 1919 $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1857 1920 $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1858 1921 $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1859 1922 $status = get_option('mlsimport_force_stop_' . $postId);
1860 - $fieldImport = $this->mlsimport_saas_return_mls_fields();
1861 - $options = get_option('mlsimport_admin_options');
1862 - $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);
1863 1928
1864 - ? intval($options['mlsimport_mls_name'])
1865 - : 0;
1866 -
1867 1929 // Ask the MLS how many listings currently match this task.
1868 1930 $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1869 1931 // print_r($mlsRequest);
1870 1932
1871 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.
1872 1940 $hasError = isset($mlsRequest['success']) && !$mlsRequest['success'];
1873 1941 if ($hasError) {
1874 - 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>';
1875 1944 }
1876 1945
1877 - // 'none' means no results key -> likely an expired token; re-test.
1878 - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1879 - if ($foundItems === 'none') {
1880 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
1881 - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1882 - }
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;
1883 1956
1884 1957 // Build and print the parameter form.
1885 1958 echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError);
1886 1959 }
@@ -1891,9 +1964,10 @@
1891 1964 /**
1892 1965 * Generate Meta Options HTML
1893 1966 *
1894 1967 * @param int $postId The post ID.
1895 - * @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.
1896 1970 * @param string $lastDate The last date checked.
1897 1971 * @param string $mlsimportItemHowMany How many items to import.
1898 1972 * @param string $mlsimportItemStatCron The status of the cron job.
1899 1973 * @param int $mlsimportMlsId The MLS import ID.
@@ -1910,9 +1984,11 @@
1910 1984 // carry their human-readable labels alongside the raw values.
1911 1985 $metadata_api_call_city = array();
1912 1986 $metadata_api_call_county = array();
1913 1987 $metadata_api_call_property_type = array();
1914 - $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 ) );
1915 1991 if ('' !== $mlsimport_mls_metadata_mls_enums) {
1916 1992 $metadata_api_call_full = json_decode($mlsimport_mls_metadata_mls_enums, true);
1917 1993 if (isset($metadata_api_call_full['global_array']['PropertyEnums'])) {
1918 1994 $property_enums = $metadata_api_call_full['global_array']['PropertyEnums'];
@@ -1945,12 +2021,53 @@
1945 2021 </div>
1946 2022 <?php endif; ?>
1947 2023
1948 2024 <div class="mlsimport_import_no">
1949 - <?php esc_html_e('We found', 'mlsimport'); ?>
1950 - <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; ?>
1951 2032 </div>
1952 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 +
1953 2070 <fieldset class="mlsimport-fieldset">
1954 2071 <label class="mlsimport-label" for="mlsimport_item_how_many">
1955 2072 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
1956 2073 </label>
@@ -1971,35 +2088,8 @@
1971 2088 <div id="mlsimport_item_status">Ready to import!</div>
1972 2089 <div id="mlsimport_item_progress" class="mlsimport-progress-bar">
1973 2090 <div class="mlsimport-progress-bar-inner" style="width:0%;"></div>
1974 2091 </div>
1975 - <?php
1976 - // Support diagnostic (issue #216): the latest finished-run
1977 - // snapshot recorded at finish_run(). One plain sentence so
1978 - // "is it us or the host?" is answerable from this screen —
1979 - // workers above 1 + hand-offs means the host killed workers.
1980 - $mlsimport_telemetry_state = get_option('mlsimport_telemetry_state', array());
1981 - $mlsimport_last_run = is_array($mlsimport_telemetry_state) && isset($mlsimport_telemetry_state['last_import_run']) && is_array($mlsimport_telemetry_state['last_import_run'])
1982 - ? $mlsimport_telemetry_state['last_import_run']
1983 - : array();
1984 - if (!empty($mlsimport_last_run)) :
1985 - ?>
1986 - <div class="mlsimport-exp" id="mlsimport_last_run_summary">
1987 - <?php
1988 - printf(
1989 - /* translators: 1 state, 2 saved, 3 failed, 4 elapsed seconds, 5 workers, 6 peak MB, 7 pending actions. */
1990 - esc_html__('Last import run %1$s: %2$d saved, %3$d failed, %4$ds across %5$d worker(s), peak memory %6$dMB, %7$d worker action(s) pending.', 'mlsimport'),
1991 - esc_html((string) ($mlsimport_last_run['state'] ?? '')),
1992 - (int) ($mlsimport_last_run['saved'] ?? 0),
1993 - (int) ($mlsimport_last_run['failed'] ?? 0),
1994 - (int) ($mlsimport_last_run['elapsed_seconds'] ?? 0),
1995 - (int) ($mlsimport_last_run['workers'] ?? 0),
1996 - (int) ($mlsimport_last_run['peak_memory_mb'] ?? 0),
1997 - (int) ($mlsimport_last_run['queue_depth'] ?? 0)
1998 - );
1999 - ?>
2000 - </div>
2001 - <?php endif; ?>
2002 2092 <input class="button mlsimport_button save_data " type="button" id="mlsimport-start_item"
2003 2093 data-post-number="<?php echo intval($foundItems); ?>"
2004 2094 data-post_id="<?php echo intval($postId); ?>" value="Start Import">
2005 2095 <input class="button mlsimport_button error_action" type="button" id="mlsimport_stop_item"
@@ -2107,18 +2197,18 @@
2107 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); ?>">
2108 2198 </fieldset>
2109 2199
2110 2200 <?php
2111 - // Let the active provider adjust only the Import Task fields it owns.
2112 - $options = get_option($this->plugin_name . '_admin_options');
2113 - $options = is_array( $options ) ? $options : array();
2114 - $mlsId = '';
2115 - if (isset($options['mlsimport_mls_name'])) {
2116 - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
2117 - }
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 );
2118 2206 $provider = Mlsimport_Provider_Family::adapter(
2119 - Mlsimport_Provider_Family::saved_type( $mlsId ),
2120 - $mlsId,
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,
2121 2211 $this->theme_importer
2122 2212 );
2123 2213 $fieldImport = $provider->prepare_import_task_fields( $fieldImport );
2124 2214
@@ -2179,9 +2269,13 @@
2179 2269 'MLSAreaMajor',
2180 2270 'SubdivisionName',
2181 2271 ];
2182 2272
2183 - 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) {
2184 2278 $selectAllNone[] = 'PropertyType';
2185 2279 }
2186 2280
2187 2281 if (!in_array($key, $selectAllNone)): ?>
@@ -2368,15 +2462,23 @@
2368 2462 * @return int Number of listings found in the MLS feed, or 0 on failure.
2369 2463 */
2370 2464 public function mlsimport_saas_start_cron_links_per_item( int $item_id ): int {
2371 2465 // A task becomes eligible only after its first manual import completed.
2372 - // Keep the existing guard at this scheduling boundary; execution rules
2373 - // themselves now live in the shared runner below.
2374 - $manual_completed = 1 === (int) get_post_meta( $item_id, 'mlsimport_initial_import_completed', true );
2375 - $legacy_completed = mlsimport_cron_should_process_task( get_post_meta( $item_id, 'mlsimport_spawn_status', true ) );
2376 - if ( ! $manual_completed && ! $legacy_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 ) );
2377 2478 return 0;
2378 2479 }
2480 + mlsimport_alert_resolve( $incident );
2379 2481
2380 2482 $start = $this->mlsimport_import_task_execution()->start(
2381 2483 array(
2382 2484 'task_id' => $item_id,
@@ -2388,8 +2490,14 @@
2388 2490 if ( true !== ( $start['accepted'] ?? false ) ) {
2389 2491 return 0;
2390 2492 }
2391 2493
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' ) );
2499 +
2392 2500 // Same rules as the manual worker: a large hourly sync must not be
2393 2501 // killed by the web/cron request time limit mid-run, and term counts
2394 2502 // are recomputed once after the run instead of per assignment.
2395 2503 if ( function_exists( 'set_time_limit' ) ) {
@@ -2397,10 +2505,17 @@
2397 2505 }
2398 2506 wp_defer_term_counting( true );
2399 2507 $result = $this->mlsimport_import_task_execution()->execute( (string) $start['run_id'] );
2400 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.
2401 2514 mlsimport_saas_single_write_import_custom_logs(
2402 - 'Automatic import for task ' . $item_id . ' finished with state ' . (string) $result['state'] . '.' . PHP_EOL,
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,
2403 2518 'cron'
2404 2519 );
2405 2520 gc_collect_cycles();
2406 2521
@@ -2414,36 +2529,39 @@
2414 2529
2415 2530 /**
2416 2531 * Backward-compatible entry point for the deep reconciliation module.
2417 2532 *
2418 - * Cron now calls the module directly. This method remains for existing plugin
2419 - * callers and delegates the full snapshot, plan, policy, deletion, and retry
2420 - * sequence through the same public seam.
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.
2421 2537 *
2422 - * @return array<string, int|string> Structured Reconciliation Outcome.
2538 + * @return array<int, array<string, int|string>> Reconciliation Outcome per
2539 + * connection (key 0 = the single legacy unscoped run).
2423 2540 */
2424 2541 public function mlsimport_saas_start_doing_reconciliation() {
2425 2542 // Backward-compatible entry point for callers outside the cron hook. The
2426 - // complete destructive decision path now lives behind the deep module seam.
2427 - $environment = new Mlsimport_Reconciliation_WordPress_Environment(
2428 - function (): array {
2429 - return $this->mlsimport_saas_get_mls_reconciliation_data();
2430 - }
2431 - );
2432 -
2433 - return ( new Mlsimport_Reconciliation( $environment ) )->reconcile_current_listings();
2543 + // complete destructive decision path lives behind the deep module seam,
2544 + // sequenced per connection by the runner.
2545 + return mlsimport_reconciliation_run_connections();
2434 2546 }
2435 2547
2436 2548 /**
2437 2549 * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
2438 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.
2439 2557 * @return array The API response, expected to carry an 'all_data' key.
2440 2558 */
2441 - public function mlsimport_saas_get_mls_reconciliation_data() {
2559 + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) {
2442 2560
2443 - // GET /reconciliation with no arguments.
2444 - $arguments = array();
2445 - $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' );
2446 2564 return $answer;
2447 2565 }
2448 2566
2449 2567 /**
@@ -2511,9 +2629,20 @@
2511 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' ),
2512 2630 );
2513 2631 }
2514 2632
2515 - //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);
2516 2645 //print '----------------------------'.PHP_EOL;
2517 2646 // POST the query to the SaaS 'listings' endpoint.
2518 2647 $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
2519 2648
@@ -2526,8 +2655,15 @@
2526 2655 'message' => is_string( $answer ) ? $answer : esc_html__( 'The request to the MLS could not be completed.', 'mlsimport' ),
2527 2656 );
2528 2657 }
2529 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 +
2530 2666 // Echo the computed argument length back on the response for diagnostics.
2531 2667 $answer['potential_leght'] = $potential_leght;
2532 2668
2533 2669 // Record the pre-filter MLS feed count for telemetry. Every import path
@@ -2539,9 +2675,11 @@
2539 2675
2540 2676 // Record the request outcome into sync-health telemetry (issue #207):
2541 2677 // success stamps last_sync_success; failure stamps last_sync_failed plus
2542 2678 // a real failure class instead of the former always-"unknown" code.
2543 - mlsimport_telemetry_record_sync_result( $answer );
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 ) );
2544 2682
2545 2683 return ( $answer );
2546 2684 }
2547 2685
@@ -2568,17 +2706,19 @@
2568 2706 * @return array|string The argument array, or '' when core options are missing.
2569 2707 */
2570 2708 public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2571 2709
2572 - // 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.
2573 2713 $options = get_option( $this->plugin_name . '_admin_options' );
2574 - if ( isset( $options['mlsimport_mls_name'] ) ) {
2575 - $mls_id = intval( $options['mlsimport_mls_name'] );
2576 - } else {
2714 + $mls_id = mlsimport_task_mls_id( (int) $item_id );
2715 + if ( $mls_id <= 0 ) {
2577 2716 return '';
2578 2717 }
2579 2718
2580 - // 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).
2581 2721 if ( isset( $options['mlsimport_theme_used'] ) ) {
2582 2722 $theme_id = intval( $options['mlsimport_theme_used'] );
2583 2723 } else {
2584 2724 return '';
@@ -2674,10 +2814,14 @@
2674 2814 $values = $this->mls_import_saas_add_to_parms_input( 'CustomParameters', $item_id, 'custom_parameters', $values );
2675 2815
2676 2816
2677 2817 // Hand only provider-specific request preparation to the active adapter.
2678 - // Saved type wins; numeric ranges are used only by older configurations.
2679 - $saved_type = Mlsimport_Provider_Family::saved_type( $mls_id );
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 );
2680 2824 $provider = Mlsimport_Provider_Family::adapter( $saved_type, $mls_id, $this->theme_importer );
2681 2825 if ( ! $provider->supported() ) {
2682 2826 return array( 'mlsimport_provider_error' => $provider->error() );
2683 2827 }
@@ -2802,14 +2946,16 @@
2802 2946 * definition array the metabox renders from. Falls back StandardStatus to
2803 2947 * MlsStatus when the MLS has no StandardStatus enum. Emits a warning when no
2804 2948 * metadata has been fetched yet.
2805 2949 *
2950 + * @param int $mls_id Connection whose enums to read (#277); 0 = current.
2806 2951 * @return array Field key => definition (label, description, type, multiple, values).
2807 2952 */
2808 - public function mlsimport_saas_return_mls_fields() {
2953 + public function mlsimport_saas_return_mls_fields( int $mls_id = 0 ) {
2809 2954
2810 - // Saved MLS enum metadata (JSON); empty until fields have been fetched.
2811 - $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 );
2812 2958
2813 2959 // Warn the user when no metadata is available yet.
2814 2960 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
2815 2961 ?>
@@ -3253,10 +3399,10 @@
3253 3399 . ' peak ' . round( memory_get_peak_usage( true ) / 1048576 ) . 'MB.'
3254 3400 . ( '' !== (string) $result['error'] ? ' Error: ' . (string) $result['error'] : '' );
3255 3401 mlsimport_saas_single_write_import_custom_logs(
3256 3402 'running' === (string) $result['state']
3257 - ? 'Manual import chunk handed off at ' . (int) ( $result['saved'] + $result['failed'] ) . ' listings; next worker queued.' . $worker_trace . PHP_EOL
3258 - : 'Manual import finished with state ' . (string) $result['state'] . '.' . $worker_trace . PHP_EOL,
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,
3259 3405 'manual'
3260 3406 );
3261 3407 gc_collect_cycles();
3262 3408 }
@@ -3375,8 +3521,22 @@
3375 3521 /**
3376 3522 * AJAX: fetch the MLS metadata (theme schema + field data + enums) for the
3377 3523 * configured theme and cache it in options, marking metadata as populated.
3378 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 + *
3379 3539 * @return void
3380 3540 */
3381 3541 public function mlsimport_saas_get_metadata_function() {
3382 3542 // CSRF.
@@ -3383,59 +3543,48 @@
3383 3543 check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
3384 3544 if ( ! current_user_can( 'manage_options' ) ) {
3385 3545 wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to gather MLS metadata.', 'mlsimport' ) ), 403 );
3386 3546 }
3387 - $theme_Start = new ThemeImport();
3388 3547
3389 - // GET /clients?theme_id=<id> to retrieve the schema + MLS metadata.
3390 - $values = array();
3391 - $options = get_option( $this->plugin_name . '_admin_options' );
3392 - $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 );
3393 3550
3394 - $answer = $theme_Start::globalApiRequestSaas( $url, $values, 'GET' );
3395 - // If the API call failed, STOP before touching anything. A failed request
3396 - // returns ['success' => false, ...] with none of the metadata keys; writing
3397 - // that would overwrite the good cached metadata with nothing and mark the
3398 - // site populated with an empty field list. Keep the old cache and the
3399 - // "not populated" state so the next page load retries.
3400 - if ( ! is_array( $answer ) || ! isset( $answer['theme_schema'], $answer['mls_data']['mls_meta_data'], $answer['mls_data']['mls_meta_enums'] ) ) {
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 + }
3565 +
3566 + // One shared gather core, scoped to the resolved connection.
3567 + $gather = mlsimport_gather_connection_metadata( $mls_id );
3568 +
3569 + // A response without the metadata shape changed nothing — retryable 502.
3570 + if ( 'request_failed' === $gather['code'] ) {
3401 3571 wp_send_json_error(
3402 3572 array(
3403 - 'message' => esc_html__( 'Gathering MLS metadata failed. Nothing was changed - it will retry on the next page load.', 'mlsimport' ),
3404 - 'detail' => is_array( $answer ) && isset( $answer['error_message'] ) ? $answer['error_message'] : '',
3573 + 'message' => $gather['message'],
3574 + 'detail' => $gather['detail'],
3405 3575 ),
3406 3576 502
3407 3577 );
3408 3578 }
3409 3579
3410 - // Metadata contains the authoritative provider type stored with this MLS.
3411 - // Record it without moving field_corellation out of the SaaS/Dynamo data.
3412 - if ( isset( $answer['mls_data']['type'], $options['mlsimport_mls_name'] ) ) {
3413 - Mlsimport_Provider_Family::remember_type(
3414 - $answer['mls_data']['type'],
3415 - $options['mlsimport_mls_name']
3416 - );
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 );
3417 3584 }
3418 3585
3419 - // Cache metadata first, then build/reconcile the complete Field
3420 - // Configuration in one server-side save. The browser never posts 1,000
3421 - // individual initialization requests and opening the page remains read-only.
3422 - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
3423 - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
3424 - update_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'] );
3425 -
3426 - $metadata = is_string( $answer['mls_data']['mls_meta_data'] )
3427 - ? json_decode( $answer['mls_data']['mls_meta_data'], true )
3428 - : $answer['mls_data']['mls_meta_data'];
3429 - $metadata = is_array( $metadata ) ? $metadata : array();
3430 - $result = mlsimport_reconcile_field_configuration( $metadata, mlsimport_hardocde_theme_schema() );
3431 - if ( ! $result['success'] ) {
3432 - delete_option( 'mlsimport_mls_metadata_populated' );
3433 - wp_send_json_error( $result, 500 );
3434 - }
3435 -
3436 - update_option( 'mlsimport_mls_metadata_populated', 'yes' );
3437 - wp_send_json_success( array( 'revision' => $result['revision'] ) );
3586 + wp_send_json_success( array( 'revision' => $gather['revision'] ) );
3438 3587 }
3439 3588
3440 3589
3441 3590