| @@ -2,9 +2,37 @@ | ||
| 2 | 2 | if ( ! defined( 'ABSPATH' ) ) { |
| 3 | 3 | exit; // Exit if accessed directly |
| 4 | 4 | } |
| 5 | 5 | |
| 6 | +/* | |
| 7 | + * --------------------------------------------------------------------------- | |
| 8 | + * FILE ROLE: admin-side controller for the whole plugin. | |
| 9 | + * --------------------------------------------------------------------------- | |
| 10 | + * This ~3,470-line class (Mlsimport_Admin) is the admin monolith referenced in | |
| 11 | + * CLAUDE.md. Its hooks are registered by the core Mlsimport class via the | |
| 12 | + * Loader. Broadly it owns: | |
| 13 | + * - Asset enqueue for wp-admin (styles, field-selector JS, standalone React | |
| 14 | + * settings app, deactivation survey, searchable selects). | |
| 15 | + * - Admin menu + settings pages (main options page, Import History, and the | |
| 16 | + * standalone theme_id 990 "Design Settings" React page). | |
| 17 | + * - Settings registration + validation callbacks (register_setting) for the | |
| 18 | + * several mlsimport_admin_* option groups. | |
| 19 | + * - The mlsimport_item (Import Task) metaboxes: rendering the import-parameter | |
| 20 | + * form and saving its post meta. | |
| 21 | + * - The MLS connection test and SaaS token/metadata retrieval. | |
| 22 | + * SaaS account identifiers accept username or email in the existing field. | |
| 23 | + * - Building the RESO listing-request arguments from an Import Task's meta. | |
| 24 | + * - The import engine: manual (AJAX), hourly cron per item, and the | |
| 25 | + * background/Action Scheduler batch processors. | |
| 26 | + * - The daily reconciliation sweep (delete/keep local listings vs. the MLS | |
| 27 | + * feed) with a truncated-feed safety guard. | |
| 28 | + * - The plugin-deactivation exit survey (~18 AJAX handlers total live here). | |
| 29 | + * NOTE: the enviroment/ directory name is intentionally misspelled plugin-wide; | |
| 30 | + * "env_data" is the active theme adapter, "mls_env_data" the MLS provider one. | |
| 31 | + * --------------------------------------------------------------------------- | |
| 32 | + */ | |
| 6 | 33 | |
| 34 | + | |
| 7 | 35 | /** |
| 8 | 36 | * The admin-specific functionality of the plugin. |
| 9 | 37 | * |
| 10 | 38 | * @link http://mlsimport.com/ |
| @@ -43,14 +71,25 @@ | ||
| 43 | 71 | * @access private |
| 44 | 72 | * @var string $version The current version of this plugin. |
| 45 | 73 | */ |
| 46 | 74 | private $version; |
| 75 | + // Back-reference to the core Mlsimport instance (set externally). | |
| 47 | 76 | public $main; |
| 77 | + // ThemeImport API client instance (OAuth + all SaaS API calls). | |
| 48 | 78 | public $theme_importer; |
| 79 | + // Active theme adapter object (e.g. ResidenceClass); stdClass when no theme. | |
| 49 | 80 | public $env_data; |
| 81 | + // Active MLS provider adapter object; stdClass when none configured. | |
| 50 | 82 | public $mls_env_data; |
| 83 | + /** @var string Clear adapter error checked before the first listings request. */ | |
| 84 | + private $stored_listing_configuration_error = ''; | |
| 85 | + // Reserved handle for a batch/queue processor (declared, assigned elsewhere). | |
| 51 | 86 | protected $process_all; |
| 87 | + // One shared Import Task runner, created only when a caller needs it. | |
| 88 | + private $import_task_execution; | |
| 89 | + // Field-import definition array (populated per request where used). | |
| 52 | 90 | public $field_import; |
| 91 | + // Map of supported theme_id => human name (990 standalone, 991-994 themes). | |
| 53 | 92 | public $themes; |
| 54 | 93 | /** |
| 55 | 94 | * Initialize the class and set its properties. |
| 56 | 95 | * |
| @@ -59,11 +98,13 @@ | ||
| 59 | 98 | * @param string $version The version of this plugin. |
| 60 | 99 | */ |
| 61 | 100 | public function __construct( $plugin_name, $version ) { |
| 62 | 101 | |
| 102 | + // Store the plugin slug (used as the option-key prefix) and version. | |
| 63 | 103 | $this->plugin_name = $plugin_name; |
| 64 | 104 | $this->version = $version; |
| 65 | 105 | |
| 106 | + // RESO fields that are enum/lookup-driven and shown on the Import Task form. | |
| 66 | 107 | $this->field_import = array( |
| 67 | 108 | 'City', |
| 68 | 109 | 'CountyOrParish', |
| 69 | 110 | 'MlsStatus', |
| @@ -73,9 +114,12 @@ | ||
| 73 | 114 | 'InternetEntireListingDisplayYN', |
| 74 | 115 | 'InternetAddressDisplayYN', |
| 75 | 116 | ); |
| 76 | 117 | |
| 118 | + // theme_id => administrator-facing name. Adapter classes are selected by | |
| 119 | + // Mlsimport_Stored_Listing_Adapter_Factory, never derived from these labels. | |
| 77 | 120 | $this->themes = array( |
| 121 | + 990 => 'Standalone', | |
| 78 | 122 | 991 => 'WpResidence', |
| 79 | 123 | 992 => 'Houzez', |
| 80 | 124 | 993 => 'RealHomes', |
| 81 | 125 | 994 => 'Wpestate', |
| @@ -80,104 +124,208 @@ | ||
| 80 | 124 | 993 => 'RealHomes', |
| 81 | 125 | 994 => 'Wpestate', |
| 82 | 126 | ); |
| 83 | 127 | } |
| 128 | + | |
| 84 | 129 | /** |
| 130 | + * Return the one shared Import Task execution module for this request. | |
| 85 | 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. | |
| 86 | 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 | + /** | |
| 147 | + * Wire up the theme and MLS provider adapter objects for this request. | |
| 87 | 148 | * |
| 88 | - * Admin Setup | |
| 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. | |
| 89 | 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 | + * | |
| 162 | + * @param string $plugin_name Plugin slug passed to ThemeImport. | |
| 163 | + * @param string $mls_enviroment Legacy argument retained for call compatibility. | |
| 164 | + * @param string $theme_enviroment Legacy ignored theme-environment name. | |
| 90 | 165 | * @since 1.0.0 |
| 91 | 166 | */ |
| 92 | 167 | public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) { |
| 93 | 168 | |
| 169 | + // Load saved options (MLS id below) and resolve the theme id the site | |
| 170 | + // already reports: saved choice, else detected theme, else standalone. | |
| 94 | 171 | $options = get_option( $this->plugin_name . '_admin_options' ); |
| 95 | - $theme_id = 0; | |
| 96 | - if ( isset( $options['mlsimport_theme_used'] ) ) { | |
| 97 | - $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(); | |
| 98 | 187 | } |
| 99 | - $themes = $this->themes; | |
| 100 | 188 | |
| 101 | - $theme_enviroment = ''; | |
| 102 | - if ( isset( $themes[ $theme_id ] ) ) { | |
| 103 | - $theme_enviroment = $themes[ $theme_id ]; | |
| 104 | - } | |
| 105 | - | |
| 106 | - $this->theme_importer = new ThemeImport( $plugin_name ); | |
| 107 | - | |
| 108 | - $options_api = get_option( $this->plugin_name . '_admin_options' ); | |
| 109 | - | |
| 110 | - if ( '' !== $theme_enviroment ) { | |
| 111 | - $classname = str_replace('Wp','',$theme_enviroment ). 'Class'; | |
| 112 | - $this->env_data = new $classname(); | |
| 113 | - } else { | |
| 114 | - $this->env_data = new stdClass(); | |
| 115 | - } | |
| 116 | - | |
| 117 | - if ( '' !== $mls_enviroment ) { | |
| 118 | - $mls_classname = $mls_enviroment . 'Class'; | |
| 119 | - | |
| 120 | - $this->mls_env_data = new $mls_classname( $this->theme_importer ); | |
| 121 | - } else { | |
| 122 | - $this->mls_env_data = new stdClass(); | |
| 123 | - } | |
| 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 | + ); | |
| 124 | 199 | } |
| 125 | 200 | |
| 126 | 201 | /** |
| 202 | + * Register the stylesheets for the admin area. | |
| 127 | 203 | * |
| 204 | + * Enqueues the main admin CSS plus the onboarding and field-selector styles. | |
| 128 | 205 | * |
| 129 | - * | |
| 130 | - * Register the stylesheets for the admin area. | |
| 131 | - * | |
| 132 | 206 | * @since 1.0.0 |
| 133 | 207 | */ |
| 134 | 208 | public function enqueue_styles() { |
| 209 | + // Main admin stylesheet. | |
| 135 | 210 | wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/mlsimport-admin.css', array(), MLSIMPORT_VERSION, 'all' ); |
| 211 | + // Onboarding wizard styles. | |
| 136 | 212 | wp_enqueue_style( 'mlsimport-onboarding', plugin_dir_url( __FILE__ ) . 'css/mlsimport-onboarding.css', array(), MLSIMPORT_VERSION, 'all' ); |
| 213 | + // Drag-and-drop field selector styles. | |
| 137 | 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 | + } | |
| 138 | 221 | } |
| 139 | 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 | + } | |
| 140 | 238 | |
| 141 | 239 | |
| 142 | 240 | |
| 241 | + | |
| 143 | 242 | /** |
| 243 | + * Register the JavaScript for the admin area. | |
| 144 | 244 | * |
| 245 | + * Enqueues the core admin script, the single Field Configuration controller, | |
| 246 | + * and conditionally (by page/hook) injects inline bootstraps for | |
| 247 | + * metadata fetch and MLS autocomplete, plus the searchable-select and | |
| 248 | + * deactivation-survey scripts on their respective screens. | |
| 145 | 249 | * |
| 146 | - * | |
| 147 | - * Register the JavaScript for the admin area. | |
| 148 | - * | |
| 250 | + * @param string $hook_suffix Current admin page hook suffix. | |
| 149 | 251 | * @since 1.0.0 |
| 150 | 252 | */ |
| 151 | 253 | public function enqueue_scripts($hook_suffix) { |
| 254 | + // jQuery UI autocomplete backs the MLS-name search box. | |
| 152 | 255 | wp_enqueue_script( 'jquery-ui-autocomplete' ); |
| 256 | + // Pull the cached MLS list (used later for the autocomplete bootstrap). | |
| 153 | 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 | + ); | |
| 288 | + // Core admin script + AJAX endpoint. | |
| 154 | 289 | wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true ); |
| 155 | 290 | wp_localize_script( |
| 156 | 291 | 'mlsimport-admin', |
| 157 | 292 | 'mlsimport_vars', |
| 158 | 293 | array( |
| 159 | - 'ajax_url' => admin_url( 'admin-ajax.php' ) | |
| 294 | + 'ajax_url' => admin_url( 'admin-ajax.php' ), | |
| 295 | + 'provider_families' => $provider_browser_config, | |
| 160 | 296 | ) |
| 161 | 297 | ); |
| 162 | 298 | |
| 163 | - 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 ); | |
| 164 | - | |
| 165 | - // Pass AJAX parameters to script | |
| 166 | - wp_localize_script( 'mlsimport-field-selector', 'mlsimport_params', array( | |
| 167 | - 'ajax_url' => admin_url( 'admin-ajax.php' ), | |
| 168 | - 'nonce' => wp_create_nonce( 'mlsimport_field_selector_nonce' ) | |
| 169 | - )); | |
| 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 | + ); | |
| 170 | 315 | |
| 171 | 316 | |
| 172 | - wp_enqueue_script( 'mlsimport-progressive-save', plugin_dir_url( ( __FILE__ ) ) . 'js/progressive-save.js', array('mlsimport-field-selector' ), '1.0.0', true ); | |
| 173 | - | |
| 174 | 317 | |
| 175 | - | |
| 176 | - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix && | |
| 177 | - isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && | |
| 318 | + // On the settings page Field Options tab: if metadata was never fetched, | |
| 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. | |
| 323 | + if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix && | |
| 324 | + isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && | |
| 178 | 325 | isset($_GET['tab']) && $_GET['tab'] === 'field_options') { |
| 179 | - $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 ); | |
| 180 | 328 | if ( 'yes' !== $mlsimport_mls_metadata_populated ) { |
| 181 | 329 | $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });'; |
| 182 | 330 | wp_add_inline_script('mlsimport-admin', $inline_script); |
| 183 | 331 | } |
| @@ -182,13 +330,21 @@ | ||
| 182 | 330 | wp_add_inline_script('mlsimport-admin', $inline_script); |
| 183 | 331 | } |
| 184 | 332 | } |
| 185 | 333 | |
| 186 | - if ( | |
| 187 | - 'admin_page_mlsimport-onboarding' === $hook_suffix && | |
| 188 | - isset($_GET['page']) && $_GET['page'] === 'mlsimport-onboarding' | |
| 189 | - ) { | |
| 190 | - $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', '' ); | |
| 191 | 347 | if ('yes' !== $mlsimport_mls_metadata_populated) { |
| 192 | 348 | $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });'; |
| 193 | 349 | wp_add_inline_script('mlsimport-admin', $inline_script); |
| 194 | 350 | } |
| @@ -193,20 +349,98 @@ | ||
| 193 | 349 | wp_add_inline_script('mlsimport-admin', $inline_script); |
| 194 | 350 | } |
| 195 | 351 | } |
| 196 | 352 | |
| 197 | - | |
| 198 | 353 | |
| 199 | 354 | |
| 200 | - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix && | |
| 201 | - ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') || | |
| 202 | - (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) { | |
| 203 | - | |
| 204 | - $mls_import_list = mlsimport_saas_request_list(); | |
| 205 | - if(!is_array($mls_import_list)){ | |
| 206 | - $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });'; | |
| 207 | - wp_add_inline_script('mlsimport-admin', $inline_script); | |
| 208 | - } | |
| 355 | + | |
| 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 | + ); | |
| 209 | 443 | } |
| 210 | 444 | |
| 211 | 445 | // Searchable City/County multi-select — only on the Import Task edit screen. |
| 212 | 446 | $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; |
| @@ -216,8 +450,9 @@ | ||
| 216 | 450 | } |
| 217 | 451 | |
| 218 | 452 | // Deactivation exit survey — only needed on the Plugins screen. |
| 219 | 453 | if ( 'plugins.php' === $hook_suffix ) { |
| 454 | + // Enqueue the survey modal script and hand it the nonce, options and i18n. | |
| 220 | 455 | wp_enqueue_script( 'mlsimport-deactivation-survey', plugin_dir_url( __FILE__ ) . 'js/mlsimport-deactivation-survey.js', array( 'jquery' ), MLSIMPORT_VERSION, true ); |
| 221 | 456 | wp_localize_script( 'mlsimport-deactivation-survey', 'mlsimport_deact_survey', array( |
| 222 | 457 | 'ajax_url' => admin_url( 'admin-ajax.php' ), |
| 223 | 458 | 'nonce' => wp_create_nonce( 'mlsimport_exit_survey' ), |
| @@ -239,16 +474,18 @@ | ||
| 239 | 474 | |
| 240 | 475 | |
| 241 | 476 | |
| 242 | 477 | /** |
| 478 | + * Register the administration menu for this plugin into the WordPress Dashboard menu. | |
| 243 | 479 | * |
| 480 | + * Adds the top-level "MLS Import Settings" page and the "Import History" | |
| 481 | + * submenu; in standalone mode it also adds the separate "Design Settings" | |
| 482 | + * React page and enqueues its bundle only on that hook. | |
| 244 | 483 | * |
| 245 | - * | |
| 246 | - * Register the administration menu for this plugin into the WordPress Dashboard menu. | |
| 247 | - * | |
| 248 | 484 | * @since 1.0.0 |
| 249 | 485 | */ |
| 250 | 486 | public function add_plugin_admin_menu() { |
| 487 | + // Top-level settings menu (capability: administrator). | |
| 251 | 488 | add_menu_page( |
| 252 | 489 | esc_html__( 'MLS Import Settings', 'mlsimport'), |
| 253 | 490 | esc_html__( 'MLS Import Settings', 'mlsimport' ), |
| 254 | 491 | 'administrator', |
| @@ -254,11 +491,16 @@ | ||
| 254 | 491 | 'administrator', |
| 255 | 492 | 'mlsimport_plugin_options', |
| 256 | 493 | array( $this, 'display_plugin_setup_page' ), |
| 257 | 494 | MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png', |
| 258 | - 22 | |
| 495 | + // Fractional slot right after Import Tasks (21). Fractions are only | |
| 496 | + // honoured by add_menu_page, so the two settings pages take 21.1/21.2 | |
| 497 | + // and leave integer slots 22/23 for the Properties/Agents CPTs — keeping | |
| 498 | + // all MLSImport menus grouped above core Comments (25). | |
| 499 | + 21.1 | |
| 259 | 500 | ); |
| 260 | 501 | |
| 502 | + // Import History submenu under the settings menu. | |
| 261 | 503 | add_submenu_page( |
| 262 | 504 | 'mlsimport_plugin_options', |
| 263 | 505 | esc_html__( 'Import History', 'mlsimport' ), |
| 264 | 506 | esc_html__( 'Import History', 'mlsimport' ), |
| @@ -265,16 +507,187 @@ | ||
| 265 | 507 | 'administrator', |
| 266 | 508 | 'mlsimport_history', |
| 267 | 509 | array( $this, 'display_history_page' ) |
| 268 | 510 | ); |
| 511 | + | |
| 512 | + // Standalone (theme_id 990) front-end design. Its own top-level menu, | |
| 513 | + // deliberately separate from MLS import settings because it controls | |
| 514 | + // the public-facing visuals. React app; see admin/settings-app/. | |
| 515 | + if ( function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode() ) { | |
| 516 | + // Separate top-level menu for the standalone front-end design app. | |
| 517 | + $standalone_hook = add_menu_page( | |
| 518 | + esc_html__( 'MLS Import Design Settings', 'mlsimport' ), | |
| 519 | + esc_html__( 'MLS Import Design Settings', 'mlsimport' ), | |
| 520 | + 'manage_options', | |
| 521 | + 'mlsimport_standalone_settings', | |
| 522 | + array( $this, 'display_standalone_settings_page' ), | |
| 523 | + MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png', | |
| 524 | + 21.2 | |
| 525 | + ); | |
| 526 | + | |
| 527 | + // Load the React bundle only when this exact page hook is rendering. | |
| 528 | + add_action( | |
| 529 | + 'admin_enqueue_scripts', | |
| 530 | + function ( $current_hook ) use ( $standalone_hook ) { | |
| 531 | + if ( $current_hook === $standalone_hook ) { | |
| 532 | + $this->enqueue_standalone_settings_app(); | |
| 533 | + } | |
| 534 | + } | |
| 535 | + ); | |
| 536 | + } | |
| 269 | 537 | } |
| 270 | 538 | |
| 271 | 539 | /** |
| 540 | + * Render the Standalone Design page — just the React mount point. All | |
| 541 | + * fields, save and validation live in the app (admin/settings-app/) and the | |
| 542 | + * settings REST endpoint. | |
| 543 | + * | |
| 544 | + * @return void | |
| 545 | + */ | |
| 546 | + public function display_standalone_settings_page() { | |
| 547 | + echo '<div class="wrap">'; | |
| 548 | + echo '<h1>' . esc_html__( 'MLS Import Design Settings', 'mlsimport' ) . '</h1>'; | |
| 549 | + echo '<div id="mlsimport-standalone-app"></div>'; | |
| 550 | + echo '</div>'; | |
| 551 | + } | |
| 552 | + | |
| 553 | + /** | |
| 554 | + * Enqueue the compiled Standalone Design React bundle and its WP component | |
| 555 | + * styles. Dependencies + cache-busting version come from the build's | |
| 556 | + * generated index.asset.php. | |
| 557 | + * | |
| 558 | + * @return void | |
| 559 | + */ | |
| 560 | + private function enqueue_standalone_settings_app() { | |
| 561 | + // The build emits index.asset.php with dependencies + a content hash; | |
| 562 | + // bail quietly if the app was never built. | |
| 563 | + $asset_file = MLSIMPORT_PLUGIN_PATH . 'admin/settings-app/build/index.asset.php'; | |
| 564 | + if ( ! file_exists( $asset_file ) ) { | |
| 565 | + return; | |
| 566 | + } | |
| 567 | + $asset = require $asset_file; | |
| 568 | + | |
| 569 | + // The MLS-logo control opens the native WordPress media modal (wp.media). | |
| 570 | + wp_enqueue_media(); | |
| 571 | + | |
| 572 | + // wp-color-picker (Iris) powers the native colour control in the React app; | |
| 573 | + // it pulls in jQuery + iris, so the window.jQuery global is available. | |
| 574 | + wp_enqueue_script( | |
| 575 | + 'mlsimport-standalone-settings', | |
| 576 | + MLSIMPORT_PLUGIN_URL . 'admin/settings-app/build/index.js', | |
| 577 | + array_merge( $asset['dependencies'], array( 'wp-color-picker' ) ), | |
| 578 | + $asset['version'], | |
| 579 | + true | |
| 580 | + ); | |
| 581 | + // Enable JS translation loading for the app's strings. | |
| 582 | + wp_set_script_translations( 'mlsimport-standalone-settings', 'mlsimport' ); | |
| 583 | + | |
| 584 | + // The field tree the React app renders from — tabs/sub-tabs/fields generated | |
| 585 | + // from the ONE registry (mlsimport_standalone_settings_app_config). The app | |
| 586 | + // reads window.mlsimportFields instead of a hard-coded list, so a field added | |
| 587 | + // to the registry appears here (and, via the schema, in the Customizer) with | |
| 588 | + // no JS change. | |
| 589 | + if ( function_exists( 'mlsimport_standalone_settings_app_config' ) ) { | |
| 590 | + wp_add_inline_script( | |
| 591 | + 'mlsimport-standalone-settings', | |
| 592 | + 'window.mlsimportFields = ' . wp_json_encode( mlsimport_standalone_settings_app_config() ) . ';', | |
| 593 | + 'before' | |
| 594 | + ); | |
| 595 | + } | |
| 596 | + | |
| 597 | + // Feed the "Arrange Sections" control its catalog (slug + label) from the | |
| 598 | + // property section registry, so the list matches what the front end renders. | |
| 599 | + if ( function_exists( 'mlsimport_standalone_section_catalog' ) ) { | |
| 600 | + $catalog = array(); | |
| 601 | + foreach ( mlsimport_standalone_section_catalog() as $slug => $label ) { | |
| 602 | + $catalog[] = array( 'slug' => $slug, 'label' => $label ); | |
| 603 | + } | |
| 604 | + wp_add_inline_script( | |
| 605 | + 'mlsimport-standalone-settings', | |
| 606 | + 'window.mlsimportSections = ' . wp_json_encode( $catalog ) . ';', | |
| 607 | + 'before' | |
| 608 | + ); | |
| 609 | + } | |
| 610 | + | |
| 611 | + // The Overview "Arrange Fields" control reads the Overview tile catalog — the | |
| 612 | + // stat tiles the Overview section can draw (Updated, MLS #, Bedrooms, …). | |
| 613 | + if ( function_exists( 'mlsimport_standalone_overview_fields_catalog' ) ) { | |
| 614 | + $overview_fields = array(); | |
| 615 | + foreach ( mlsimport_standalone_overview_fields_catalog() as $slug => $label ) { | |
| 616 | + $overview_fields[] = array( 'slug' => $slug, 'label' => $label ); | |
| 617 | + } | |
| 618 | + wp_add_inline_script( | |
| 619 | + 'mlsimport-standalone-settings', | |
| 620 | + 'window.mlsimportOverviewFields = ' . wp_json_encode( $overview_fields ) . ';', | |
| 621 | + 'before' | |
| 622 | + ); | |
| 623 | + } | |
| 624 | + | |
| 625 | + // The agent "Arrange Sections" control reads its own catalog (the agent page's | |
| 626 | + // reorderable content-column sections), kept separate from the property catalog. | |
| 627 | + if ( function_exists( 'mlsimport_standalone_agent_section_catalog' ) ) { | |
| 628 | + $agent_catalog = array(); | |
| 629 | + foreach ( mlsimport_standalone_agent_section_catalog() as $slug => $label ) { | |
| 630 | + $agent_catalog[] = array( 'slug' => $slug, 'label' => $label ); | |
| 631 | + } | |
| 632 | + wp_add_inline_script( | |
| 633 | + 'mlsimport-standalone-settings', | |
| 634 | + 'window.mlsimportAgentSections = ' . wp_json_encode( $agent_catalog ) . ';', | |
| 635 | + 'before' | |
| 636 | + ); | |
| 637 | + } | |
| 638 | + | |
| 639 | + // The archive "Taxonomy filters" control reads its own catalog (the search | |
| 640 | + // form's toggleable filter fields), so the on/off toggle list matches what | |
| 641 | + // the taxonomy/CPT archive search bar can render. | |
| 642 | + if ( function_exists( 'mlsimport_standalone_archive_filters_catalog' ) ) { | |
| 643 | + $archive_filters = array(); | |
| 644 | + foreach ( mlsimport_standalone_archive_filters_catalog() as $slug => $label ) { | |
| 645 | + $archive_filters[] = array( 'slug' => $slug, 'label' => $label ); | |
| 646 | + } | |
| 647 | + wp_add_inline_script( | |
| 648 | + 'mlsimport-standalone-settings', | |
| 649 | + 'window.mlsimportArchiveFilters = ' . wp_json_encode( $archive_filters ) . ';', | |
| 650 | + 'before' | |
| 651 | + ); | |
| 652 | + } | |
| 653 | + | |
| 654 | + // The saved MLS logo's preview URL, so the media control can show the | |
| 655 | + // current image before the user opens the picker. | |
| 656 | + if ( function_exists( 'mlsimport_standalone_mls_logo_url' ) ) { | |
| 657 | + wp_add_inline_script( | |
| 658 | + 'mlsimport-standalone-settings', | |
| 659 | + 'window.mlsimportLogoUrl = ' . wp_json_encode( mlsimport_standalone_mls_logo_url() ) . ';', | |
| 660 | + 'before' | |
| 661 | + ); | |
| 662 | + } | |
| 663 | + | |
| 664 | + // Component + color-picker styles the React controls rely on, then the | |
| 665 | + // app's own stylesheet. Cache-bust by file mtime so edits to the CSS are | |
| 666 | + // picked up immediately — the plugin version (MLSIMPORT_VERSION) doesn't | |
| 667 | + // change between design tweaks, so keying the ?ver on it left browsers | |
| 668 | + // serving a stale cached copy under the same URL. | |
| 669 | + $standalone_css_path = MLSIMPORT_PLUGIN_PATH . 'admin/css/mlsimport-standalone-settings.css'; | |
| 670 | + $standalone_css_ver = file_exists( $standalone_css_path ) | |
| 671 | + ? (string) filemtime( $standalone_css_path ) | |
| 672 | + : ( defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : false ); | |
| 673 | + wp_enqueue_style( 'wp-components' ); | |
| 674 | + wp_enqueue_style( 'wp-color-picker' ); | |
| 675 | + wp_enqueue_style( | |
| 676 | + 'mlsimport-standalone-settings', | |
| 677 | + MLSIMPORT_PLUGIN_URL . 'admin/css/mlsimport-standalone-settings.css', | |
| 678 | + array( 'wp-components' ), | |
| 679 | + $standalone_css_ver | |
| 680 | + ); | |
| 681 | + } | |
| 682 | + | |
| 683 | + /** | |
| 272 | 684 | * Renders the Import History admin page. |
| 273 | 685 | * |
| 274 | 686 | * @return void |
| 275 | 687 | */ |
| 276 | 688 | public function display_history_page() { |
| 689 | + // Delegates the whole page to the history partial template. | |
| 277 | 690 | include_once plugin_dir_path( __FILE__ ) . 'partials/mlsimport-history.php'; |
| 278 | 691 | } |
| 279 | 692 | |
| 280 | 693 | |
| @@ -284,16 +697,16 @@ | ||
| 284 | 697 | |
| 285 | 698 | |
| 286 | 699 | |
| 287 | 700 | /** |
| 701 | + * Add a "Settings" action link to this plugin's row on the Plugins page. | |
| 288 | 702 | * |
| 289 | - * | |
| 290 | - * | |
| 291 | - * Add settings action link to the plugins page. | |
| 292 | - * | |
| 703 | + * @param array $links Existing plugin action links. | |
| 704 | + * @return array Links with the Settings link prepended. | |
| 293 | 705 | * @since 1.0.0 |
| 294 | 706 | */ |
| 295 | 707 | public function add_action_links( $links ) { |
| 708 | + // Build the Settings link and place it before the default action links. | |
| 296 | 709 | $settings_link = array( |
| 297 | 710 | '<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>', |
| 298 | 711 | ); |
| 299 | 712 | return array_merge( $settings_link, $links ); |
| @@ -306,15 +719,16 @@ | ||
| 306 | 719 | |
| 307 | 720 | |
| 308 | 721 | |
| 309 | 722 | /** |
| 723 | + * Render the main settings page for this plugin. | |
| 310 | 724 | * |
| 725 | + * Loads the admin-display partial (whose filename is prefixed with the slug). | |
| 311 | 726 | * |
| 312 | - * Render the settings page for this plugin. | |
| 313 | - * | |
| 314 | 727 | * @since 1.0.0 |
| 315 | 728 | */ |
| 316 | 729 | public function display_plugin_setup_page() { |
| 730 | + // Delegates the whole page to the slug-prefixed admin-display partial. | |
| 317 | 731 | include_once 'partials/' . $this->plugin_name . '-admin-display.php'; |
| 318 | 732 | } |
| 319 | 733 | |
| 320 | 734 | |
| @@ -322,16 +736,29 @@ | ||
| 322 | 736 | |
| 323 | 737 | |
| 324 | 738 | |
| 325 | 739 | /** |
| 740 | + * Sanitize/whitelist the main plugin options on save (register_setting callback). | |
| 326 | 741 | * |
| 742 | + * Copies only the known keys from $input (esc_attr'd), then invalidates the | |
| 743 | + * connection-test / metadata flags and cached tokens/schema so the next page | |
| 744 | + * load re-tests the connection with the new credentials. | |
| 327 | 745 | * |
| 328 | - * Validate plugin options fields | |
| 329 | - * | |
| 746 | + * @param array $input Raw submitted options. | |
| 747 | + * @return array Whitelisted, escaped options. | |
| 330 | 748 | * @since 1.0.0 |
| 331 | 749 | */ |
| 332 | 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 | + : ''; | |
| 333 | 758 | |
| 759 | + // Whitelist of accepted option keys (value = label/help metadata, unused | |
| 760 | + // beyond documentation here); anything not listed is dropped on save. | |
| 334 | 761 | $valid = array(); |
| 335 | 762 | $settings_list = array( |
| 336 | 763 | 'auth_username' => array( |
| 337 | 764 | 'name' => esc_html__( 'Api auth_username ', 'mlsimport' ), |
| @@ -361,9 +788,9 @@ | ||
| 361 | 788 | 'name' => esc_html__( 'title_format', 'mlsimport' ), |
| 362 | 789 | 'details' => 'to be added', |
| 363 | 790 | ), |
| 364 | 791 | 'mlsimport_username' => array( |
| 365 | - 'name' => esc_html__( 'MLSImport.com Username (not your email)', 'mlsimport' ), | |
| 792 | + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ), | |
| 366 | 793 | 'details' => 'to be added', |
| 367 | 794 | ), |
| 368 | 795 | 'mlsimport_password' => array( |
| 369 | 796 | 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ), |
| @@ -459,15 +886,37 @@ | ||
| 459 | 886 | 'details' => 'to be added', |
| 460 | 887 | ), |
| 461 | 888 | ); |
| 462 | 889 | |
| 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. | |
| 463 | 894 | foreach ( $settings_list as $key => $setting ) { |
| 464 | - $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 | + } | |
| 465 | 902 | } |
| 466 | 903 | |
| 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. | |
| 467 | 915 | delete_option( 'mlsimport_connection_test' ); |
| 468 | - delete_option( 'mlsimport_mls_metadata_populated' ); | |
| 916 | + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id ); | |
| 469 | 917 | |
| 918 | + // Reset cached encoding and drop cached token/schema transients. | |
| 470 | 919 | update_option( 'mlsimport_encoding_array', '' ); |
| 471 | 920 | delete_transient( 'mlsimport_token_request' ); |
| 472 | 921 | delete_transient( 'mlsimport_schema' ); |
| 473 | 922 | delete_transient( 'mlsimport_plugin_data_schema' ); |
| @@ -480,49 +929,24 @@ | ||
| 480 | 929 | |
| 481 | 930 | |
| 482 | 931 | |
| 483 | 932 | /** |
| 933 | + * Validate the MLS-sync option group on save (register_setting callback). | |
| 484 | 934 | * |
| 935 | + * Copies a fixed whitelist of sync/import parameter keys straight through. | |
| 485 | 936 | * |
| 486 | - * Validate admin fields | |
| 487 | - * | |
| 937 | + * @param array $input Raw submitted sync settings. | |
| 938 | + * @return array Whitelisted sync settings. | |
| 488 | 939 | * @since 1.0.0 |
| 489 | 940 | */ |
| 490 | - public function validate_admin_fields_select( $input ) { | |
| 491 | - $valid = array(); | |
| 492 | - | |
| 493 | - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' ); | |
| 494 | - $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true ); | |
| 495 | - | |
| 496 | - foreach ( $metadata_api_call as $key => $value ) { | |
| 497 | - if ( isset( $input['mls-fields'][ $key ] ) ) { | |
| 498 | - $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] ); | |
| 499 | - } | |
| 500 | - | |
| 501 | - if ( isset( $input['mls-fields-admin'][ $key ] ) ) { | |
| 502 | - $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] ); | |
| 503 | - $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] ); | |
| 504 | - $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] ); | |
| 505 | - $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] ); | |
| 506 | - $valid['field_order'][ $key ] = esc_attr( $input['field_order'][ $key ] ); | |
| 507 | - } | |
| 508 | - } | |
| 509 | - //$valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] ); | |
| 510 | - return $valid; | |
| 511 | - } | |
| 512 | - | |
| 513 | - /** | |
| 514 | - * | |
| 515 | - * | |
| 516 | - * Validate Mls Sync fields | |
| 517 | - * | |
| 518 | - * @since 1.0.0 | |
| 519 | - */ | |
| 520 | 941 | public function validate_admin_mls_sync( $input ) { |
| 521 | 942 | $valid = array(); |
| 522 | 943 | |
| 523 | - $field_import = array( 'force_rand', 'min_price', 'max_price', 'title_format', 'property_agent', 'property_user', 'City', 'City_check', 'CountyOrParish', 'CountyOrParish_check', 'MlsStatus', 'MlsStatus_check', 'PropertySubType', 'PropertySubType_check', 'PropertyType', 'PropertyType_check', | |
| 944 | + // Fixed whitelist of sync parameters (price, title, agent/user, the enum | |
| 945 | + // filters and their "select-all" _check flags). | |
| 946 | + $field_import = array( 'force_rand', 'min_price', 'max_price', 'title_format', 'property_agent', 'property_user', 'City', 'City_check', 'CountyOrParish', 'CountyOrParish_check', 'MlsStatus', 'MlsStatus_check', 'PropertySubType', 'PropertySubType_check', 'PropertyType', 'PropertyType_check', | |
| 524 | 947 | 'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' ); |
| 948 | + // Pass each whitelisted key through unchanged. | |
| 525 | 949 | foreach ( $field_import as $key ) { |
| 526 | 950 | $valid[ $key ] = $input[ $key ]; |
| 527 | 951 | } |
| 528 | 952 | |
| @@ -529,110 +953,40 @@ | ||
| 529 | 953 | return $valid; |
| 530 | 954 | } |
| 531 | 955 | |
| 532 | 956 | |
| 533 | - /** | |
| 534 | - * | |
| 535 | - * | |
| 536 | - * Validate Administrative options | |
| 537 | - * | |
| 538 | - * @since 1.0.0 | |
| 539 | - */ | |
| 540 | - public function validate_administrative_options( $input ) { | |
| 541 | 957 | |
| 542 | - $valid = array(); | |
| 543 | - | |
| 544 | - $field_import = array( 'import' ); | |
| 545 | - foreach ( $field_import as $key ) { | |
| 546 | - $valid[ $key ] = $input[ $key ]; | |
| 547 | - } | |
| 548 | - | |
| 549 | - return $valid; | |
| 550 | - } | |
| 551 | - | |
| 552 | 958 | /** |
| 553 | - * | |
| 554 | - * | |
| 555 | - * | |
| 556 | - * Validate Import Options fields | |
| 557 | - * | |
| 558 | - * @since 1.0.0 | |
| 959 | + * Register all plugin option groups with the Settings API and bind each to | |
| 960 | + * its validation callback. Hooked on admin_init. | |
| 559 | 961 | */ |
| 560 | - public function validate_admin_import_options( $input ) { | |
| 561 | - $valid = array(); | |
| 562 | - | |
| 563 | - $field_import = array( 'import_number' ); | |
| 564 | - foreach ( $field_import as $key ) { | |
| 565 | - $valid[ $key ] = intval( $input[ $key ] ); | |
| 566 | - } | |
| 567 | - | |
| 568 | - if ( isset( $input['import'] ) && '' !== $input['import'] ) { | |
| 569 | - $decode = json_decode( $input['import'] ); | |
| 570 | - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] ); | |
| 571 | - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] ); | |
| 572 | - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] ); | |
| 573 | - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] ); | |
| 574 | - } | |
| 575 | - | |
| 576 | - return $valid; | |
| 577 | - } | |
| 578 | - | |
| 579 | - | |
| 580 | - | |
| 581 | - | |
| 582 | - | |
| 583 | - | |
| 584 | - /** | |
| 585 | - * | |
| 586 | - * | |
| 587 | - * plugin options update | |
| 588 | - */ | |
| 589 | 962 | public function options_update() { |
| 963 | + // Field Configuration is intentionally absent: it is form-free and only the | |
| 964 | + // deep module's compact command endpoint may mutate its option. | |
| 590 | 965 | register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) ); |
| 591 | - register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) ); | |
| 592 | 966 | register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) ); |
| 593 | - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) ); | |
| 594 | - register_setting( $this->plugin_name . '_administrative_options', $this->plugin_name . '_administrative_options', array( $this, 'validate_administrative_options' ) ); | |
| 967 | + // The standalone option is registered in class-mlsimport-standalone-settings.php | |
| 968 | + // (on init, with show_in_rest) so the dedicated React design page can read/write it. | |
| 595 | 969 | } |
| 596 | 970 | |
| 597 | - | |
| 598 | - | |
| 599 | 971 | /** |
| 600 | - * | |
| 601 | - * | |
| 602 | - * | |
| 603 | - * | |
| 604 | - * | |
| 605 | - * | |
| 606 | - * | |
| 972 | + * Update-option hook for the field-select group: ask the active theme | |
| 973 | + * adapter to (re)register its custom fields/taxonomies for the mapped fields. | |
| 607 | 974 | */ |
| 608 | - public function update_option_mlsimport_administrative_options() { | |
| 609 | - $import = get_option( 'mlsimport_administrative_options' ); | |
| 610 | - if ( '' !== $import ) { | |
| 611 | - $decode = json_decode( $import['import'], true ); | |
| 612 | - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] ); | |
| 613 | - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] ); | |
| 614 | - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] ); | |
| 615 | - } | |
| 616 | - } | |
| 617 | - | |
| 618 | - /** | |
| 619 | - * | |
| 620 | - * | |
| 621 | - * plugin options update | |
| 622 | - */ | |
| 623 | 975 | public function update_option_mlsimport_admin_fields_select() { |
| 624 | 976 | |
| 977 | + // Delegate to the theme adapter to sync its custom fields. | |
| 625 | 978 | $this->env_data->enviroment_custom_fields( $this->plugin_name ); |
| 626 | 979 | } |
| 627 | 980 | |
| 628 | 981 | |
| 629 | 982 | /** |
| 983 | + * Register the "Hidden Fields" metabox on the theme's property post type. | |
| 630 | 984 | * |
| 631 | - * | |
| 632 | - * plugin options update | |
| 985 | + * Only added when the theme adapter exposes get_property_post_type(). | |
| 633 | 986 | */ |
| 634 | 987 | public function mlsimport_meta_options() { |
| 988 | + // Add the metabox to whatever post type the active theme uses for listings. | |
| 635 | 989 | if ( method_exists( $this->env_data, 'get_property_post_type' ) ) { |
| 636 | 990 | add_meta_box( 'mlsimport_hidden_fields', esc_html__( 'Mls Import Hidden Fields', 'mlsimport' ), array( $this, 'mlsimport_hidden_fields' ), $this->env_data->get_property_post_type(), 'normal', 'low' ); |
| 637 | 991 | } |
| 638 | 992 | } |
| @@ -637,22 +991,27 @@ | ||
| 637 | 991 | } |
| 638 | 992 | } |
| 639 | 993 | |
| 640 | 994 | /** |
| 995 | + * Render the "Hidden Fields" metabox for a single property post. | |
| 641 | 996 | * |
| 642 | - * | |
| 643 | - * plugin options update | |
| 997 | + * Shows the ListingKey, the source Import Task (inserted/updated), any | |
| 998 | + * protected statuses, every admin-flagged imported field value, and the | |
| 999 | + * property change history. | |
| 644 | 1000 | */ |
| 645 | 1001 | public function mlsimport_hidden_fields() { |
| 646 | 1002 | global $post; |
| 647 | 1003 | |
| 648 | - $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(); | |
| 649 | 1006 | |
| 1007 | + // Which Import Task created / last updated this property, and its RESO key. | |
| 650 | 1008 | $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true ); |
| 651 | 1009 | $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true ); |
| 652 | - $listing_key = get_post_meta( $post->ID, 'ListingKey', true ); | |
| 1010 | + $listing_key = get_post_meta( $post->ID, '_mlsimport_listing_key', true ); | |
| 653 | 1011 | |
| 654 | 1012 | // Get the import task ID to retrieve protected statuses |
| 1013 | + // (prefer the inserting task, fall back to the updating task). | |
| 655 | 1014 | $import_task_id = !empty( $MLSimport_item_inserted ) ? $MLSimport_item_inserted : ( !empty( $MLSimport_item_updated ) ? $MLSimport_item_updated : null ); |
| 656 | 1015 | $mlsImportItemStatusProtect = $import_task_id ? get_post_meta( $import_task_id, 'mlsimport_item_standardstatusprotect', true ) : null; |
| 657 | 1016 | |
| 658 | 1017 | // Check if the ListingKey exists |
| @@ -669,8 +1028,9 @@ | ||
| 669 | 1028 | if ( !empty( $MLSimport_item_updated ) ) { |
| 670 | 1029 | echo 'Updated via MLS item id: ' . esc_html( $MLSimport_item_updated ) . ' - ' . esc_html( get_the_title( $MLSimport_item_updated ) ) . '<br>'; |
| 671 | 1030 | } |
| 672 | 1031 | |
| 1032 | + // Show any protected statuses (array or scalar) configured on the task. | |
| 673 | 1033 | if(!empty($mlsImportItemStatusProtect)) { |
| 674 | 1034 | if(is_array($mlsImportItemStatusProtect)) { |
| 675 | 1035 | echo 'Protected statuses: ' . esc_html( implode(', ', $mlsImportItemStatusProtect) ) . '<br>'; |
| 676 | 1036 | } else { |
| @@ -677,24 +1037,34 @@ | ||
| 677 | 1037 | echo 'Protected statuses: ' . esc_html($mlsImportItemStatusProtect) . '<br>'; |
| 678 | 1038 | } |
| 679 | 1039 | } |
| 680 | 1040 | |
| 681 | - foreach ( $options['mls-fields-admin'] as $key => $value ) { | |
| 1041 | + // Print each admin-flagged field: label + stored meta value. | |
| 1042 | + foreach ( ( is_array( $options ) && ! empty( $options['mls-fields-admin'] ) ? $options['mls-fields-admin'] : array() ) as $key => $value ) { | |
| 1043 | + // Only fields explicitly marked for admin display (flag === 1). | |
| 682 | 1044 | if ( 1 === intval($options['mls-fields-admin'][ $key ] ) ) { |
| 1045 | + // Prefer a custom label if one was set for this field. | |
| 683 | 1046 | $display_label = $key; |
| 684 | 1047 | if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) { |
| 685 | 1048 | $display_label = $options['mls-fields-label'][ $key ]; |
| 686 | 1049 | } |
| 687 | 1050 | |
| 688 | - if ( 'ListingKey' !== $key ) { | |
| 689 | - $meta_key = strtolower( $key ); | |
| 1051 | + // Resolve the stored value. Standalone (990) stores every imported | |
| 1052 | + // field as mlsimport_<Field> (with an _x_ fallback); the theme modes | |
| 1053 | + // store them lowercase (except ListingKey). Reading the wrong casing | |
| 1054 | + // is why hidden fields (e.g. ParcelNumber) showed here without a value. | |
| 1055 | + if ( function_exists( 'mlsimport_is_standalone_mode' ) && mlsimport_is_standalone_mode() && function_exists( 'mlsimport_property_field_value' ) ) { | |
| 1056 | + $field_value = mlsimport_property_field_value( (int) $post->ID, (string) $key ); | |
| 690 | 1057 | } else { |
| 691 | - $meta_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'; | |
| 1061 | + $field_value = (string) get_post_meta( $post->ID, $meta_key, true ); | |
| 692 | 1062 | } |
| 693 | 1063 | ?> |
| 694 | 1064 | |
| 695 | 1065 | <strong><?php echo esc_html($display_label);?>:</strong> |
| 696 | - <?php echo esc_html( get_post_meta( $post->ID, $meta_key, true ) ); ?> </br> | |
| 1066 | + <?php echo esc_html( $field_value ); ?> </br> | |
| 697 | 1067 | <?php |
| 698 | 1068 | } |
| 699 | 1069 | } |
| 700 | 1070 | ?> |
| @@ -699,9 +1069,10 @@ | ||
| 699 | 1069 | } |
| 700 | 1070 | ?> |
| 701 | 1071 | |
| 702 | 1072 | <h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2> |
| 703 | - <?php | |
| 1073 | + <?php | |
| 1074 | + // Property change history (only populated when history logging is enabled). | |
| 704 | 1075 | $meta = get_post_meta( $post->ID, 'mlsimport_property_history', true ); |
| 705 | 1076 | if ( '' === trim( $meta ) ) { ?> |
| 706 | 1077 | <strong>Property history is blank - you can enable it in Settings/ Tools page </strong> |
| 707 | 1078 | <?php |
| @@ -713,14 +1084,17 @@ | ||
| 713 | 1084 | |
| 714 | 1085 | |
| 715 | 1086 | |
| 716 | 1087 | /** |
| 717 | - * delete cache | |
| 1088 | + * AJAX (Tools page): clear all MLSImport caches/transients and the | |
| 1089 | + * metadata-populated flag, forcing the next request to re-fetch everything. | |
| 718 | 1090 | */ |
| 719 | 1091 | function mlsimport_delete_cache() { |
| 720 | 1092 | |
| 1093 | + // CSRF: Tools-page nonce. | |
| 721 | 1094 | check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 722 | 1095 | |
| 1096 | + // Drop every cached token/metadata/schema transient. | |
| 723 | 1097 | delete_transient( 'mlsimport_token_request' ); |
| 724 | 1098 | delete_transient( 'mlsimport_metadata_api_call_data_service_property' ); |
| 725 | 1099 | delete_transient( 'mls_import_meta_enums' ); |
| 726 | 1100 | delete_transient( 'mls_import_meta' ); |
| @@ -727,44 +1101,52 @@ | ||
| 727 | 1101 | delete_transient( 'mlsimport_plugin_data_schema' ); |
| 728 | 1102 | delete_transient( 'mlsimport_ready_to_go_mlsimport_data' ); |
| 729 | 1103 | delete_transient( 'mlsimport_saas_token' ); |
| 730 | 1104 | |
| 731 | - 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' ); | |
| 732 | 1107 | |
| 733 | 1108 | die( 'deleted' ); |
| 734 | 1109 | } |
| 735 | 1110 | |
| 736 | 1111 | /** |
| 737 | - * clear fields data | |
| 1112 | + * AJAX (Tools page): reset the field-mapping configuration so the field | |
| 1113 | + * selector starts fresh (also clears the metadata-populated flag). | |
| 738 | 1114 | */ |
| 739 | 1115 | function mlsimport_clear_fields_data() { |
| 740 | 1116 | |
| 1117 | + // CSRF: Tools-page nonce. | |
| 741 | 1118 | check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 742 | 1119 | |
| 743 | - delete_option( 'mlsimport_mls_metadata_populated' ); | |
| 744 | - 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' ); | |
| 745 | 1125 | |
| 746 | 1126 | die( 'deleted' ); |
| 747 | 1127 | } |
| 748 | 1128 | |
| 749 | 1129 | /** |
| 1130 | + * AJAX (Tools page): return the terms of a taxonomy for the "delete | |
| 1131 | + * properties by term" picker. Admin-only; validates the taxonomy exists. | |
| 750 | 1132 | * |
| 751 | - * | |
| 752 | - * | |
| 753 | - * | |
| 754 | - * delete properties | |
| 1133 | + * @return void Emits a JSON success payload of {slug,name,count} rows. | |
| 755 | 1134 | */ |
| 756 | 1135 | function mlsimport_get_taxonomy_terms() { |
| 1136 | + // CSRF + capability. | |
| 757 | 1137 | check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 758 | 1138 | if ( ! current_user_can( 'administrator' ) ) { |
| 759 | 1139 | wp_send_json_error( 'Unauthorized' ); |
| 760 | 1140 | } |
| 761 | 1141 | |
| 1142 | + // Reject unknown taxonomies. | |
| 762 | 1143 | $taxonomy = sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) ); |
| 763 | 1144 | if ( ! taxonomy_exists( $taxonomy ) ) { |
| 764 | 1145 | wp_send_json_error( 'Invalid taxonomy' ); |
| 765 | 1146 | } |
| 766 | 1147 | |
| 1148 | + // Fetch all terms (including empties) and flatten to slug/name/count. | |
| 767 | 1149 | $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name' ) ); |
| 768 | 1150 | $result = array(); |
| 769 | 1151 | if ( ! is_wp_error( $terms ) ) { |
| 770 | 1152 | foreach ( $terms as $term ) { |
| @@ -777,11 +1159,19 @@ | ||
| 777 | 1159 | } |
| 778 | 1160 | wp_send_json_success( $result ); |
| 779 | 1161 | } |
| 780 | 1162 | |
| 1163 | + /** | |
| 1164 | + * AJAX (Tools page): delete imported properties matching selected taxonomy | |
| 1165 | + * terms, in batches of 20. Admin-only. Reports progress so the client can | |
| 1166 | + * loop until done; refreshes term counts once the last batch completes. | |
| 1167 | + * | |
| 1168 | + * @return void Emits a JSON success payload {deleted,remaining,total,done}. | |
| 1169 | + */ | |
| 781 | 1170 | function mlsimport_delete_properties() { |
| 782 | 1171 | global $mlsimport; |
| 783 | 1172 | |
| 1173 | + // CSRF + capability. | |
| 784 | 1174 | check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 785 | 1175 | |
| 786 | 1176 | if ( ! current_user_can( 'administrator' ) ) { |
| 787 | 1177 | wp_send_json_error( 'Unauthorized' ); |
| @@ -786,11 +1176,13 @@ | ||
| 786 | 1176 | if ( ! current_user_can( 'administrator' ) ) { |
| 787 | 1177 | wp_send_json_error( 'Unauthorized' ); |
| 788 | 1178 | } |
| 789 | 1179 | |
| 1180 | + // Selected taxonomy and its chosen term slugs. | |
| 790 | 1181 | $taxonomy = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) ); |
| 791 | 1182 | $terms = array(); |
| 792 | 1183 | |
| 1184 | + // Collect and sanitize the selected term slugs. | |
| 793 | 1185 | if ( isset( $_POST['mlsimport_delete_category_term'] ) && is_array( $_POST['mlsimport_delete_category_term'] ) ) { |
| 794 | 1186 | foreach ( $_POST['mlsimport_delete_category_term'] as $term ) { |
| 795 | 1187 | $terms[] = sanitize_text_field( wp_unslash( $term ) ); |
| 796 | 1188 | } |
| @@ -795,16 +1187,19 @@ | ||
| 795 | 1187 | $terms[] = sanitize_text_field( wp_unslash( $term ) ); |
| 796 | 1188 | } |
| 797 | 1189 | } |
| 798 | 1190 | |
| 1191 | + // Require a taxonomy. | |
| 799 | 1192 | if ( '' === $taxonomy ) { |
| 800 | 1193 | wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) ); |
| 801 | 1194 | } |
| 802 | 1195 | |
| 1196 | + // Require at least one term. | |
| 803 | 1197 | if ( empty( $terms ) ) { |
| 804 | 1198 | wp_send_json_error( esc_html__( 'Please select at least one term', 'mlsimport' ) ); |
| 805 | 1199 | } |
| 806 | 1200 | |
| 1201 | + // Query one page of property IDs matching the term selection. | |
| 807 | 1202 | $post_type = $mlsimport->admin->env_data->get_property_post_type(); |
| 808 | 1203 | |
| 809 | 1204 | $args = array( |
| 810 | 1205 | 'post_type' => $post_type, |
| @@ -822,18 +1217,21 @@ | ||
| 822 | 1217 | |
| 823 | 1218 | $prop_selection = new WP_Query( $args ); |
| 824 | 1219 | $deleted = 0; |
| 825 | 1220 | |
| 1221 | + // Delete each property in this batch via the theme importer's SQL delete. | |
| 826 | 1222 | foreach ( $prop_selection->posts as $delete_id ) { |
| 827 | 1223 | $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $delete_id, ' delete from tools ' ); |
| 828 | 1224 | ++$deleted; |
| 829 | 1225 | } |
| 830 | 1226 | |
| 1227 | + // Compute how many still match after this batch; done when none remain. | |
| 831 | 1228 | $remaining = $prop_selection->found_posts - $deleted; |
| 832 | 1229 | $done = ( $remaining <= 0 ); |
| 833 | 1230 | |
| 834 | 1231 | // Update term counts only when all deletions are complete |
| 835 | 1232 | if ( $done ) { |
| 1233 | + // Recount every taxonomy on the property post type in one pass. | |
| 836 | 1234 | $all_taxonomies = get_object_taxonomies( $post_type ); |
| 837 | 1235 | foreach ( $all_taxonomies as $tax_name ) { |
| 838 | 1236 | $all_terms = get_terms( array( 'taxonomy' => $tax_name, 'hide_empty' => false, 'fields' => 'ids' ) ); |
| 839 | 1237 | if ( ! is_wp_error( $all_terms ) && ! empty( $all_terms ) ) { |
| @@ -841,8 +1239,9 @@ | ||
| 841 | 1239 | } |
| 842 | 1240 | } |
| 843 | 1241 | } |
| 844 | 1242 | |
| 1243 | + // Report progress back to the client loop. | |
| 845 | 1244 | wp_send_json_success( array( |
| 846 | 1245 | 'deleted' => $deleted, |
| 847 | 1246 | 'remaining' => max( 0, $remaining ), |
| 848 | 1247 | 'total' => $prop_selection->found_posts, |
| @@ -857,11 +1256,48 @@ | ||
| 857 | 1256 | |
| 858 | 1257 | |
| 859 | 1258 | |
| 860 | 1259 | /** |
| 1260 | + * Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes. | |
| 861 | 1261 | * |
| 1262 | + * @param string|int $value Raw ini/constant value. | |
| 1263 | + * @return int Bytes, or -1 for an unlimited (-1) setting. | |
| 1264 | + */ | |
| 1265 | + private function mlsimport_parse_bytes( $value ) { | |
| 1266 | + $value = trim( (string) $value ); | |
| 1267 | + if ( '' === $value ) { | |
| 1268 | + return 0; | |
| 1269 | + } | |
| 1270 | + if ( '-1' === $value ) { | |
| 1271 | + return -1; // Unlimited. | |
| 1272 | + } | |
| 1273 | + $unit = strtolower( substr( $value, -1 ) ); | |
| 1274 | + $number = (int) $value; | |
| 1275 | + switch ( $unit ) { | |
| 1276 | + case 'g': | |
| 1277 | + $number *= 1024 * 1024 * 1024; | |
| 1278 | + break; | |
| 1279 | + case 'm': | |
| 1280 | + $number *= 1024 * 1024; | |
| 1281 | + break; | |
| 1282 | + case 'k': | |
| 1283 | + $number *= 1024; | |
| 1284 | + break; | |
| 1285 | + } | |
| 1286 | + return $number; | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + /** | |
| 1290 | + * Print admin warnings when the PHP/WordPress environment is too constrained | |
| 1291 | + * for large imports (effective memory below 256MB, or a positive | |
| 1292 | + * max_execution_time below 600s). Suppressed during AJAX and on the | |
| 1293 | + * onboarding screen. | |
| 862 | 1294 | * |
| 863 | - * Testing enviroment variables | |
| 1295 | + * Memory is judged from the effective runtime limit: the larger of | |
| 1296 | + * WP_MEMORY_LIMIT (wp-config) and the actual PHP ini memory_limit | |
| 1297 | + * (which may be raised at the server/php.ini level), and -1 counts as | |
| 1298 | + * unlimited. This avoids a false warning when memory is fine but only set | |
| 1299 | + * outside wp-config.php. | |
| 864 | 1300 | */ |
| 865 | 1301 | public function mlsimport_saas_setting_up() { |
| 866 | 1302 | // Do not output warnings during AJAX requests |
| 867 | 1303 | if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || |
| @@ -867,229 +1303,161 @@ | ||
| 867 | 1303 | if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || |
| 868 | 1304 | ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { |
| 869 | 1305 | return; |
| 870 | 1306 | } |
| 871 | - | |
| 872 | - $is_onboarding = isset( $_GET['page'] ) && 'mlsimport-onboarding' === $_GET['page']; | |
| 873 | - if ( ! $is_onboarding && intval( WP_MEMORY_LIMIT ) < 256 ) : | |
| 874 | - if (intval(WP_MEMORY_LIMIT) < 256){ ?> | |
| 875 | - <div class="mlsimport_warning long_warning"> | |
| 876 | - <strong>WordPress Memory Limit</strong> is set to <strong><?php echo esc_html(WP_MEMORY_LIMIT); ?></strong>. Allocated Memory should be at least <strong>256MB</strong>. Please refer to: <a href="https://wordpress.org/support/article/editing-wp-config-php/#increasing-memory-allocated-to-php" target="_blank">Increasing memory allocated to PHP</a> | |
| 877 | - </div> | |
| 878 | - <?php | |
| 879 | - } | |
| 880 | - | |
| 881 | - $max_time = ini_get('max_execution_time'); | |
| 882 | - if ($max_time < 600 && 0 !== $max_time){ | |
| 883 | - ?> | |
| 884 | - <div class="mlsimport_warning long_warning">Your <strong>max_execution_time</strong> setting in php is set to <strong><?php echo esc_html($max_time); ?></strong>. Importing hundreds of listings requires extra time. Please set max_execution_time to <strong>0 (unlimited)</strong>. If that is not possible, set it to a minimum of <strong>600 (10 minutes)</strong>.</div> | |
| 885 | - | |
| 1307 | + | |
| 1308 | + // Skip all warnings on the onboarding wizard. | |
| 1309 | + $is_onboarding = isset( $_GET['page'] ) && 'mlsimport-onboarding' === $_GET['page']; | |
| 1310 | + if ( $is_onboarding ) { | |
| 1311 | + return; | |
| 1312 | + } | |
| 1313 | + | |
| 1314 | + // Effective memory limit: the larger of wp-config's WP_MEMORY_LIMIT and | |
| 1315 | + // the actual PHP runtime limit; either being -1 means unlimited. | |
| 1316 | + $min_bytes = 256 * 1024 * 1024; | |
| 1317 | + $wp_bytes = $this->mlsimport_parse_bytes( WP_MEMORY_LIMIT ); | |
| 1318 | + $php_bytes = $this->mlsimport_parse_bytes( ini_get( 'memory_limit' ) ); | |
| 1319 | + $memory_ok = ( -1 === $wp_bytes ) || ( -1 === $php_bytes ) | |
| 1320 | + || ( $wp_bytes >= $min_bytes ) || ( $php_bytes >= $min_bytes ); | |
| 1321 | + | |
| 1322 | + // Memory-limit warning. | |
| 1323 | + if ( ! $memory_ok ) { ?> | |
| 1324 | + <div class="mlsimport_warning long_warning"> | |
| 1325 | + <?php | |
| 1326 | + printf( | |
| 1327 | + /* translators: 1: current WordPress memory limit, 2: URL to the WordPress documentation on increasing memory. */ | |
| 1328 | + wp_kses( | |
| 1329 | + __( '<strong>WordPress Memory Limit</strong> is set to <strong>%1$s</strong>. Allocated Memory should be at least <strong>256MB</strong>. Please refer to: <a href="%2$s" target="_blank">Increasing memory allocated to PHP</a>', 'mlsimport' ), | |
| 1330 | + array( | |
| 1331 | + 'strong' => array(), | |
| 1332 | + 'a' => array( | |
| 1333 | + 'href' => array(), | |
| 1334 | + 'target' => array(), | |
| 1335 | + ), | |
| 1336 | + ) | |
| 1337 | + ), | |
| 1338 | + esc_html( WP_MEMORY_LIMIT ), | |
| 1339 | + 'https://wordpress.org/support/article/editing-wp-config-php/#increasing-memory-allocated-to-php' | |
| 1340 | + ); | |
| 1341 | + ?> | |
| 1342 | + </div> | |
| 886 | 1343 | <?php |
| 887 | 1344 | } |
| 888 | 1345 | |
| 1346 | + // Execution-time warning: 0 or -1 means unlimited (fine); only a | |
| 1347 | + // positive value below 600s is flagged. | |
| 1348 | + $max_time = (int) ini_get( 'max_execution_time' ); | |
| 1349 | + if ( $max_time > 0 && $max_time < 600 ) { | |
| 1350 | + ?> | |
| 1351 | + <div class="mlsimport_warning long_warning"> | |
| 1352 | + <?php | |
| 1353 | + printf( | |
| 1354 | + /* translators: %s: current max_execution_time value. */ | |
| 1355 | + wp_kses( | |
| 1356 | + __( 'Your <strong>max_execution_time</strong> setting in php is set to <strong>%s</strong>. Importing hundreds of listings requires extra time. Please set max_execution_time to <strong>0 (unlimited)</strong>. If that is not possible, set it to a minimum of <strong>600 (10 minutes)</strong>.', 'mlsimport' ), | |
| 1357 | + array( 'strong' => array() ) | |
| 1358 | + ), | |
| 1359 | + esc_html( $max_time ) | |
| 1360 | + ); | |
| 1361 | + ?> | |
| 1362 | + </div> | |
| 889 | 1363 | |
| 890 | - endif; // emd pn boardingin check | |
| 891 | - | |
| 1364 | + <?php | |
| 1365 | + } | |
| 892 | 1366 | } |
| 893 | 1367 | |
| 894 | 1368 | /** |
| 895 | - * Check if token validates with MLS | |
| 1369 | + * Test the configured MLS credentials against the SaaS API. | |
| 896 | 1370 | * |
| 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. | |
| 1374 | + * | |
| 897 | 1375 | * @since 4.0.1 |
| 898 | - * returns token fron mlsimport | |
| 1376 | + * @return array|void The API response, or void on an early return. | |
| 899 | 1377 | */ |
| 900 | 1378 | public function mlsimport_saas_check_mls_connection() { |
| 901 | 1379 | |
| 902 | - $values = array(); | |
| 903 | - $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 ); | |
| 904 | 1389 | |
| 905 | - $mls_id = ''; | |
| 906 | - if ( isset( $options['mlsimport_mls_name'] ) ) { | |
| 907 | - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) ); | |
| 908 | - } | |
| 909 | - | |
| 910 | - $mls_token = ''; | |
| 911 | - if ( isset( $options['mlsimport_mls_name'] ) ) { | |
| 912 | - $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) ); | |
| 913 | - } | |
| 914 | - | |
| 915 | - $mls_id_int = intval( $mls_id ); | |
| 916 | - | |
| 917 | - $mlsimport_tresle_client_id = ''; | |
| 918 | - if ( isset( $options['mlsimport_tresle_client_id'] ) ) { | |
| 919 | - $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 | + ); | |
| 920 | 1398 | } |
| 921 | 1399 | |
| 922 | - $mlsimport_tresle_client_secret = ''; | |
| 923 | - if ( isset( $options['mlsimport_tresle_client_secret'] ) ) { | |
| 924 | - $mlsimport_tresle_client_secret = sanitize_text_field( trim( $options['mlsimport_tresle_client_secret'] ) ); | |
| 925 | - } | |
| 926 | - | |
| 927 | - $mlsimport_connectmls_username = ''; | |
| 928 | - if ( isset( $options['mlsimport_connectmls_username'] ) ) { | |
| 929 | - $mlsimport_connectmls_username = sanitize_text_field( trim( $options['mlsimport_connectmls_username'] ) ); | |
| 930 | - } | |
| 931 | - | |
| 932 | - $mlsimport_connectmls_password = ''; | |
| 933 | - if ( isset( $options['mlsimport_connectmls_password'] ) ) { | |
| 934 | - $mlsimport_connectmls_password = sanitize_text_field( trim( $options['mlsimport_connectmls_password'] ) ); | |
| 935 | - } | |
| 936 | - | |
| 937 | - // rapattoni data | |
| 938 | - $mlsimport_rapattoni_client_id = ''; | |
| 939 | - if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) { | |
| 940 | - $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_id'] ) ); | |
| 941 | - } | |
| 942 | - $mlsimport_rapattoni_client_secret = ''; | |
| 943 | - if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) { | |
| 944 | - $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 | + ); | |
| 945 | 1408 | } |
| 1409 | + $values = $payload_result['payload']; | |
| 946 | 1410 | |
| 947 | - $mlsimport_rapattoni_username = ''; | |
| 948 | - if ( isset( $options['mlsimport_rapattoni_username'] ) ) { | |
| 949 | - $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 ); | |
| 950 | 1422 | } |
| 951 | 1423 | |
| 952 | - $mlsimport_rapattoni_password = ''; | |
| 953 | - if ( isset( $options['mlsimport_rapattoni_password'] ) ) { | |
| 954 | - $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 ); | |
| 955 | 1430 | } |
| 956 | - | |
| 957 | - // paragon data | |
| 958 | - $mlsimport_paragon_client_id = ''; | |
| 959 | - if ( isset( $options['mlsimport_paragon_client_id'] ) ) { | |
| 960 | - $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 ); | |
| 961 | 1435 | } |
| 962 | - $mlsimport_paragon_client_secret = ''; | |
| 963 | - if ( isset( $options['mlsimport_paragon_client_secret'] ) ) { | |
| 964 | - $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) ); | |
| 965 | - } | |
| 966 | 1436 | |
| 967 | - // realtor.ca data | |
| 968 | - $mlsimport_realtorca_client_id = ''; | |
| 969 | - if ( isset( $options['mlsimport_realtorca_client_id'] ) ) { | |
| 970 | - $mlsimport_realtorca_client_id = sanitize_text_field( trim( $options['mlsimport_realtorca_client_id'] ) ); | |
| 971 | - } | |
| 972 | - $mlsimport_realtorca_client_secret = ''; | |
| 973 | - if ( isset( $options['mlsimport_realtorca_client_secret'] ) ) { | |
| 974 | - $mlsimport_realtorca_client_secret = sanitize_text_field( trim( $options['mlsimport_realtorca_client_secret'] ) ); | |
| 975 | - } | |
| 976 | 1437 | |
| 977 | - // brightmls data | |
| 978 | - $mlsimport_brightmls_client_id = ''; | |
| 979 | - if ( isset( $options['mlsimport_brightmls_client_id'] ) ) { | |
| 980 | - $mlsimport_brightmls_client_id = sanitize_text_field( trim( $options['mlsimport_brightmls_client_id'] ) ); | |
| 981 | - } | |
| 982 | - $mlsimport_brightmls_client_secret = ''; | |
| 983 | - if ( isset( $options['mlsimport_brightmls_client_secret'] ) ) { | |
| 984 | - $mlsimport_brightmls_client_secret = sanitize_text_field( trim( $options['mlsimport_brightmls_client_secret'] ) ); | |
| 985 | - } | |
| 986 | 1438 | |
| 987 | 1439 | |
| 988 | - | |
| 989 | - | |
| 990 | - | |
| 991 | - | |
| 992 | - | |
| 993 | - if ( trim( $mls_token ) === '' ) { | |
| 994 | - if ( $this->mlsimport_is_brightmls_provider( $mls_id_int ) ) { // BrightMLS | |
| 995 | - if ( trim( $mlsimport_brightmls_client_id ) === '' || trim( $mlsimport_brightmls_client_secret ) === '' ) { | |
| 996 | - return; | |
| 997 | - } | |
| 998 | - } elseif ( $mls_id_int > 900 && $mls_id_int < 3000 ) { // Trestle | |
| 999 | - if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) { | |
| 1000 | - return; | |
| 1001 | - } | |
| 1002 | - } elseif ( $this->mlsimport_is_connectmls_provider( $mls_id_int ) ) { // ConnectMLS | |
| 1003 | - if ( | |
| 1004 | - trim( $mlsimport_connectmls_username ) === '' || | |
| 1005 | - trim( $mlsimport_connectmls_password ) === '' | |
| 1006 | - ) { | |
| 1007 | - return; | |
| 1008 | - } | |
| 1009 | - } elseif ( $mls_id_int >= 5000 && $mls_id_int < 6000 ) { // Rapattoni | |
| 1010 | - if ( | |
| 1011 | - trim( $mlsimport_rapattoni_client_id ) === '' || | |
| 1012 | - trim( $mlsimport_rapattoni_client_secret ) === '' || | |
| 1013 | - trim( $mlsimport_rapattoni_username ) === '' || | |
| 1014 | - trim( $mlsimport_rapattoni_password ) === '' | |
| 1015 | - ) { | |
| 1016 | - return; | |
| 1017 | - } | |
| 1018 | - } elseif ( $mls_id_int >= 6000 && $mls_id_int < 7000 ) { // Paragon | |
| 1019 | - if ( | |
| 1020 | - trim( $mlsimport_paragon_client_id ) === '' || | |
| 1021 | - trim( $mlsimport_paragon_client_secret ) === '' | |
| 1022 | - ) { | |
| 1023 | - return; | |
| 1024 | - } | |
| 1025 | - } elseif ( $mls_id_int >= 7000 && $mls_id_int < 8000 ) { // Realtor.ca | |
| 1026 | - if ( | |
| 1027 | - trim( $mlsimport_realtorca_client_id ) === '' || | |
| 1028 | - trim( $mlsimport_realtorca_client_secret ) === '' | |
| 1029 | - ) { | |
| 1030 | - return; | |
| 1031 | - } | |
| 1032 | - } | |
| 1033 | - } | |
| 1034 | - | |
| 1035 | - $values['mls_token'] = $mls_token; | |
| 1036 | - $values['mls_id'] = $mls_id; | |
| 1037 | - $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id; | |
| 1038 | - $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret; | |
| 1039 | - $values['mlsimport_connectmls_username'] = $mlsimport_connectmls_username; | |
| 1040 | - $values['mlsimport_connectmls_password'] = $mlsimport_connectmls_password; | |
| 1041 | - | |
| 1042 | - $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id; | |
| 1043 | - $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret; | |
| 1044 | - $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username; | |
| 1045 | - $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password; | |
| 1046 | - | |
| 1047 | - $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id; | |
| 1048 | - $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret; | |
| 1049 | - | |
| 1050 | - | |
| 1051 | - $values['mlsimport_realtorca_client_id'] = $mlsimport_realtorca_client_id; | |
| 1052 | - $values['mlsimport_realtorca_client_secret'] = $mlsimport_realtorca_client_secret; | |
| 1053 | - | |
| 1054 | - $values['mlsimport_brightmls_client_id'] = $mlsimport_brightmls_client_id; | |
| 1055 | - $values['mlsimport_brightmls_client_secret'] = $mlsimport_brightmls_client_secret; | |
| 1056 | - | |
| 1057 | - | |
| 1058 | - | |
| 1059 | - | |
| 1060 | - | |
| 1061 | - | |
| 1062 | - | |
| 1063 | - $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' ); | |
| 1064 | - | |
| 1065 | - | |
| 1066 | - | |
| 1067 | - | |
| 1068 | - if ( isset( $answer['success'] ) && true === $answer['success'] ) { | |
| 1069 | - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) { | |
| 1070 | - update_option( 'mlsimport_connection_test', 'yes' ); | |
| 1071 | - mlsimport_telemetry_set_once( 'mls_connected_at', time() ); | |
| 1072 | - } else { | |
| 1073 | - delete_option( 'mlsimport_connection_test' ); | |
| 1074 | - delete_option( 'mlsimport_mls_metadata_populated' ); | |
| 1075 | - } | |
| 1440 | + // Persist the connection-test flag only on a confirmed successful test; | |
| 1441 | + // any other outcome clears it (and the metadata flag) so the UI re-tests. | |
| 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() ); | |
| 1076 | 1447 | } else { |
| 1077 | 1448 | delete_option( 'mlsimport_connection_test' ); |
| 1078 | - delete_option( 'mlsimport_mls_metadata_populated' ); | |
| 1449 | + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' ); | |
| 1079 | 1450 | } |
| 1080 | 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 | + | |
| 1081 | 1457 | return $answer; |
| 1082 | 1458 | } |
| 1083 | 1459 | |
| 1084 | - private function mlsimport_is_connectmls_provider( $mls_id_int ) { | |
| 1085 | - return $mls_id_int >= 8000 && $mls_id_int < 9000 && 8001 !== (int) $mls_id_int; | |
| 1086 | - } | |
| 1087 | - | |
| 1088 | - private function mlsimport_is_brightmls_provider( int $mls_id_int ): bool { | |
| 1089 | - return 8001 === $mls_id_int; | |
| 1090 | - } | |
| 1091 | - | |
| 1092 | 1460 | /** |
| 1093 | 1461 | * AJAX handler for the plugin-deactivation exit survey. |
| 1094 | 1462 | * |
| 1095 | 1463 | * Thin wrapper: it verifies the nonce and capability, sanitizes input, |
| @@ -1237,17 +1605,20 @@ | ||
| 1237 | 1605 | |
| 1238 | 1606 | |
| 1239 | 1607 | |
| 1240 | 1608 | /** |
| 1241 | - * Request auth token from mlsimport.net | |
| 1609 | + * Return a valid SaaS API bearer token, using the cached transient when | |
| 1610 | + * present, otherwise requesting a fresh one and caching it for ~58 minutes. | |
| 1242 | 1611 | * |
| 1243 | 1612 | * @since 4.0.1 |
| 1244 | - * returns token fron mlsimport | |
| 1613 | + * @return string|array The token string, or the raw answer/'' on failure. | |
| 1245 | 1614 | */ |
| 1246 | 1615 | public function mlsimport_saas_get_mls_api_token_from_transient() { |
| 1247 | 1616 | |
| 1617 | + // Prefer the cached token. | |
| 1248 | 1618 | $token = get_transient( 'mlsimport_saas_token' ); |
| 1249 | 1619 | |
| 1620 | + // Cache miss/empty: request a new token and cache it on success. | |
| 1250 | 1621 | if ( false === $token || '' === $token ) { |
| 1251 | 1622 | $token_json_answer = $this->mlsimport_saas_get_mls_api_token(); |
| 1252 | 1623 | |
| 1253 | 1624 | if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) { |
| @@ -1252,8 +1623,9 @@ | ||
| 1252 | 1623 | |
| 1253 | 1624 | if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) { |
| 1254 | 1625 | $token = $token_json_answer['token']; |
| 1255 | 1626 | |
| 1627 | + // 3500s < the token's 1h life, leaving headroom before expiry. | |
| 1256 | 1628 | set_transient( 'mlsimport_saas_token', $token, 3500 ); |
| 1257 | 1629 | } |
| 1258 | 1630 | } |
| 1259 | 1631 | |
| @@ -1261,12 +1633,16 @@ | ||
| 1261 | 1633 | } |
| 1262 | 1634 | |
| 1263 | 1635 | |
| 1264 | 1636 | /** |
| 1265 | - * call for token | |
| 1637 | + * Request a fresh SaaS API token using the stored account username/password. | |
| 1266 | 1638 | * |
| 1639 | + * If the selected MLS changed since the last run, all cached token/metadata | |
| 1640 | + * transients and the field-select option are purged first so nothing leaks | |
| 1641 | + * across providers. Returns '' when credentials are missing. | |
| 1642 | + * | |
| 1267 | 1643 | * @since 4.0.1 |
| 1268 | - * returns token fron mlsimport | |
| 1644 | + * @return array|string The 'token' API response, or '' when unconfigured. | |
| 1269 | 1645 | */ |
| 1270 | 1646 | protected function mlsimport_saas_get_mls_api_token() { |
| 1271 | 1647 | $values = array(); |
| 1272 | 1648 | $options = get_option( $this->plugin_name . '_admin_options' ); |
| @@ -1281,9 +1657,11 @@ | ||
| 1281 | 1657 | } |
| 1282 | 1658 | |
| 1283 | 1659 | $password = ''; |
| 1284 | 1660 | if ( isset( $options['mlsimport_password'] ) ) { |
| 1285 | - $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'] ); | |
| 1286 | 1664 | } |
| 1287 | 1665 | $mls_name = ''; |
| 1288 | 1666 | if ( isset( $options['mlsimport_mls_name'] ) ) { |
| 1289 | 1667 | $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) ); |
| @@ -1293,8 +1671,9 @@ | ||
| 1293 | 1671 | if ( isset( $options['mlsimport_mls_token'] ) ) { |
| 1294 | 1672 | $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) ); |
| 1295 | 1673 | } |
| 1296 | 1674 | |
| 1675 | + // Provider switch detected: purge all cross-provider cached state. | |
| 1297 | 1676 | if ( $prev_mls !== '' && $prev_mls !== $mls_name ) { |
| 1298 | 1677 | delete_transient( 'mlsimport_token_request' ); |
| 1299 | 1678 | delete_transient( 'mlsimport_metadata_api_call_data_service_property' ); |
| 1300 | 1679 | delete_transient( 'mls_import_meta_enums' ); |
| @@ -1302,28 +1681,38 @@ | ||
| 1302 | 1681 | delete_transient( 'mlsimport_plugin_data_schema' ); |
| 1303 | 1682 | delete_transient( 'mlsimport_ready_to_go_mlsimport_data' ); |
| 1304 | 1683 | delete_transient( 'mlsimport_saas_token' ); |
| 1305 | 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. | |
| 1306 | 1689 | delete_option( 'mlsimport_mls_metadata_populated' ); |
| 1307 | 1690 | |
| 1308 | 1691 | delete_option( 'mlsimport_admin_fields_select' ); |
| 1309 | 1692 | } |
| 1310 | 1693 | |
| 1694 | + // Remember the current MLS so the next call can detect a switch. | |
| 1311 | 1695 | update_option( 'mlsimport_prev_mls_name', $mls_name ); |
| 1312 | 1696 | |
| 1313 | 1697 | |
| 1314 | 1698 | |
| 1699 | + // Credentials to exchange for a token. | |
| 1315 | 1700 | $values['username'] = $username; |
| 1316 | 1701 | $values['password'] = $password; |
| 1317 | 1702 | |
| 1703 | + // No account credentials -> nothing to request. | |
| 1318 | 1704 | if ( '' === $username || '' === $password ) { |
| 1319 | 1705 | return ''; |
| 1320 | 1706 | } |
| 1321 | 1707 | |
| 1708 | + // POST to the SaaS 'token' endpoint and return its response. | |
| 1322 | 1709 | $theme_Start = new ThemeImport(); |
| 1323 | 1710 | $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' ); |
| 1324 | 1711 | |
| 1325 | - | |
| 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 ); | |
| 1326 | 1715 | |
| 1327 | 1716 | return $answer; |
| 1328 | 1717 | } |
| 1329 | 1718 | |
| @@ -1332,13 +1721,14 @@ | ||
| 1332 | 1721 | |
| 1333 | 1722 | |
| 1334 | 1723 | |
| 1335 | 1724 | /** |
| 1336 | - * save meta options | |
| 1725 | + * Register the "Set Import data" metabox on the mlsimport_item post type. | |
| 1337 | 1726 | * |
| 1338 | 1727 | * @since 3.0.1 |
| 1339 | 1728 | */ |
| 1340 | 1729 | public function mlsimport_item_product_metaboxes() { |
| 1730 | + // The metabox renders the import-parameter form for an Import Task. | |
| 1341 | 1731 | add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' ); |
| 1342 | 1732 | } |
| 1343 | 1733 | |
| 1344 | 1734 | |
| @@ -1343,29 +1733,61 @@ | ||
| 1343 | 1733 | |
| 1344 | 1734 | |
| 1345 | 1735 | |
| 1346 | 1736 | /** |
| 1737 | + * Save the Import Task metabox fields to post meta (save_post callback). | |
| 1347 | 1738 | * |
| 1739 | + * Only acts on mlsimport_item posts. Sanitizes and stores each posted | |
| 1740 | + * whitelisted key; separately, any "blank_keys" absent from the POST (e.g. | |
| 1741 | + * unchecked multi-selects) are explicitly reset to '' so cleared selections | |
| 1742 | + * actually clear. | |
| 1348 | 1743 | * |
| 1349 | - * | |
| 1350 | - * save meta options | |
| 1351 | - * | |
| 1744 | + * @param int $post_id Post being saved. | |
| 1745 | + * @param WP_Post $post Post object. | |
| 1352 | 1746 | * @since 3.0.1 |
| 1353 | 1747 | */ |
| 1354 | 1748 | public function mlsimport_item_product_save_metaboxes( $post_id, $post ) { |
| 1355 | 1749 | |
| 1750 | + // Guard against non-post contexts. | |
| 1356 | 1751 | if ( ! is_object( $post ) || ! isset( $post->post_type ) ) { |
| 1357 | 1752 | return; |
| 1358 | 1753 | } |
| 1359 | 1754 | |
| 1755 | + // Only handle Import Task posts. | |
| 1360 | 1756 | if ( 'mlsimport_item' !== $post->post_type ) { |
| 1361 | 1757 | return; |
| 1362 | 1758 | } |
| 1363 | 1759 | |
| 1760 | + // Never persist metabox fields from autosaves or revision saves. | |
| 1761 | + if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) ) { | |
| 1762 | + return; | |
| 1763 | + } | |
| 1764 | + | |
| 1765 | + // The nonce rendered by mlsimport_saas_display_meta_options(). | |
| 1766 | + if ( ! isset( $_POST['estate_agent_noncename'] ) | |
| 1767 | + || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['estate_agent_noncename'] ) ), plugin_basename( __FILE__ ) ) ) { | |
| 1768 | + return; | |
| 1769 | + } | |
| 1770 | + | |
| 1771 | + // Import Tasks are admin-only: require edit rights on this task. | |
| 1772 | + if ( ! current_user_can( 'edit_post', $post_id ) ) { | |
| 1773 | + return; | |
| 1774 | + } | |
| 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 | + | |
| 1784 | + // Every import-parameter meta key this metabox may write. | |
| 1364 | 1785 | $allowed_keys = array( |
| 1365 | 1786 | 'mlsimport_item_how_many', |
| 1366 | 1787 | 'mlsimport_item_title_format', |
| 1367 | 1788 | 'mlsimport_item_agent', |
| 1789 | + 'mlsimport_item_use_mls_agent', | |
| 1368 | 1790 | 'mlsimport_item_property_status', |
| 1369 | 1791 | 'mlsimport_item_property_user', |
| 1370 | 1792 | 'mlsimport_item_min_price', |
| 1371 | 1793 | 'mlsimport_item_max_price', |
| @@ -1394,8 +1816,9 @@ | ||
| 1394 | 1816 | 'mlsimport_item_listofficekey', |
| 1395 | 1817 | 'mlsimport_item_postalcode', |
| 1396 | 1818 | 'mlsimport_item_listofficemlsid', |
| 1397 | 1819 | 'mlsimport_item_listingid', |
| 1820 | + 'mlsimport_item_listingkey', | |
| 1398 | 1821 | 'mlsimport_item_extracity', |
| 1399 | 1822 | 'mlsimport_item_extracounty', |
| 1400 | 1823 | 'mlsimport_item_exclude_listofficemlsid', |
| 1401 | 1824 | 'mlsimport_item_exclude_listofficekey', |
| @@ -1408,8 +1831,9 @@ | ||
| 1408 | 1831 | |
| 1409 | 1832 | |
| 1410 | 1833 | |
| 1411 | 1834 | |
| 1835 | + // Store each posted key (recursively sanitized; key sanitized too). | |
| 1412 | 1836 | foreach ( $allowed_keys as $key => $key_value ) { |
| 1413 | 1837 | if( isset($_POST[$key_value]) ){ |
| 1414 | 1838 | $postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ; |
| 1415 | 1839 | update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta ); |
| @@ -1416,9 +1840,11 @@ | ||
| 1416 | 1840 | } |
| 1417 | 1841 | |
| 1418 | 1842 | } |
| 1419 | 1843 | |
| 1844 | + // Keys that must be reset to '' when omitted from the POST (cleared). | |
| 1420 | 1845 | $blank_keys = array( |
| 1846 | + 'mlsimport_item_use_mls_agent', | |
| 1421 | 1847 | 'mlsimport_item_standardstatus', |
| 1422 | 1848 | 'mlsimport_item_standardstatusprotect', |
| 1423 | 1849 | 'mlsimport_item_city', |
| 1424 | 1850 | 'mlsimport_item_countyorparish', |
| @@ -1425,8 +1851,9 @@ | ||
| 1425 | 1851 | 'mlsimport_item_propertysubtype', |
| 1426 | 1852 | 'mlsimport_item_propertytype', |
| 1427 | 1853 | 'mlsimport_item_standardstatus', |
| 1428 | 1854 | 'mlsimport_item_listingid', |
| 1855 | + 'mlsimport_item_listingkey', | |
| 1429 | 1856 | 'mlsimport_item_customparameters', |
| 1430 | 1857 | 'mlsimport_item_mlsareamajor', |
| 1431 | 1858 | 'mlsimport_item_subdivisionname', |
| 1432 | 1859 | |
| @@ -1431,8 +1858,9 @@ | ||
| 1431 | 1858 | 'mlsimport_item_subdivisionname', |
| 1432 | 1859 | |
| 1433 | 1860 | ); |
| 1434 | 1861 | |
| 1862 | + // Reset any whitelisted-blank key that was not submitted this save. | |
| 1435 | 1863 | foreach ( $blank_keys as $key ) { |
| 1436 | 1864 | if ( ! isset( $_POST[ $key ] ) ) { |
| 1437 | 1865 | update_post_meta( $post_id, $key, '' ); |
| 1438 | 1866 | } |
| @@ -1442,61 +1870,92 @@ | ||
| 1442 | 1870 | } |
| 1443 | 1871 | |
| 1444 | 1872 | |
| 1445 | 1873 | /** |
| 1446 | - * Display Meta Options | |
| 1874 | + * Render the Import Task metabox content. | |
| 1447 | 1875 | * |
| 1876 | + * Ensures a live SaaS token + MLS connection, prints a warning and stops if | |
| 1877 | + * either is missing, otherwise runs a listing count request and hands off to | |
| 1878 | + * generateMetaOptionsHtml() to build the parameter form. | |
| 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 | + * | |
| 1448 | 1886 | * @param WP_Post $post The post object. |
| 1449 | 1887 | */ |
| 1450 | 1888 | public function mlsimport_saas_display_meta_options($post) { |
| 1889 | + // Nonce for the metabox save. | |
| 1451 | 1890 | wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename'); |
| 1452 | 1891 | global $mlsimport; |
| 1453 | 1892 | |
| 1893 | + // Ensure a token, read the cached connection flag, print env warnings. | |
| 1454 | 1894 | $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient(); |
| 1455 | 1895 | $is_mls_connected = get_option('mlsimport_connection_test', ''); |
| 1456 | 1896 | $mlsimport->admin->mlsimport_saas_setting_up(); |
| 1457 | 1897 | |
| 1898 | + // If not marked connected, run the connection test once and re-read the flag. | |
| 1458 | 1899 | if ('yes' !== $is_mls_connected) { |
| 1459 | 1900 | $mlsimport->admin->mlsimport_saas_check_mls_connection(); |
| 1460 | 1901 | $is_mls_connected = get_option('mlsimport_connection_test', ''); |
| 1461 | 1902 | } |
| 1462 | 1903 | |
| 1904 | + // No token -> account not authenticated; stop with a notice | |
| 1905 | + // that names the reason (no subscription vs wrong password). | |
| 1463 | 1906 | if (trim($token) === '') { |
| 1464 | - 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. | |
| 1465 | 1908 | return; |
| 1466 | 1909 | } |
| 1467 | 1910 | |
| 1911 | + // Token OK but MLS connection failed -> stop with a notice. | |
| 1468 | 1912 | if ('yes' !== $is_mls_connected) { |
| 1469 | 1913 | echo '<div class="mlsimport_warning">' . esc_html__('The connection to your MLS was NOT succesful. Please check the authentication token is correct and check your MLS Data Access Application is approved.', 'mlsimport') . '</div>'; |
| 1470 | 1914 | return; |
| 1471 | 1915 | } |
| 1472 | 1916 | |
| 1917 | + // Load current task settings for the form. | |
| 1473 | 1918 | $postId = $post->ID; |
| 1474 | 1919 | $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true)); |
| 1475 | 1920 | $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true)); |
| 1476 | 1921 | $lastDate = get_post_meta($postId, 'mlsimport_last_date', true); |
| 1477 | 1922 | $status = get_option('mlsimport_force_stop_' . $postId); |
| 1478 | - $fieldImport = $this->mlsimport_saas_return_mls_fields(); | |
| 1479 | - $options = get_option('mlsimport_admin_options'); | |
| 1480 | - $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); | |
| 1481 | 1928 | |
| 1482 | - ? intval($options['mlsimport_mls_name']) | |
| 1483 | - : 0; | |
| 1484 | - | |
| 1929 | + // Ask the MLS how many listings currently match this task. | |
| 1485 | 1930 | $mlsRequest = $this->mlsimport_make_listing_requests($postId); |
| 1486 | 1931 | // print_r($mlsRequest); |
| 1487 | 1932 | |
| 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. | |
| 1488 | 1940 | $hasError = isset($mlsRequest['success']) && !$mlsRequest['success']; |
| 1489 | 1941 | if ($hasError) { |
| 1490 | - 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>'; | |
| 1491 | 1944 | } |
| 1492 | 1945 | |
| 1493 | - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none'; | |
| 1494 | - if ($foundItems === 'none') { | |
| 1495 | - $mlsimport->admin->mlsimport_saas_check_mls_connection(); | |
| 1496 | - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport'); | |
| 1497 | - } | |
| 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; | |
| 1498 | 1956 | |
| 1957 | + // Build and print the parameter form. | |
| 1499 | 1958 | echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError); |
| 1500 | 1959 | } |
| 1501 | 1960 | |
| 1502 | 1961 | |
| @@ -1505,9 +1964,10 @@ | ||
| 1505 | 1964 | /** |
| 1506 | 1965 | * Generate Meta Options HTML |
| 1507 | 1966 | * |
| 1508 | 1967 | * @param int $postId The post ID. |
| 1509 | - * @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. | |
| 1510 | 1970 | * @param string $lastDate The last date checked. |
| 1511 | 1971 | * @param string $mlsimportItemHowMany How many items to import. |
| 1512 | 1972 | * @param string $mlsimportItemStatCron The status of the cron job. |
| 1513 | 1973 | * @param int $mlsimportMlsId The MLS import ID. |
| @@ -1516,14 +1976,19 @@ | ||
| 1516 | 1976 | */ |
| 1517 | 1977 | private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError = false) { |
| 1518 | 1978 | |
| 1519 | 1979 | |
| 1980 | + // Buffer all HTML and return it as a string. | |
| 1520 | 1981 | ob_start(); |
| 1521 | 1982 | |
| 1983 | + // Decode the saved MLS enums so City/County/PropertyType options can | |
| 1984 | + // carry their human-readable labels alongside the raw values. | |
| 1522 | 1985 | $metadata_api_call_city = array(); |
| 1523 | 1986 | $metadata_api_call_county = array(); |
| 1524 | 1987 | $metadata_api_call_property_type = array(); |
| 1525 | - $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 ) ); | |
| 1526 | 1991 | if ('' !== $mlsimport_mls_metadata_mls_enums) { |
| 1527 | 1992 | $metadata_api_call_full = json_decode($mlsimport_mls_metadata_mls_enums, true); |
| 1528 | 1993 | if (isset($metadata_api_call_full['global_array']['PropertyEnums'])) { |
| 1529 | 1994 | $property_enums = $metadata_api_call_full['global_array']['PropertyEnums']; |
| @@ -1556,12 +2021,53 @@ | ||
| 1556 | 2021 | </div> |
| 1557 | 2022 | <?php endif; ?> |
| 1558 | 2023 | |
| 1559 | 2024 | <div class="mlsimport_import_no"> |
| 1560 | - <?php esc_html_e('We found', 'mlsimport'); ?> | |
| 1561 | - <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; ?> | |
| 1562 | 2032 | </div> |
| 1563 | 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 | + | |
| 1564 | 2070 | <fieldset class="mlsimport-fieldset"> |
| 1565 | 2071 | <label class="mlsimport-label" for="mlsimport_item_how_many"> |
| 1566 | 2072 | <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?> |
| 1567 | 2073 | </label> |
| @@ -1625,8 +2131,23 @@ | ||
| 1625 | 2131 | ?> |
| 1626 | 2132 | </select> |
| 1627 | 2133 | </fieldset> |
| 1628 | 2134 | |
| 2135 | + <?php if ( mlsimport_is_standalone_mode() ) : | |
| 2136 | + $mlsimportItemUseMlsAgent = get_post_meta($postId, 'mlsimport_item_use_mls_agent', true); | |
| 2137 | + ?> | |
| 2138 | + <fieldset class="mlsimport-fieldset"> | |
| 2139 | + <label class="mlsimport-label" for="mlsimport_item_use_mls_agent"> | |
| 2140 | + <?php esc_html_e('Which agent shows on these properties', 'mlsimport'); ?> | |
| 2141 | + </label> | |
| 2142 | + <p class="mlsimport-exp"><?php esc_html_e('Off: every property from this task shows the agent you picked above. On: each property shows its own listing agent instead — the name, phone, email and office that came with that listing in the MLS feed, and the agent picked above is ignored. No agent profiles are created either way.', 'mlsimport'); ?></p> | |
| 2143 | + <label class="mlsimport-switch"> | |
| 2144 | + <input type="checkbox" id="mlsimport_item_use_mls_agent" name="mlsimport_item_use_mls_agent" value="1" <?php checked('1', (string) $mlsimportItemUseMlsAgent); ?> /> | |
| 2145 | + <?php esc_html_e('Show each property\'s own listing agent from the MLS feed', 'mlsimport'); ?> | |
| 2146 | + </label> | |
| 2147 | + </fieldset> | |
| 2148 | + <?php endif; ?> | |
| 2149 | + | |
| 1629 | 2150 | <?php |
| 1630 | 2151 | $mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true)); |
| 1631 | 2152 | if ('' === $mlsimportItemPropertyStatus) { |
| 1632 | 2153 | $mlsimportItemPropertyStatus = 'publish'; |
| @@ -1676,37 +2197,36 @@ | ||
| 1676 | 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); ?>"> |
| 1677 | 2198 | </fieldset> |
| 1678 | 2199 | |
| 1679 | 2200 | <?php |
| 1680 | - $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 ); | |
| 1681 | 2214 | |
| 1682 | - $mlsId = ''; | |
| 1683 | - if (isset($options['mlsimport_mls_name'])) { | |
| 1684 | - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name'])); | |
| 1685 | - } | |
| 1686 | - | |
| 1687 | - if ($mlsId > 5000) { | |
| 1688 | - $fieldImport['PropertyType']['multiple'] = 'no'; | |
| 1689 | - } | |
| 1690 | - | |
| 1691 | - | |
| 1692 | - if ($mlsId >= 7000) { | |
| 1693 | - // there is no such thing for realtor.ca | |
| 1694 | - unset($fieldImport['PropertyType']); | |
| 1695 | - } | |
| 1696 | - | |
| 1697 | - | |
| 1698 | - | |
| 1699 | - | |
| 2215 | + // Render one fieldset per import parameter. | |
| 1700 | 2216 | foreach ($fieldImport as $key => $field): |
| 2217 | + // Skip fields flagged hidden. | |
| 1701 | 2218 | if (!empty($field['hidden'])) { |
| 1702 | 2219 | continue; |
| 1703 | 2220 | } |
| 2221 | + // Derive the meta key + its companion "_check" (select-all) key. | |
| 1704 | 2222 | $nameCheck = strtolower('mlsimport_item_' . $key . '_check'); |
| 1705 | 2223 | $name = strtolower('mlsimport_item_' . $key); |
| 1706 | 2224 | |
| 2225 | + // Current saved value + select-all flag for this field. | |
| 1707 | 2226 | $value = get_post_meta($postId, $name, true); |
| 1708 | 2227 | $valueCheck = get_post_meta($postId, $nameCheck, true); |
| 2228 | + // extraCity/extraCounty render as a toggle button, not a plain label. | |
| 1709 | 2229 | $extraClass = ''; |
| 1710 | 2230 | if ('extraCity' === $key || 'extraCounty' === $key) { |
| 1711 | 2231 | $extraClass = ' mlsimport_hidden_field_button button mlsimport_button'; |
| 1712 | 2232 | } |
| @@ -1719,13 +2239,15 @@ | ||
| 1719 | 2239 | <div class="mlsimport-input-wrapper" style="display:none"> |
| 1720 | 2240 | <?php endif; ?> |
| 1721 | 2241 | <p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?> |
| 1722 | 2242 | <?php |
| 2243 | + // Whether the "select all" checkbox is currently on. | |
| 1723 | 2244 | $isCheckboxAdmin = 0; |
| 1724 | 2245 | if (1 === intval($valueCheck)) { |
| 1725 | 2246 | $isCheckboxAdmin = 1; |
| 1726 | 2247 | } |
| 1727 | 2248 | |
| 2249 | + // Fields that must NOT offer a "select all" checkbox. | |
| 1728 | 2250 | $selectAllNone = [ |
| 1729 | 2251 | 'InternetAddressDisplayYN', |
| 1730 | 2252 | 'InternetEntireListingDisplayYN', |
| 1731 | 2253 | 'PostalCode', |
| @@ -1735,8 +2257,9 @@ | ||
| 1735 | 2257 | 'ListOfficeKey', |
| 1736 | 2258 | 'ListOfficeMlsId', |
| 1737 | 2259 | 'StandardStatus', |
| 1738 | 2260 | 'ListingId', |
| 2261 | + 'ListingKey', | |
| 1739 | 2262 | 'extraCity', |
| 1740 | 2263 | 'extraCounty', |
| 1741 | 2264 | 'Exclude_ListOfficeKey', |
| 1742 | 2265 | 'Exclude_ListOfficeMlsId', |
| @@ -1746,9 +2269,13 @@ | ||
| 1746 | 2269 | 'MLSAreaMajor', |
| 1747 | 2270 | 'SubdivisionName', |
| 1748 | 2271 | ]; |
| 1749 | 2272 | |
| 1750 | - 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) { | |
| 1751 | 2278 | $selectAllNone[] = 'PropertyType'; |
| 1752 | 2279 | } |
| 1753 | 2280 | |
| 1754 | 2281 | if (!in_array($key, $selectAllNone)): ?> |
| @@ -1765,8 +2292,9 @@ | ||
| 1765 | 2292 | $permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending']; |
| 1766 | 2293 | |
| 1767 | 2294 | if ($field['type'] === 'select'): ?> |
| 1768 | 2295 | <?php |
| 2296 | + // Multi-select fields need the multiple attr + [] name. | |
| 1769 | 2297 | $multiple = ''; |
| 1770 | 2298 | if ('yes' === $field['multiple']) { |
| 1771 | 2299 | $multiple = 'multiple'; |
| 1772 | 2300 | $name .= '[]'; |
| @@ -1771,8 +2299,9 @@ | ||
| 1771 | 2299 | $multiple = 'multiple'; |
| 1772 | 2300 | $name .= '[]'; |
| 1773 | 2301 | } |
| 1774 | 2302 | |
| 2303 | + // Default StandardStatus to Active when nothing saved. | |
| 1775 | 2304 | if ('StandardStatus' === $key && '' === $value) { |
| 1776 | 2305 | $value = ['Active']; |
| 1777 | 2306 | } |
| 1778 | 2307 | |
| @@ -1796,20 +2325,25 @@ | ||
| 1796 | 2325 | <?php foreach ($field['values'] as $selectKey): ?> |
| 1797 | 2326 | |
| 1798 | 2327 | <?php if ('' !== $selectKey): ?> |
| 1799 | 2328 | <?php |
| 2329 | + // Match saved value against the raw key AND its | |
| 2330 | + // enum-mapped label, so either form stays selected. | |
| 1800 | 2331 | $option_value = $selectKey; |
| 1801 | 2332 | $option_label = $selectKey; |
| 1802 | 2333 | $comparison_values = array($option_value); |
| 1803 | 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. | |
| 1804 | 2338 | if ('City' === $key && isset($metadata_api_call_city[$selectKey])) { |
| 1805 | - $option_label = $selectKey; | |
| 2339 | + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_city); | |
| 1806 | 2340 | $comparison_values[] = $metadata_api_call_city[$selectKey]; |
| 1807 | 2341 | } elseif ('CountyOrParish' === $key && isset($metadata_api_call_county[$selectKey])) { |
| 1808 | - $option_label = $selectKey; | |
| 2342 | + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_county); | |
| 1809 | 2343 | $comparison_values[] = $metadata_api_call_county[$selectKey]; |
| 1810 | 2344 | } elseif ('PropertyType' === $key && isset($metadata_api_call_property_type[$selectKey])) { |
| 1811 | - $option_label = $selectKey; | |
| 2345 | + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_property_type); | |
| 1812 | 2346 | $comparison_values[] = $metadata_api_call_property_type[$selectKey]; |
| 1813 | 2347 | } |
| 1814 | 2348 | |
| 1815 | 2349 | $comparison_values = array_values(array_unique(array_filter($comparison_values, static function ($compare_value) { |
| @@ -1815,8 +2349,10 @@ | ||
| 1815 | 2349 | $comparison_values = array_values(array_unique(array_filter($comparison_values, static function ($compare_value) { |
| 1816 | 2350 | return '' !== $compare_value && null !== $compare_value; |
| 1817 | 2351 | }))); |
| 1818 | 2352 | |
| 2353 | + // Selected if any comparison value matches the saved | |
| 2354 | + // value (array for multi-selects, scalar otherwise). | |
| 1819 | 2355 | $is_selected = false; |
| 1820 | 2356 | if (is_array($value)) { |
| 1821 | 2357 | $is_selected = count(array_intersect($comparison_values, $value)) > 0; |
| 1822 | 2358 | } else { |
| @@ -1841,8 +2377,9 @@ | ||
| 1841 | 2377 | <?php endforeach; ?> |
| 1842 | 2378 | |
| 1843 | 2379 | </div> |
| 1844 | 2380 | <?php |
| 2381 | + // Return the buffered form markup. | |
| 1845 | 2382 | return ob_get_clean(); |
| 1846 | 2383 | } |
| 1847 | 2384 | |
| 1848 | 2385 | |
| @@ -1849,18 +2386,22 @@ | ||
| 1849 | 2386 | |
| 1850 | 2387 | |
| 1851 | 2388 | |
| 1852 | 2389 | |
| 2390 | + // Placeholder hook target for injecting additional Import Task fields (no-op). | |
| 1853 | 2391 | public function mlsimport_add_extra_fields() { |
| 1854 | 2392 | } |
| 1855 | 2393 | |
| 1856 | 2394 | /** |
| 2395 | + * Per-field help text override, keyed by MLS + meta field. | |
| 1857 | 2396 | * |
| 2397 | + * Currently only special-cases MLS 111 (Rae Edmonton), which has no status | |
| 2398 | + * field; every other case returns the field's default description unchanged. | |
| 1858 | 2399 | * |
| 1859 | - * | |
| 1860 | - * | |
| 1861 | - * | |
| 1862 | - * | |
| 2400 | + * @param int $mlsimport_mls_id Numeric MLS id. | |
| 2401 | + * @param string $name Meta field name (e.g. mlsimport_item_standardstatus). | |
| 2402 | + * @param string $description Default description to fall back to. | |
| 2403 | + * @return string | |
| 1863 | 2404 | */ |
| 1864 | 2405 | function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) { |
| 1865 | 2406 | // 111 - Rae Edmonton |
| 1866 | 2407 | |
| @@ -1872,15 +2413,18 @@ | ||
| 1872 | 2413 | } |
| 1873 | 2414 | |
| 1874 | 2415 | |
| 1875 | 2416 | /** |
| 2417 | + * Return the "last checked" timestamp for an Import Task, seeding it if unset. | |
| 1876 | 2418 | * |
| 1877 | - * | |
| 1878 | - * Get Last date | |
| 2419 | + * @param int $item_id Import Task post id. | |
| 2420 | + * @return string A 'Y-m-d\TH:i' timestamp. | |
| 1879 | 2421 | */ |
| 1880 | 2422 | public function mlsimport_saas_get_last_date( $item_id ) { |
| 2423 | + // Stored watermark used as the modification-time filter for syncs. | |
| 1881 | 2424 | $last_date = get_post_meta( $item_id, 'mlsimport_last_date', true ); |
| 1882 | 2425 | |
| 2426 | + // First run: initialize it. | |
| 1883 | 2427 | if ( '' === $last_date ) { |
| 1884 | 2428 | $last_date = $this->mlsimport_saas_update_last_date( $item_id ); |
| 1885 | 2429 | } |
| 1886 | 2430 | return $last_date; |
| @@ -1887,14 +2431,19 @@ | ||
| 1887 | 2431 | } |
| 1888 | 2432 | |
| 1889 | 2433 | |
| 1890 | 2434 | /** |
| 2435 | + * Set the Import Task's "last checked" watermark to 2 hours ago and store it. | |
| 1891 | 2436 | * |
| 2437 | + * The 2-hour backdate provides overlap so listings modified right around the | |
| 2438 | + * run boundary are not missed. Note: also echoes the value as a side effect. | |
| 1892 | 2439 | * |
| 1893 | - * Save Last date | |
| 2440 | + * @param int $item_id Import Task post id. | |
| 2441 | + * @return string The stored 'Y-m-d\TH:i' timestamp. | |
| 1894 | 2442 | */ |
| 1895 | 2443 | public function mlsimport_saas_update_last_date( $item_id ) { |
| 1896 | 2444 | |
| 2445 | + // Current site time minus 2 hours, formatted as an ISO-ish local stamp. | |
| 1897 | 2446 | $unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 ); |
| 1898 | 2447 | print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time ); |
| 1899 | 2448 | update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save ); |
| 1900 | 2449 | |
| @@ -1911,248 +2460,116 @@ | ||
| 1911 | 2460 | * |
| 1912 | 2461 | * @param int $item_id |
| 1913 | 2462 | * @return int Number of listings found in the MLS feed, or 0 on failure. |
| 1914 | 2463 | */ |
| 1915 | - public function mlsimport_saas_start_cron_links_per_item( int $item_id ): int { | |
| 1916 | - // 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 ); | |
| 1917 | 2481 | |
| 1918 | - $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 | + } | |
| 1919 | 2493 | |
| 1920 | - // Auto-sync only maintains tasks the realtor has imported at least once | |
| 1921 | - // by hand. A task created and forgotten has no 'mlsimport_spawn_status' | |
| 1922 | - // meta, so the cron skips it instead of pulling the whole MLS feed. | |
| 1923 | - if ( '' === get_post_meta( $item_id, 'mlsimport_spawn_status', true ) ) { | |
| 1924 | - return 0; | |
| 1925 | - } | |
| 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' ) ); | |
| 1926 | 2499 | |
| 1927 | - $last_date = $this->mlsimport_saas_get_last_date( $item_id ); | |
| 1928 | - print 'MLSitem id: ' . $item_id . ' - '; | |
| 1929 | - esc_html_e('date to consider: ','mlsimport'); | |
| 1930 | - 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(); | |
| 1931 | 2521 | |
| 1932 | - // Make request to MLS API | |
| 1933 | - $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date, '', '', true ); | |
| 2522 | + return (int) $result['found']; | |
| 2523 | + } | |
| 1934 | 2524 | |
| 1935 | - if ( isset( $mlsrequest['results'] ) ) { | |
| 1936 | - $found_items = intval( $mlsrequest['results'] ); | |
| 1937 | - } else { | |
| 1938 | - delete_transient( 'mlsimport_saas_token' ); | |
| 1939 | - mlsimport_telemetry_set( 'last_sync_failed', time() ); | |
| 1940 | - mlsimport_telemetry_set( 'last_sync_failed_code', (string) ( $mlsrequest['error_code'] ?? 'unknown' ) ); | |
| 1941 | - } | |
| 1942 | - print esc_html__('We found ','mlsimport') . esc_html( $found_items ) . ' listings.</br>' . PHP_EOL; | |
| 1943 | 2525 | |
| 1944 | - // Only process if items found | |
| 1945 | - if ( $found_items > 0 ) { | |
| 1946 | 2526 | |
| 1947 | - $item_id_array = array( | |
| 1948 | - 'item_id' => $item_id, | |
| 1949 | - 'how_many' => 0, | |
| 1950 | - 'max_number' => $found_items, | |
| 1951 | - 'batch_counter' => 1, | |
| 1952 | - ); | |
| 1953 | 2527 | |
| 1954 | - // Potentially large array, log memory before/after | |
| 1955 | - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date, true ); | |
| 1956 | 2528 | |
| 1957 | - // Store in post meta (beware if array is huge) | |
| 1958 | - update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' ); | |
| 1959 | - update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move ); | |
| 1960 | 2529 | |
| 1961 | - // Save last date for next run | |
| 1962 | - $this->mlsimport_saas_update_last_date( $item_id ); | |
| 1963 | - | |
| 1964 | - // Prepare and pass only necessary arguments to background process | |
| 1965 | - $attachments_to_send = array( | |
| 1966 | - 'args' => array( | |
| 1967 | - 'attachments_to_move' => $item_id, | |
| 1968 | - 'item_id_array' => $item_id_array, | |
| 1969 | - ), | |
| 1970 | - ); | |
| 1971 | - | |
| 1972 | - $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] ); | |
| 1973 | - | |
| 1974 | - // Unset large arrays/objects after use | |
| 1975 | - unset($attachments_to_move, $attachments_to_send, $mlsrequest, $item_id_array); | |
| 1976 | - gc_collect_cycles(); | |
| 1977 | - } | |
| 1978 | - | |
| 1979 | - return $found_items; | |
| 1980 | - } | |
| 1981 | - | |
| 1982 | - | |
| 1983 | - | |
| 1984 | - | |
| 1985 | - | |
| 1986 | - | |
| 1987 | 2530 | /** |
| 1988 | - * Reconciliation log (optimized, batched, memory logged) | |
| 2531 | + * Backward-compatible entry point for the deep reconciliation module. | |
| 2532 | + * | |
| 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. | |
| 2537 | + * | |
| 2538 | + * @return array<int, array<string, int|string>> Reconciliation Outcome per | |
| 2539 | + * connection (key 0 = the single legacy unscoped run). | |
| 1989 | 2540 | */ |
| 1990 | 2541 | public function mlsimport_saas_start_doing_reconciliation() { |
| 1991 | - global $mlsimport, $wpdb; | |
| 1992 | - | |
| 1993 | - | |
| 1994 | - // Get all MLS keys in memory (we assume this is necessary for lookup) | |
| 1995 | - $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data(); | |
| 1996 | - $listingKey_in_MLS = $mls_data['all_data'] ?? []; | |
| 1997 | - | |
| 1998 | - unset($mls_data); | |
| 1999 | - gc_collect_cycles(); | |
| 2000 | - | |
| 2001 | - if (empty($listingKey_in_MLS)) { | |
| 2002 | - return; | |
| 2003 | - } | |
| 2004 | - // Flip for fast lookup | |
| 2005 | - $listingKey_in_MLS = array_flip($listingKey_in_MLS); | |
| 2006 | - | |
| 2007 | - // Batch fetch local listings | |
| 2008 | - $batch = 1000; | |
| 2009 | - $offset = 0; | |
| 2010 | - $to_delete = 0; | |
| 2011 | - $counter = 0; | |
| 2012 | - | |
| 2013 | - $mlsimport_preload_all_mls_item_status_meta = $this->mlsimport_preload_all_mls_item_status_meta(); | |
| 2014 | - //print_r($mlsimport_preload_all_mls_item_status_meta); | |
| 2015 | - | |
| 2016 | - do { | |
| 2017 | - $local = $wpdb->get_results( | |
| 2018 | - $wpdb->prepare( | |
| 2019 | - "SELECT | |
| 2020 | - p.ID, | |
| 2021 | - listingkey_meta.meta_value AS listingkey, | |
| 2022 | - inserted_meta.meta_value AS mlsimport_item_inserted | |
| 2023 | - FROM {$wpdb->posts} p | |
| 2024 | - INNER JOIN {$wpdb->postmeta} listingkey_meta | |
| 2025 | - ON p.ID = listingkey_meta.post_id | |
| 2026 | - AND listingkey_meta.meta_key = %s | |
| 2027 | - LEFT JOIN {$wpdb->postmeta} inserted_meta | |
| 2028 | - ON p.ID = inserted_meta.post_id | |
| 2029 | - AND inserted_meta.meta_key = %s | |
| 2030 | - WHERE p.post_status NOT IN ('draft', 'trash') | |
| 2031 | - LIMIT %d OFFSET %d", | |
| 2032 | - 'ListingKey', | |
| 2033 | - 'MLSimport_item_inserted', | |
| 2034 | - $batch, | |
| 2035 | - $offset | |
| 2036 | - ), | |
| 2037 | - ARRAY_A | |
| 2038 | - ); | |
| 2039 | - | |
| 2040 | - $count = count($local); | |
| 2041 | - | |
| 2042 | - | |
| 2043 | - foreach ($local as $item) { | |
| 2044 | - $listingkey = $item['listingkey']; // not 'meta_value' anymore | |
| 2045 | - $property_id = $item['ID']; | |
| 2046 | - $mlsimportItemId = $item['mlsimport_item_inserted']; | |
| 2047 | - ++$counter; | |
| 2048 | - // IN MLS | |
| 2049 | - if (isset($listingKey_in_MLS[$listingkey])) { | |
| 2050 | - | |
| 2051 | - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) { | |
| 2052 | - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null; | |
| 2053 | - $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null; | |
| 2054 | - } else { | |
| 2055 | - $mlsimport_item_standardstatus = null; | |
| 2056 | - $mlsimport_item_standardstatusprotect = null; | |
| 2057 | - } | |
| 2058 | - | |
| 2059 | - $keep_when_in_mls = $mlsimport->admin->theme_importer->check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect); | |
| 2060 | - if (!$keep_when_in_mls) { | |
| 2061 | - ++$to_delete; | |
| 2062 | - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey); | |
| 2063 | - } | |
| 2064 | - } else { | |
| 2065 | - // NOT IN MLS | |
| 2066 | - | |
| 2067 | - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) { | |
| 2068 | - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null; | |
| 2069 | - $mlsimport_item_standardstatusprotect = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusprotect'] ?? null; | |
| 2070 | - } else { | |
| 2071 | - $mlsimport_item_standardstatus = null; | |
| 2072 | - $mlsimport_item_standardstatusprotect = null; | |
| 2073 | - } | |
| 2074 | - $keep = $mlsimport->admin->theme_importer->check_if_delete_when_status($property_id, $mlsimport_item_standardstatus, null, $mlsimport_item_standardstatusprotect); | |
| 2075 | - if (!$keep) { | |
| 2076 | - ++$to_delete; | |
| 2077 | - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey); | |
| 2078 | - } | |
| 2079 | - } | |
| 2080 | - | |
| 2081 | - // Memory housekeeping | |
| 2082 | - unset($listingkey, $property_id, $mlsimportItemId, $mlsImportItemStatus, $mlsimport_item_standardstatusprotect, $keep_when_in_mls, $keep); | |
| 2083 | - if ($counter % 250 == 0) { | |
| 2084 | - gc_collect_cycles(); | |
| 2085 | - } | |
| 2086 | - } | |
| 2087 | - | |
| 2088 | - unset($local); | |
| 2089 | - gc_collect_cycles(); | |
| 2090 | - | |
| 2091 | - $offset += $batch; | |
| 2092 | - } while ($count === $batch); | |
| 2093 | - | |
| 2094 | - | |
| 2095 | - print esc_html(' to delete:' . $to_delete); | |
| 2096 | - | |
| 2097 | - // Final cleanup | |
| 2098 | - unset($listingKey_in_MLS); | |
| 2099 | - gc_collect_cycles(); | |
| 2100 | - | |
| 2101 | - | |
| 2102 | - 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(); | |
| 2103 | 2546 | } |
| 2104 | 2547 | |
| 2105 | - | |
| 2106 | - | |
| 2107 | -/** | |
| 2108 | - * Preload all status meta for ALL mlsimport_item posts in ONE QUERY. | |
| 2109 | - * Returns: [mlsimport_item_id => ['mlsimport_item_standardstatus' => ..., 'mlsimport_item_standardstatusprotect' => ...], ...] | |
| 2110 | - */ | |
| 2111 | -function mlsimport_preload_all_mls_item_status_meta() { | |
| 2112 | - global $wpdb; | |
| 2113 | - | |
| 2114 | - $sql = " | |
| 2115 | - SELECT p.ID as post_id, pm.meta_key, pm.meta_value | |
| 2116 | - FROM {$wpdb->posts} p | |
| 2117 | - LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id | |
| 2118 | - WHERE p.post_type = 'mlsimport_item' | |
| 2119 | - AND pm.meta_key IN ('mlsimport_item_standardstatus', 'mlsimport_item_standardstatusprotect') | |
| 2120 | - "; | |
| 2121 | - | |
| 2122 | - $rows = $wpdb->get_results($sql); | |
| 2123 | - | |
| 2124 | - $meta = []; | |
| 2125 | - foreach ($rows as $row) { | |
| 2126 | - if (!isset($meta[$row->post_id])) { | |
| 2127 | - $meta[$row->post_id] = [ | |
| 2128 | - 'mlsimport_item_standardstatus' => null, | |
| 2129 | - 'mlsimport_item_standardstatusprotect' => null | |
| 2130 | - ]; | |
| 2131 | - } | |
| 2132 | - $meta[$row->post_id][$row->meta_key] = maybe_unserialize($row->meta_value); | |
| 2133 | - } | |
| 2134 | - return $meta; | |
| 2135 | -} | |
| 2136 | - | |
| 2137 | - | |
| 2138 | - | |
| 2139 | 2548 | /** |
| 2549 | + * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API. | |
| 2140 | 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. | |
| 2141 | 2555 | * |
| 2142 | - * Requestq Reconciliation log | |
| 2556 | + * @param int $mls_id Connection to scope the snapshot to; 0 = unscoped. | |
| 2557 | + * @return array The API response, expected to carry an 'all_data' key. | |
| 2143 | 2558 | */ |
| 2144 | - public function mlsimport_saas_get_mls_reconciliation_data() { | |
| 2559 | + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) { | |
| 2145 | 2560 | |
| 2146 | - $arguments = array(); | |
| 2147 | - $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' ); | |
| 2148 | 2564 | return $answer; |
| 2149 | 2565 | } |
| 2150 | 2566 | |
| 2151 | 2567 | /** |
| 2568 | + * Return all published posts' values for a given meta key, with their post ids. | |
| 2152 | 2569 | * |
| 2153 | - * | |
| 2154 | - * Reconciliation get local data | |
| 2570 | + * @param string $key Meta key to fetch. | |
| 2571 | + * @return array Rows of {meta_value, ID}. | |
| 2155 | 2572 | */ |
| 2156 | 2573 | public function mlsimport_saas_get_all_meta_values($key) { |
| 2157 | 2574 | global $wpdb; |
| 2158 | 2575 | $result = $wpdb->get_results( |
| @@ -2172,39 +2589,39 @@ | ||
| 2172 | 2589 | } |
| 2173 | 2590 | |
| 2174 | 2591 | |
| 2175 | 2592 | |
| 2176 | - /* | |
| 2177 | - * Do api Listing Requests | |
| 2593 | + /** | |
| 2594 | + * Run a single listings request for an Import Task and return the API result. | |
| 2178 | 2595 | * |
| 2596 | + * Builds the RESO query arguments, rejects invalid combinations (Rapattoni | |
| 2597 | + * requiring a property type; over-long argument strings), POSTs to the SaaS | |
| 2598 | + * 'listings' endpoint, normalizes a non-array failure into a success=false | |
| 2599 | + * array, records feed-count telemetry, and returns the response array. | |
| 2179 | 2600 | * |
| 2180 | - * | |
| 2181 | - * | |
| 2182 | - * */ | |
| 2601 | + * @param int $item_id Import Task post id. | |
| 2602 | + * @param string $last_date Modification-time watermark (optional). | |
| 2603 | + * @param string $skip Pagination offset (optional). | |
| 2604 | + * @param string $top Page size (optional). | |
| 2605 | + * @param bool $is_hourly_sync Whether this call is from the hourly cron. | |
| 2606 | + * @return array The (normalized) API response. | |
| 2607 | + */ | |
| 2183 | 2608 | public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) { |
| 2184 | - $options = get_option( $this->plugin_name . '_admin_options' ); | |
| 2185 | - $mls_id = ''; | |
| 2186 | - if ( isset( $options['mlsimport_mls_name'] ) ) { | |
| 2187 | - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) ); | |
| 2188 | - } | |
| 2189 | - | |
| 2609 | + // Build the full RESO query argument set from the task's meta. | |
| 2190 | 2610 | $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top, $is_hourly_sync ); |
| 2191 | 2611 | |
| 2192 | - | |
| 2193 | - if ( | |
| 2194 | - $mls_id > 5000 && $mls_id < 6000 && | |
| 2195 | - ( ! isset( $arguments['property_type'] ) or | |
| 2196 | - ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or | |
| 2197 | - ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] ) | |
| 2198 | - ) | |
| 2199 | - ) { | |
| 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']; | |
| 2200 | 2616 | return array( |
| 2201 | 2617 | 'success' => false, |
| 2202 | - 'type' => 'rapattoni', | |
| 2203 | - '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' ), | |
| 2204 | 2620 | ); |
| 2205 | 2621 | } |
| 2206 | 2622 | |
| 2623 | + // Guard against an over-long query string (too many parameters selected). | |
| 2207 | 2624 | $potential_leght = strlen( wp_json_encode( $arguments ) ); |
| 2208 | 2625 | if ( $potential_leght > 1750 ) { |
| 2209 | 2626 | return array( |
| 2210 | 2627 | 'success' => false, |
| @@ -2212,11 +2629,42 @@ | ||
| 2212 | 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' ), |
| 2213 | 2630 | ); |
| 2214 | 2631 | } |
| 2215 | 2632 | |
| 2216 | - //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); | |
| 2217 | 2645 | //print '----------------------------'.PHP_EOL; |
| 2218 | - $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' ); | |
| 2646 | + // POST the query to the SaaS 'listings' endpoint. | |
| 2647 | + $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' ); | |
| 2648 | + | |
| 2649 | + // globalApiRequestCurlSaas() returns a plain string on failure (token | |
| 2650 | + // validation, network/WP error, JSON decode). Callers expect an array, | |
| 2651 | + // so normalize the failure into the success=>false shape they handle. | |
| 2652 | + if ( ! is_array( $answer ) ) { | |
| 2653 | + $answer = array( | |
| 2654 | + 'success' => false, | |
| 2655 | + 'message' => is_string( $answer ) ? $answer : esc_html__( 'The request to the MLS could not be completed.', 'mlsimport' ), | |
| 2656 | + ); | |
| 2657 | + } | |
| 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 | + | |
| 2666 | + // Echo the computed argument length back on the response for diagnostics. | |
| 2219 | 2667 | $answer['potential_leght'] = $potential_leght; |
| 2220 | 2668 | |
| 2221 | 2669 | // Record the pre-filter MLS feed count for telemetry. Every import path |
| 2222 | 2670 | // — manual, hourly cron, and onboarding — routes through this method, so |
| @@ -2224,8 +2672,15 @@ | ||
| 2224 | 2672 | if ( isset( $answer['results'] ) ) { |
| 2225 | 2673 | mlsimport_telemetry_set( 'last_feed_found', (int) $answer['results'] ); |
| 2226 | 2674 | } |
| 2227 | 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 | + | |
| 2228 | 2683 | return ( $answer ); |
| 2229 | 2684 | } |
| 2230 | 2685 | |
| 2231 | 2686 | |
| @@ -2232,26 +2687,38 @@ | ||
| 2232 | 2687 | |
| 2233 | 2688 | |
| 2234 | 2689 | |
| 2235 | 2690 | |
| 2236 | - /* | |
| 2237 | - * Create Api query arguments | |
| 2691 | + /** | |
| 2692 | + * Assemble the RESO listings query arguments from an Import Task's meta. | |
| 2238 | 2693 | * |
| 2694 | + * Reads the task's saved filters (price, city/county, area, subdivision, | |
| 2695 | + * postal code, status, property (sub)type, internet-display flags, agent / | |
| 2696 | + * office keys and their exclusions, custom parameters) and maps them to the | |
| 2697 | + * SaaS API parameter names, applying provider-specific quirks (Edmonton has | |
| 2698 | + * no status; Rapattoni collapses property_type; Realtor.ca / PropTx need a | |
| 2699 | + * specific modification-time format). | |
| 2239 | 2700 | * |
| 2240 | - * | |
| 2241 | - * | |
| 2242 | - * | |
| 2243 | - * */ | |
| 2244 | - | |
| 2701 | + * @param int $item_id Import Task post id. | |
| 2702 | + * @param string $last_date Modification-time watermark (optional). | |
| 2703 | + * @param string $skip Pagination offset (optional). | |
| 2704 | + * @param string $top Page size (optional). | |
| 2705 | + * @param bool $is_hourly_sync Whether this call is from the hourly cron. | |
| 2706 | + * @return array|string The argument array, or '' when core options are missing. | |
| 2707 | + */ | |
| 2245 | 2708 | public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) { |
| 2246 | 2709 | |
| 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. | |
| 2247 | 2713 | $options = get_option( $this->plugin_name . '_admin_options' ); |
| 2248 | - if ( isset( $options['mlsimport_mls_name'] ) ) { | |
| 2249 | - $mls_id = intval( $options['mlsimport_mls_name'] ); | |
| 2250 | - } else { | |
| 2714 | + $mls_id = mlsimport_task_mls_id( (int) $item_id ); | |
| 2715 | + if ( $mls_id <= 0 ) { | |
| 2251 | 2716 | return ''; |
| 2252 | 2717 | } |
| 2253 | 2718 | |
| 2719 | + // Theme id is mandatory (selects the server-side field schema; the | |
| 2720 | + // theme schema is GLOBAL per decision #263, so this stays flat). | |
| 2254 | 2721 | if ( isset( $options['mlsimport_theme_used'] ) ) { |
| 2255 | 2722 | $theme_id = intval( $options['mlsimport_theme_used'] ); |
| 2256 | 2723 | } else { |
| 2257 | 2724 | return ''; |
| @@ -2256,15 +2723,18 @@ | ||
| 2256 | 2723 | } else { |
| 2257 | 2724 | return ''; |
| 2258 | 2725 | } |
| 2259 | 2726 | |
| 2727 | + // Base parameters every request carries. | |
| 2260 | 2728 | $values = array(); |
| 2261 | 2729 | $values['mls_id'] = $mls_id; |
| 2262 | 2730 | $values['theme_id'] = $theme_id; |
| 2731 | + // Flag hourly-sync calls so the backend can treat them differently. | |
| 2263 | 2732 | if ( $is_hourly_sync ) { |
| 2264 | 2733 | $values['hourly_sync'] = 1; |
| 2265 | 2734 | } |
| 2266 | 2735 | |
| 2736 | + // Pagination (only when a page size was supplied). | |
| 2267 | 2737 | if ( '' !== $top ) { |
| 2268 | 2738 | $values['top'] = $top; |
| 2269 | 2739 | $values['skip'] = intval( $skip ); |
| 2270 | 2740 | } |
| @@ -2269,8 +2739,9 @@ | ||
| 2269 | 2739 | $values['skip'] = intval( $skip ); |
| 2270 | 2740 | } |
| 2271 | 2741 | |
| 2272 | 2742 | // // add price |
| 2743 | + // Price range (only when both bounds are set). | |
| 2273 | 2744 | $mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true ); |
| 2274 | 2745 | $mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true ); |
| 2275 | 2746 | if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) { |
| 2276 | 2747 | $values['list_price_min'] = floatval( $mlsimport_item_min_price ); |
| @@ -2293,11 +2764,11 @@ | ||
| 2293 | 2764 | $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values ); |
| 2294 | 2765 | |
| 2295 | 2766 | // add status |
| 2296 | 2767 | |
| 2297 | - if ( 111 !== $mls_id ) { // edmonton check | |
| 2298 | - $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values ); | |
| 2299 | - } | |
| 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 ); | |
| 2300 | 2771 | |
| 2301 | 2772 | // add property_subtype |
| 2302 | 2773 | $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values ); |
| 2303 | 2774 | |
| @@ -2303,19 +2774,8 @@ | ||
| 2303 | 2774 | |
| 2304 | 2775 | // add property_type |
| 2305 | 2776 | $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values ); |
| 2306 | 2777 | |
| 2307 | - // rapattoni exception | |
| 2308 | - if ( $mls_id > 5000 && | |
| 2309 | - ( isset($values['property_type']) && $values['property_type'] !='' ) ) { | |
| 2310 | - | |
| 2311 | - $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values ); | |
| 2312 | - $temp = $values['property_type']; | |
| 2313 | - $temp = str_replace( ' ', '', $temp ); | |
| 2314 | - $values['property_type'] = array(); | |
| 2315 | - $values['property_type'][] = $temp; | |
| 2316 | - } | |
| 2317 | - | |
| 2318 | 2778 | // add internet_entirelisting_displayyn |
| 2319 | 2779 | $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values ); |
| 2320 | 2780 | |
| 2321 | 2781 | // add internet_address_displayyn |
| @@ -2334,8 +2794,12 @@ | ||
| 2334 | 2794 | |
| 2335 | 2795 | // add ListingId |
| 2336 | 2796 | $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values ); |
| 2337 | 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 | + | |
| 2338 | 2802 | //add Exclude_ListOfficeKey |
| 2339 | 2803 | $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values ); |
| 2340 | 2804 | // add Exclude_ListOfficeMlsId |
| 2341 | 2805 | $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values ); |
| @@ -2349,31 +2813,43 @@ | ||
| 2349 | 2813 | // add CustomParameters |
| 2350 | 2814 | $values = $this->mls_import_saas_add_to_parms_input( 'CustomParameters', $item_id, 'custom_parameters', $values ); |
| 2351 | 2815 | |
| 2352 | 2816 | |
| 2353 | - // if we have realtorca | |
| 2354 | - if ($mls_id >= 7000 && $mls_id < 8000 && $last_date!=='') { | |
| 2355 | - $dateTime_realtorca = new DateTime($last_date, new DateTimeZone('UTC')); | |
| 2356 | - // Format with seconds and UTC timezone marker | |
| 2357 | - $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() ); | |
| 2358 | 2827 | } |
| 2359 | 2828 | |
| 2360 | - if ( '' !== $last_date ) { | |
| 2361 | - $values['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'] ); | |
| 2362 | 2832 | } |
| 2363 | 2833 | |
| 2364 | - return( $values ); | |
| 2834 | + return $prepared['arguments']; | |
| 2365 | 2835 | } |
| 2366 | 2836 | |
| 2367 | 2837 | |
| 2368 | 2838 | |
| 2369 | - /* | |
| 2839 | + /** | |
| 2840 | + * Copy a single scalar Import Task meta value into the arguments array. | |
| 2370 | 2841 | * |
| 2371 | - * add input items to parameters array | |
| 2842 | + * Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name. | |
| 2372 | 2843 | * |
| 2844 | + * @param string $key Field key (used to build the meta key). | |
| 2845 | + * @param int $post_id Import Task post id. | |
| 2846 | + * @param string $new_name API parameter name to store under. | |
| 2847 | + * @param array $all_values Accumulating arguments array. | |
| 2848 | + * @return array The updated arguments array. | |
| 2373 | 2849 | */ |
| 2374 | - | |
| 2375 | 2850 | public function mls_import_saas_add_to_parms_input( $key, $post_id, $new_name, $all_values ) { |
| 2851 | + // Read the scalar meta value and add it only when set. | |
| 2376 | 2852 | $name = strtolower( 'mlsimport_item_' . $key ); |
| 2377 | 2853 | $value = get_post_meta( $post_id, $name, true ); |
| 2378 | 2854 | if ( '' !== $value ) { |
| 2379 | 2855 | $all_values[ $new_name ] = $value; |
| @@ -2382,15 +2858,24 @@ | ||
| 2382 | 2858 | return $all_values; |
| 2383 | 2859 | } |
| 2384 | 2860 | |
| 2385 | 2861 | |
| 2386 | - /* | |
| 2862 | + /** | |
| 2863 | + * Copy a multi-value (list) Import Task meta value into the arguments array. | |
| 2387 | 2864 | * |
| 2388 | - * add list items to parameters array | |
| 2389 | - * | |
| 2865 | + * Reads the list value plus its "_check" (select-all) flag; for city/county | |
| 2866 | + * it also merges any comma-separated "extra" free-text values. The value is | |
| 2867 | + * added only when select-all is off and it is non-empty — except 'status', | |
| 2868 | + * which is always written. | |
| 2869 | + * | |
| 2870 | + * @param string $key Field key (used to build the meta keys). | |
| 2871 | + * @param int $post_id Import Task post id. | |
| 2872 | + * @param string $new_name API parameter name to store under. | |
| 2873 | + * @param array $all_values Accumulating arguments array. | |
| 2874 | + * @return array The updated arguments array. | |
| 2390 | 2875 | */ |
| 2391 | - | |
| 2392 | 2876 | public function mls_import_return_multiple_param_value( $key, $post_id, $new_name, $all_values ) { |
| 2877 | + // The selected list value and its companion select-all flag. | |
| 2393 | 2878 | $name_check = strtolower( 'mlsimport_item_' . $key . '_check' ); |
| 2394 | 2879 | $name = strtolower( 'mlsimport_item_' . $key ); |
| 2395 | 2880 | |
| 2396 | 2881 | $value = get_post_meta( $post_id, $name, true ); |
| @@ -2435,8 +2920,9 @@ | ||
| 2435 | 2920 | } |
| 2436 | 2921 | } |
| 2437 | 2922 | } |
| 2438 | 2923 | |
| 2924 | + // Only include the list when "select all" is off and there is a value. | |
| 2439 | 2925 | $value_check = get_post_meta( $post_id, $name_check, true ); |
| 2440 | 2926 | |
| 2441 | 2927 | if ( 0 === intval($value_check) && '' !== $value ) { |
| 2442 | 2928 | $all_values[ $new_name ] = $value; |
| @@ -2441,9 +2927,9 @@ | ||
| 2441 | 2927 | if ( 0 === intval($value_check) && '' !== $value ) { |
| 2442 | 2928 | $all_values[ $new_name ] = $value; |
| 2443 | 2929 | } |
| 2444 | 2930 | |
| 2445 | - // status exception | |
| 2931 | + // status exception: always send status, regardless of the check flag. | |
| 2446 | 2932 | if ( 'status' === $new_name ) { |
| 2447 | 2933 | $all_values[ $new_name ] = $value; |
| 2448 | 2934 | } |
| 2449 | 2935 | |
| @@ -2451,23 +2937,27 @@ | ||
| 2451 | 2937 | } |
| 2452 | 2938 | |
| 2453 | 2939 | |
| 2454 | 2940 | |
| 2455 | - /* | |
| 2941 | + /** | |
| 2942 | + * Build the Import Task field definition list (labels, types, enum values). | |
| 2456 | 2943 | * |
| 2457 | - * All Enums fiels to be used on MLS import Taaks | |
| 2944 | + * Reads the saved MLS enums option, extracts the available City / County / | |
| 2945 | + * status / property (sub)type value lists, and returns the ordered field | |
| 2946 | + * definition array the metabox renders from. Falls back StandardStatus to | |
| 2947 | + * MlsStatus when the MLS has no StandardStatus enum. Emits a warning when no | |
| 2948 | + * metadata has been fetched yet. | |
| 2458 | 2949 | * |
| 2459 | - * | |
| 2460 | - * | |
| 2461 | - * | |
| 2462 | - * | |
| 2463 | - * | |
| 2464 | - * */ | |
| 2950 | + * @param int $mls_id Connection whose enums to read (#277); 0 = current. | |
| 2951 | + * @return array Field key => definition (label, description, type, multiple, values). | |
| 2952 | + */ | |
| 2953 | + public function mlsimport_saas_return_mls_fields( int $mls_id = 0 ) { | |
| 2465 | 2954 | |
| 2466 | - public function mlsimport_saas_return_mls_fields() { | |
| 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 ); | |
| 2467 | 2958 | |
| 2468 | - $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' ); | |
| 2469 | - | |
| 2959 | + // Warn the user when no metadata is available yet. | |
| 2470 | 2960 | if ( '' === $mlsimport_mls_metadata_mls_enums ) { |
| 2471 | 2961 | ?> |
| 2472 | 2962 | <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div> |
| 2473 | 2963 | <?php |
| @@ -2472,8 +2962,9 @@ | ||
| 2472 | 2962 | <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div> |
| 2473 | 2963 | <?php |
| 2474 | 2964 | } |
| 2475 | 2965 | |
| 2966 | + // Decode and reach into the enum container. | |
| 2476 | 2967 | $metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true ); |
| 2477 | 2968 | |
| 2478 | 2969 | if ( isset( $metadata_api_call_full['global_array'] ) ) { |
| 2479 | 2970 | $metadata_api_call = $metadata_api_call_full['global_array']; |
| @@ -2478,8 +2969,9 @@ | ||
| 2478 | 2969 | if ( isset( $metadata_api_call_full['global_array'] ) ) { |
| 2479 | 2970 | $metadata_api_call = $metadata_api_call_full['global_array']; |
| 2480 | 2971 | } |
| 2481 | 2972 | |
| 2973 | + // Extract each enum list as a flat array of option keys (empty if absent). | |
| 2482 | 2974 | $city_array = array(); |
| 2483 | 2975 | if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) { |
| 2484 | 2976 | $city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] ); |
| 2485 | 2977 | } |
| @@ -2510,8 +3002,9 @@ | ||
| 2510 | 3002 | $standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] ); |
| 2511 | 3003 | } |
| 2512 | 3004 | |
| 2513 | 3005 | // if we do not have standart status |
| 3006 | + // Fall back to MlsStatus values when the MLS exposes no StandardStatus. | |
| 2514 | 3007 | if ( empty( $standardstatus_array ) ) { |
| 2515 | 3008 | $standardstatus_array = $mlsstatus_array; |
| 2516 | 3009 | } |
| 2517 | 3010 | |
| @@ -2517,11 +3010,13 @@ | ||
| 2517 | 3010 | |
| 2518 | 3011 | |
| 2519 | 3012 | |
| 2520 | 3013 | |
| 3014 | + // Free-text "extra" inputs render empty; they hold comma-separated values. | |
| 2521 | 3015 | $extracounty_values = ''; |
| 2522 | 3016 | $extracity_values = ''; |
| 2523 | 3017 | |
| 3018 | + // Ordered field definitions consumed by the Import Task metabox renderer. | |
| 2524 | 3019 | $field_import = array( |
| 2525 | 3020 | 'City' => array( |
| 2526 | 3021 | 'label' => esc_html__( 'Select cities', 'mlsimport' ), |
| 2527 | 3022 | 'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ), |
| @@ -2660,8 +3155,14 @@ | ||
| 2660 | 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'), |
| 2661 | 3156 | 'type' => 'input', |
| 2662 | 3157 | 'multiple' => 'no', |
| 2663 | 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 | + ), | |
| 2664 | 3165 | 'Exclude_ListOfficeMlsId' => array( |
| 2665 | 3166 | 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ), |
| 2666 | 3167 | 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ), |
| 2667 | 3168 | 'type' => 'input', |
| @@ -2705,328 +3206,206 @@ | ||
| 2705 | 3206 | |
| 2706 | 3207 | |
| 2707 | 3208 | |
| 2708 | 3209 | /** |
| 3210 | + * AJAX: kick off a manual import for one Import Task. | |
| 2709 | 3211 | * |
| 3212 | + * Resets the force-stop flag, builds the paginated batch of request-argument | |
| 3213 | + * sets, stores them (and zeroed progress meta), marks the task 'started', and | |
| 3214 | + * enqueues the Action Scheduler background job that does the actual import. | |
| 3215 | + * Returns any build error immediately, otherwise {success:true}. | |
| 2710 | 3216 | * |
| 2711 | - * AYsnc Test | |
| 3217 | + * @return void Emits JSON. | |
| 2712 | 3218 | */ |
| 2713 | 3219 | public function mlsimport_move_files_per_item() { |
| 2714 | 3220 | check_ajax_referer( 'mlsimport_item_actions', 'security' ); |
| 2715 | - $post_id = 0; | |
| 2716 | - $how_many = 0; | |
| 2717 | - $max_number = 0; | |
| 2718 | - if(isset( $_POST['post_id'] )){ | |
| 2719 | - $post_id = intval( $_POST['post_id'] ); | |
| 2720 | - } | |
| 2721 | - if(isset( $_POST['how_many'] )){ | |
| 2722 | - $how_many = intval( $_POST['how_many'] ); | |
| 2723 | - } | |
| 2724 | - if(isset( $_POST['post_number'] )){ | |
| 2725 | - $max_number = intval( $_POST['post_number'] ); | |
| 2726 | - } | |
| 2727 | 3221 | |
| 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; | |
| 2728 | 3226 | |
| 2729 | - $is_onboard=intval($_POST['is_onboard']); | |
| 3227 | + // Reject the request before the shared runner or any task state changes. | |
| 3228 | + if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { | |
| 3229 | + wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 ); | |
| 3230 | + } | |
| 2730 | 3231 | |
| 2731 | - | |
| 2732 | - update_option( 'mlsimport_force_stop_' . $post_id, 'no', false ); | |
| 2733 | - | |
| 2734 | - $item_id_array = array( | |
| 2735 | - 'item_id' => $post_id, | |
| 2736 | - 'how_many' => $how_many, | |
| 2737 | - 'max_number' => $max_number, | |
| 2738 | - '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 | + ) | |
| 2739 | 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 | + } | |
| 2740 | 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 ); | |
| 2741 | 3255 | |
| 2742 | - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' ); | |
| 3256 | + $this->mlsimport_enqueue_import_worker( (string) $start['run_id'] ); | |
| 2743 | 3257 | |
| 2744 | - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array ); | |
| 2745 | - | |
| 2746 | - // If an error was returned from the API, sanitize and send it back to the client and stop further processing. | |
| 2747 | - if ( isset( $attachments_to_move['success'] ) && false === $attachments_to_move['success'] ) { | |
| 2748 | - if ( isset( $attachments_to_move['message'] ) ) { | |
| 2749 | - $attachments_to_move['message'] = wp_strip_all_tags( $attachments_to_move['message'] ); | |
| 2750 | - } | |
| 2751 | - wp_send_json( $attachments_to_move ); | |
| 2752 | - wp_die(); | |
| 2753 | - } | |
| 2754 | - | |
| 2755 | - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move ); | |
| 2756 | - | |
| 2757 | - // net stat data | |
| 2758 | - update_post_meta( $post_id, 'mlsimport_progress_properties', 0 ); | |
| 2759 | - update_post_meta( $post_id, 'mlsimport_progress_batches', 0 ); | |
| 2760 | - update_post_meta( $post_id, 'mlsimport_progress_memory', 0 ); | |
| 2761 | - | |
| 2762 | - | |
| 2763 | - | |
| 2764 | - $attachments_to_send = array( | |
| 2765 | - 'args' => array( | |
| 2766 | - 'attachments_to_move' => $post_id, | |
| 2767 | - 'item_id_array' => $item_id_array, | |
| 2768 | - 'is_onboard' =>$is_onboard, | |
| 2769 | - ), | |
| 3258 | + wp_send_json( | |
| 3259 | + array( | |
| 3260 | + 'success' => true, | |
| 3261 | + 'run_id' => (string) $start['run_id'], | |
| 3262 | + ) | |
| 2770 | 3263 | ); |
| 3264 | + } | |
| 2771 | 3265 | |
| 2772 | - mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL ); | |
| 2773 | - mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL ); | |
| 2774 | - | |
| 2775 | - update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' ); | |
| 2776 | - | |
| 2777 | - // old | |
| 2778 | - as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send ); | |
| 2779 | - | |
| 2780 | - // Remove any pending async jobs for this item and enqueue a unique one | |
| 2781 | - //bad ideea | |
| 2782 | - // as_unschedule_all_actions( 'mlsimport_background_process_per_item', $attachments_to_send ); | |
| 2783 | - //as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send, '', true ); | |
| 2784 | - | |
| 2785 | - | |
| 2786 | - spawn_cron(); | |
| 2787 | - | |
| 2788 | - unset( $attachments_to_send ); | |
| 2789 | - | |
| 2790 | - // Return success response to the AJAX caller. | |
| 2791 | - wp_send_json( array( 'success' => true ) ); | |
| 2792 | - } | |
| 2793 | - | |
| 2794 | 3266 | /** |
| 2795 | - * Process MLS Import attachments via background cron. | |
| 2796 | - * Memory-optimized with detailed memory usage logging. | |
| 2797 | - * | |
| 2798 | - * @param array $input_arg | |
| 2799 | - * @return void | |
| 2800 | - */ | |
| 2801 | - public function mlsimport_background_process_per_item_cron_function( $input_arg ) { | |
| 2802 | - global $mlsimport; | |
| 2803 | - | |
| 2804 | - $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL; | |
| 2805 | - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' ); | |
| 2806 | - mlsimport_saas_single_write_import_custom_logs( '[Memory] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' ); | |
| 2807 | - | |
| 2808 | - // Load attachments to move from post meta | |
| 2809 | - $attachments_to_move = get_post_meta( | |
| 2810 | - $input_arg['item_id_array']['item_id'], | |
| 2811 | - 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'], | |
| 2812 | - true | |
| 2813 | - ); | |
| 2814 | - $log = '[Memory] After loading attachments: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL; | |
| 2815 | - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' ); | |
| 2816 | - | |
| 2817 | - if (!empty($attachments_to_move) && is_array($attachments_to_move)) { | |
| 2818 | - foreach ($attachments_to_move as $key => $import_arguments) { | |
| 2819 | - // Optionally clear any cache for this batch | |
| 2820 | - if ( isset($GLOBALS['wp_object_cache']) ) { | |
| 2821 | - $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options'); | |
| 2822 | - } | |
| 2823 | - | |
| 2824 | - $log = '[Memory] Before API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL; | |
| 2825 | - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' ); | |
| 2826 | - | |
| 2827 | - // API call | |
| 2828 | - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas('listings', $import_arguments, 'POST'); | |
| 2829 | - | |
| 2830 | - $log = '[Memory] After API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL; | |
| 2831 | - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' ); | |
| 2832 | - | |
| 2833 | - // Parse/process response | |
| 2834 | - $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem( | |
| 2835 | - $api_call_array, $input_arg['item_id_array'], $key | |
| 2836 | - ); | |
| 2837 | - | |
| 2838 | - // Free per-iteration memory | |
| 2839 | - unset($api_call_array, $import_arguments); | |
| 2840 | - gc_collect_cycles(); | |
| 2841 | - | |
| 2842 | - $log = '[Memory] After cleanup batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL; | |
| 2843 | - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' ); | |
| 2844 | - } | |
| 2845 | - } | |
| 2846 | - | |
| 2847 | - mlsimport_saas_single_write_import_custom_logs('[Memory] End: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' ); | |
| 2848 | - mlsimport_saas_single_write_import_custom_logs('CRON JOB Import Completed ' . PHP_EOL, 'cron'); | |
| 2849 | - mlsimport_debuglogs_per_plugin('CRON JOB Import Completed ' . PHP_EOL); | |
| 2850 | - update_post_meta($input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed'); | |
| 2851 | - | |
| 2852 | - unset($attachments_to_move, $input_arg, $log); | |
| 2853 | - gc_collect_cycles(); | |
| 2854 | - } | |
| 2855 | - | |
| 2856 | - | |
| 2857 | - | |
| 2858 | - | |
| 2859 | - /** | |
| 3267 | + * Queue the background import worker for an accepted Import Run. | |
| 2860 | 3268 | * |
| 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. | |
| 2861 | 3278 | * |
| 2862 | - * Generate import Requests per item | |
| 3279 | + * @param string $run_id Accepted run identity to hand to the worker. | |
| 3280 | + * @return void | |
| 2863 | 3281 | */ |
| 2864 | - public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '', $is_hourly_sync = false ) { | |
| 2865 | - $import_step = 25; | |
| 2866 | - | |
| 2867 | - $prop_id = $item_id_array['item_id']; | |
| 2868 | - $max_found = $item_id_array['max_number']; | |
| 2869 | - $how_many = $item_id_array['how_many']; | |
| 2870 | - if ( 0=== intval($how_many) ) { | |
| 2871 | - $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 ); | |
| 2872 | 3300 | } |
| 2873 | - if ( $how_many > $max_found ) { | |
| 2874 | - $how_many = $max_found; | |
| 2875 | - } | |
| 2876 | 3301 | |
| 2877 | - $search_url_step = ''; | |
| 2878 | - $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 | + } | |
| 2879 | 3310 | |
| 2880 | - $skip = 0; | |
| 2881 | - if ( $how_many > 10000 ) { | |
| 2882 | - $how_many = 10000; | |
| 2883 | - } | |
| 2884 | - 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 | + } | |
| 2885 | 3319 | |
| 2886 | - if ( $how_many < $import_step ) { | |
| 2887 | - $import_step = $how_many; | |
| 2888 | - } | |
| 2889 | 3320 | |
| 2890 | - while ( $skip < $how_many ) { | |
| 2891 | 3321 | |
| 2892 | - // Determine how many items to request for this batch. | |
| 2893 | - $batch_step = min( $import_step, $how_many - $skip ); | |
| 2894 | 3322 | |
| 2895 | - // Build the request arguments using the remaining count. | |
| 2896 | - $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $batch_step, $is_hourly_sync ); | |
| 2897 | 3323 | |
| 2898 | - // If the API returned an error, propagate it immediately. | |
| 2899 | - if ( isset( $search_url_step['success'] ) && false === $search_url_step['success'] ) { | |
| 2900 | - return $search_url_step; | |
| 2901 | - } | |
| 2902 | 3324 | |
| 2903 | - $skip += $batch_step; | |
| 2904 | - $urls_array[] = $search_url_step; | |
| 2905 | 3325 | |
| 2906 | 3326 | |
| 2907 | - } | |
| 2908 | - return $urls_array; | |
| 2909 | - } | |
| 2910 | 3327 | |
| 2911 | 3328 | |
| 2912 | 3329 | |
| 2913 | 3330 | |
| 2914 | - | |
| 2915 | - | |
| 2916 | - | |
| 2917 | 3331 | /** |
| 2918 | - * Process Async function | |
| 3332 | + * Action Scheduler adapter for the shared Import Task runner. | |
| 3333 | + * | |
| 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. | |
| 3336 | + * | |
| 3337 | + * @param array|string $input_arg Run payload, or the run id for direct callers. | |
| 3338 | + * @return void | |
| 2919 | 3339 | */ |
| 2920 | 3340 | public function mlsimport_background_process_per_item_function( $input_arg ) { |
| 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' ); | |
| 3344 | + return; | |
| 3345 | + } | |
| 2921 | 3346 | |
| 2922 | - $mlsimportItemId = $input_arg['item_id_array']['item_id']; | |
| 2923 | - $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> '; | |
| 2924 | - mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL ); | |
| 2925 | - | |
| 2926 | - | |
| 2927 | - // Get from MLS Import the big argument array only once | |
| 2928 | - $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true ); | |
| 2929 | - | |
| 2930 | - // Retrieve all meta data in one go to reduce database queries | |
| 2931 | - $mlsimport_item_option_data = array( | |
| 2932 | - 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ), | |
| 2933 | - 'mlsimport_item_standardstatusprotect' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusprotect', true ), | |
| 2934 | - 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ), | |
| 2935 | - 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ), | |
| 2936 | - '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 | + } | |
| 2937 | 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 | + ); | |
| 2938 | 3373 | |
| 2939 | - $total_batches = count( $attachments_to_move ); | |
| 2940 | - | |
| 2941 | - // removed because $this | |
| 2942 | - global $mlsimport; | |
| 2943 | - | |
| 2944 | - $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL; | |
| 2945 | - mlsimport_saas_single_write_import_custom_logs( $log ); | |
| 2946 | - | |
| 2947 | - | |
| 2948 | - foreach ( $attachments_to_move as $key => $import_arguments ) { | |
| 2949 | - // reconsider use | |
| 2950 | - // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options'); | |
| 2951 | - $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId ); | |
| 2952 | - if ( 'no' === $status ) { | |
| 2953 | - // Clear memory before processing each batch | |
| 2954 | - wp_cache_flush(); | |
| 2955 | - gc_collect_cycles(); | |
| 2956 | - | |
| 2957 | - // wp_cache_flush(); | |
| 2958 | - $mem_usage = memory_get_usage( true ); | |
| 2959 | - $mem_usage_show = round( $mem_usage / 1048576, 2 ); | |
| 2960 | - | |
| 2961 | - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $key + 1 ); | |
| 2962 | - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $mem_usage_show ); | |
| 2963 | - | |
| 2964 | - | |
| 2965 | - | |
| 2966 | - mlsimport_saas_single_write_import_custom_logs( $log ); | |
| 2967 | - $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL; | |
| 2968 | - | |
| 2969 | - | |
| 2970 | - // Combine logs and reduce function calls | |
| 2971 | - mlsimport_saas_single_write_import_custom_logs( $log ); | |
| 2972 | - mlsimport_debuglogs_per_plugin( $log ); | |
| 2973 | - print esc_html($log); | |
| 2974 | - | |
| 2975 | - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' ); | |
| 2976 | - | |
| 2977 | - $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data ); | |
| 2978 | - | |
| 2979 | - | |
| 2980 | - | |
| 2981 | - // Explicitly unset large variables after each batch | |
| 2982 | - unset($api_call_array); | |
| 2983 | - | |
| 2984 | - // Force garbage collection again after processing | |
| 2985 | - wp_cache_flush(); | |
| 2986 | - gc_collect_cycles(); | |
| 2987 | - | |
| 2988 | - | |
| 2989 | - // Add a small delay to allow memory to be freed | |
| 2990 | - if (($key + 1) < $total_batches) { | |
| 2991 | - usleep(100000); // 100ms pause between batches | |
| 2992 | - } | |
| 2993 | - | |
| 2994 | - } else { | |
| 2995 | - | |
| 2996 | - $final_mem_usage = memory_get_usage( true ); | |
| 2997 | - $final_mem_usage_show = round( $final_mem_usage / 1048576, 2 ); | |
| 2998 | - | |
| 2999 | - | |
| 3000 | - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' ); | |
| 3001 | - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId ); | |
| 3002 | - | |
| 3003 | - //new stats | |
| 3004 | - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $total_batches ); | |
| 3005 | - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $final_mem_usage_show ); | |
| 3006 | - | |
| 3007 | - | |
| 3008 | - mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' ); | |
| 3009 | - mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' ); | |
| 3010 | - break; // Exit the loop if forced to stop | |
| 3011 | - } | |
| 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 | |
| 3012 | 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 ); | |
| 3013 | 3386 | |
| 3014 | - mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL ); | |
| 3015 | - mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL ); | |
| 3016 | - | |
| 3017 | - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' ); | |
| 3018 | - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId ); | |
| 3019 | - | |
| 3020 | - // Final cleanup | |
| 3021 | - unset($attachments_to_move); | |
| 3022 | - unset($mlsimport_item_option_data); | |
| 3023 | - unset($input_arg); | |
| 3024 | - unset($log); | |
| 3025 | - | |
| 3026 | - // One final garbage collection | |
| 3027 | - wp_cache_flush(); | |
| 3028 | - 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(); | |
| 3029 | 3408 | } |
| 3030 | 3409 | |
| 3031 | 3410 | |
| 3032 | 3411 | |
| @@ -3036,73 +3415,71 @@ | ||
| 3036 | 3415 | |
| 3037 | 3416 | |
| 3038 | 3417 | |
| 3039 | 3418 | /** |
| 3419 | + * AJAX: poll import status/logs for a task (drives the progress UI). | |
| 3040 | 3420 | * |
| 3421 | + * Admin-only; accepts either the import-task or onboarding nonce. Reads the | |
| 3422 | + * status log file plus progress meta and returns a JSON payload flagged | |
| 3423 | + * 'done' (stopped/completed) or 'wip' (in progress). | |
| 3041 | 3424 | * |
| 3042 | - * | |
| 3043 | - * update log function | |
| 3425 | + * @return void Emits JSON then dies. | |
| 3044 | 3426 | */ |
| 3045 | 3427 | public function mlsimport_logger_per_item() { |
| 3046 | - //check_ajax_referer( 'mlsimport_item_actions', 'security' ); | |
| 3428 | + // Authorization: only administrators may read import logs/status | |
| 3429 | + // (consistent with mlsimport_get_taxonomy_terms()). | |
| 3430 | + if ( ! current_user_can( 'administrator' ) ) { | |
| 3431 | + wp_send_json_error( 'Unauthorized' ); | |
| 3432 | + } | |
| 3433 | + // CSRF: accept the nonce from either legitimate caller — the import-task | |
| 3434 | + // screen (mlsimport_item_actions) or the onboarding wizard (mlsimport_onboarding_nonce). | |
| 3435 | + if ( ! check_ajax_referer( 'mlsimport_item_actions', 'security', false ) | |
| 3436 | + && ! check_ajax_referer( 'mlsimport_onboarding_nonce', 'security', false ) ) { | |
| 3437 | + wp_send_json_error( array( 'message' => 'invalid nonce' ), 403 ); | |
| 3438 | + } | |
| 3047 | 3439 | $post_id=0; |
| 3048 | 3440 | if(isset($_POST['post_id'] )){ |
| 3049 | 3441 | $post_id = intval( $_POST['post_id'] ); |
| 3050 | 3442 | } |
| 3051 | 3443 | |
| 3052 | - $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true ); | |
| 3053 | - $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log'; | |
| 3054 | - $logs = file_get_contents( $path ); | |
| 3055 | - | |
| 3056 | - //get neww status data | |
| 3057 | - $current = intval( get_post_meta( $post_id, 'mlsimport_progress_properties', true ) ); | |
| 3058 | - $total = intval( get_post_meta( $post_id, 'mlsimport_progress_batches', true ) ); | |
| 3059 | - $memory = get_post_meta( $post_id, 'mlsimport_progress_memory', true ); | |
| 3060 | - $mlsimport_task_to_import = intval( get_post_meta($post_id, 'mlsimport_task_to_import',true)); | |
| 3061 | - | |
| 3062 | - | |
| 3063 | - | |
| 3064 | - $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) ); | |
| 3065 | - $force_status = get_option( 'mlsimport_force_stop_' . $post_id ); | |
| 3066 | - | |
| 3067 | - if ( 'no' !== $force_status ) { | |
| 3068 | - echo wp_json_encode( | |
| 3069 | - array( | |
| 3070 | - 'is_done' => 'done', | |
| 3071 | - 'status' => $status, | |
| 3072 | - 'logs' => $logs, | |
| 3073 | - ) | |
| 3074 | - ); | |
| 3075 | - 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' ); | |
| 3076 | 3456 | } |
| 3077 | 3457 | |
| 3078 | - if ( '' === $status || 'completed' === $status ) { | |
| 3079 | - echo wp_json_encode( | |
| 3080 | - array( | |
| 3081 | - 'is_done' => 'done', | |
| 3082 | - 'status' => $status, | |
| 3083 | - 'logs' => $logs, | |
| 3084 | - 'mlsimport_progress_properties' => $current, | |
| 3085 | - 'mlsimport_task_to_import' => $total, | |
| 3086 | - ) | |
| 3087 | - ); | |
| 3088 | - } else { | |
| 3089 | - // return from log | |
| 3090 | - echo wp_json_encode( | |
| 3091 | - array( | |
| 3092 | - 'is_done' => 'wip', | |
| 3093 | - 'status' => $status, | |
| 3094 | - 'logs' => $logs, | |
| 3095 | - 'mlsimport_progress_properties' => $current, | |
| 3096 | - 'mlsimport_progress_batches' => $total, | |
| 3097 | - 'memory' => $memory, | |
| 3098 | - 'mlsimport_task_to_import'=>$mlsimport_task_to_import, | |
| 3099 | - '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 ) : ''; | |
| 3100 | 3463 | |
| 3101 | - ) | |
| 3102 | - ); | |
| 3103 | - } | |
| 3104 | - 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 | + ); | |
| 3105 | 3482 | } |
| 3106 | 3483 | |
| 3107 | 3484 | |
| 3108 | 3485 | |
| @@ -3109,58 +3486,105 @@ | ||
| 3109 | 3486 | |
| 3110 | 3487 | |
| 3111 | 3488 | |
| 3112 | 3489 | /** |
| 3490 | + * AJAX: request a force-stop of a running import for one task. | |
| 3113 | 3491 | * |
| 3492 | + * Sets the per-task force-stop option to 'yes' and clears its object-cache | |
| 3493 | + * entry so the in-flight background loop notices on its next iteration. | |
| 3114 | 3494 | * |
| 3115 | - * | |
| 3116 | - * | |
| 3117 | - * Force Stop Import | |
| 3495 | + * @return void Emits JSON success. | |
| 3118 | 3496 | */ |
| 3119 | 3497 | public function mlsimport_stop_import_per_item() { |
| 3120 | - | |
| 3121 | 3498 | |
| 3499 | + | |
| 3500 | + // CSRF + read the task id. | |
| 3122 | 3501 | check_ajax_referer( 'mlsimport_item_actions', 'security' ); |
| 3123 | 3502 | $post_id=0; |
| 3124 | 3503 | if(isset($_POST['post_id'] )){ |
| 3125 | 3504 | $post_id = intval( $_POST['post_id'] ); |
| 3126 | 3505 | } |
| 3506 | + // Admin boundary: the target must be an Import Task the user can edit. | |
| 3507 | + if ( 'mlsimport_item' !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { | |
| 3508 | + wp_send_json_error( array( 'message' => esc_html__( 'You are not allowed to manage this import task.', 'mlsimport' ) ), 403 ); | |
| 3509 | + } | |
| 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. | |
| 3127 | 3513 | update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false ); |
| 3128 | - // ensure caches are cleared so running processes see the update immediately | |
| 3129 | - if ( function_exists( 'wp_cache_delete' ) ) { | |
| 3130 | - wp_cache_delete( 'mlsimport_force_stop_' . $post_id, 'options' ); | |
| 3131 | - } | |
| 3132 | 3514 | mlsimport_saas_single_write_import_custom_logs( 'Stopped for ' . $post_id . PHP_EOL ); |
| 3133 | 3515 | mlsimport_debuglogs_per_plugin( 'Stopped for ' . $post_id . PHP_EOL ); |
| 3134 | - wp_send_json_success(); | |
| 3516 | + wp_send_json_success( array( 'accepted' => (bool) $stop['accepted'] ) ); | |
| 3135 | 3517 | } |
| 3136 | 3518 | |
| 3137 | 3519 | |
| 3138 | 3520 | |
| 3139 | - /* | |
| 3521 | + /** | |
| 3522 | + * AJAX: fetch the MLS metadata (theme schema + field data + enums) for the | |
| 3523 | + * configured theme and cache it in options, marking metadata as populated. | |
| 3140 | 3524 | * |
| 3141 | - * Get MLS Metadata | |
| 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. | |
| 3142 | 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). | |
| 3143 | 3538 | * |
| 3144 | - * | |
| 3145 | - * | |
| 3146 | - **/ | |
| 3539 | + * @return void | |
| 3540 | + */ | |
| 3541 | + public function mlsimport_saas_get_metadata_function() { | |
| 3542 | + // CSRF. | |
| 3543 | + check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' ); | |
| 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 | + } | |
| 3147 | 3547 | |
| 3148 | - public function mlsimport_saas_get_metadata_function() { | |
| 3149 | - check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' ); | |
| 3150 | - $theme_Start = new ThemeImport(); | |
| 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 ); | |
| 3151 | 3550 | |
| 3152 | - $values = array(); | |
| 3153 | - $options = get_option( $this->plugin_name . '_admin_options' ); | |
| 3154 | - $url = 'clients?theme_id=' . intval( $options['mlsimport_theme_used'] ); | |
| 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 | + } | |
| 3155 | 3565 | |
| 3156 | - $answer = $theme_Start::globalApiRequestSaas( $url, $values, 'GET' ); | |
| 3566 | + // One shared gather core, scoped to the resolved connection. | |
| 3567 | + $gather = mlsimport_gather_connection_metadata( $mls_id ); | |
| 3157 | 3568 | |
| 3158 | - update_option( 'mlsimport_mls_metadata_populated', 'yes' ); | |
| 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 | + } | |
| 3159 | 3579 | |
| 3160 | - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] ); | |
| 3161 | - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] ); | |
| 3162 | - update_option( 'mlsimport_mls_metadata_mls_enums', $answer['mls_data']['mls_meta_enums'] ); | |
| 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'] ) ); | |
| 3163 | 3587 | } |
| 3164 | 3588 | |
| 3165 | 3589 | |
| 3166 | 3590 | |
| @@ -3173,17 +3597,24 @@ | ||
| 3173 | 3597 | |
| 3174 | 3598 | |
| 3175 | 3599 | |
| 3176 | 3600 | /** |
| 3601 | + * Append a timestamped message to the cron log file. | |
| 3177 | 3602 | * |
| 3603 | + * Arrays are JSON-encoded; ensures the WP filesystem is initialized before | |
| 3604 | + * writing (append + exclusive lock). | |
| 3178 | 3605 | * |
| 3179 | - * write debug logs | |
| 3606 | + * @param string|array $message Message to log. | |
| 3607 | + * @return void | |
| 3180 | 3608 | */ |
| 3181 | 3609 | public function mlsimport_debuglog_cron( $message ) { |
| 3610 | + // Encode arrays for readability. | |
| 3182 | 3611 | if ( is_array( $message ) ) { |
| 3183 | 3612 | $message = wp_json_encode( $message ); |
| 3184 | 3613 | } |
| 3614 | + // Prefix with a human-readable timestamp. | |
| 3185 | 3615 | $message = date( 'F j, Y, g:i a' ) . ' -> ' . $message; |
| 3616 | + // Ensure WP_Filesystem is available (harmless if already set up). | |
| 3186 | 3617 | global $wp_filesystem; |
| 3187 | 3618 | if ( empty( $wp_filesystem ) ) { |
| 3188 | 3619 | require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 3189 | 3620 | WP_Filesystem(); |
| @@ -3188,8 +3619,9 @@ | ||
| 3188 | 3619 | require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 3189 | 3620 | WP_Filesystem(); |
| 3190 | 3621 | } |
| 3191 | 3622 | |
| 3623 | + // Append to the cron log with an exclusive lock. | |
| 3192 | 3624 | $path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log'; |
| 3193 | 3625 | |
| 3194 | 3626 | file_put_contents( $path, $message, FILE_APPEND | LOCK_EX ); |
| 3195 | 3627 | } |