| @@ -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 | |