PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
← All changes | admin/class-mlsimport-admin.php +1539 -1107 6.3.57.2 View file →
@@ -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 }