PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
← All changes | admin/class-mlsimport-admin.php +2018 -1212 6.0.47.2.1 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,22 +349,126 @@
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
445 + // Searchable City/County multi-select — only on the Import Task edit screen.
446 + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
447 + $post_type = $screen ? (string) $screen->post_type : '';
448 + if ( $this->mlsimport_is_import_task_edit_screen( (string) $hook_suffix, $post_type ) ) {
449 + wp_enqueue_script( 'mlsimport-searchable-select', plugin_dir_url( __FILE__ ) . 'js/mlsimport-searchable-select.js', array(), MLSIMPORT_VERSION, true );
450 + }
451 +
452 + // Deactivation exit survey — only needed on the Plugins screen.
453 + if ( 'plugins.php' === $hook_suffix ) {
454 + // Enqueue the survey modal script and hand it the nonce, options and i18n.
455 + wp_enqueue_script( 'mlsimport-deactivation-survey', plugin_dir_url( __FILE__ ) . 'js/mlsimport-deactivation-survey.js', array( 'jquery' ), MLSIMPORT_VERSION, true );
456 + wp_localize_script( 'mlsimport-deactivation-survey', 'mlsimport_deact_survey', array(
457 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
458 + 'nonce' => wp_create_nonce( 'mlsimport_exit_survey' ),
459 + 'plugin_basename' => plugin_basename( MLSIMPORT_PLUGIN_PATH . 'mlsimport.php' ),
460 + 'options' => $this->get_exit_survey_options(),
461 + 'i18n' => array(
462 + 'title' => esc_html__( 'Before you go — quick question', 'mlsimport' ),
463 + 'intro' => esc_html__( 'Why are you deactivating MLS Import? Your answer helps us improve.', 'mlsimport' ),
464 + 'other_placeholder' => esc_html__( 'Tell us more (optional)', 'mlsimport' ),
465 + 'submit' => esc_html__( 'Submit & Deactivate', 'mlsimport' ),
466 + 'skip' => esc_html__( 'Skip & Deactivate', 'mlsimport' ),
467 + ),
468 + ) );
469 + }
470 +
211 471 }
212 472
213 473
214 474
@@ -214,16 +474,18 @@
214 474
215 475
216 476
217 477 /**
478 + * Register the administration menu for this plugin into the WordPress Dashboard menu.
218 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.
219 483 *
220 - *
221 - * Register the administration menu for this plugin into the WordPress Dashboard menu.
222 - *
223 484 * @since 1.0.0
224 485 */
225 486 public function add_plugin_admin_menu() {
487 + // Top-level settings menu (capability: administrator).
226 488 add_menu_page(
227 489 esc_html__( 'MLS Import Settings', 'mlsimport'),
228 490 esc_html__( 'MLS Import Settings', 'mlsimport' ),
229 491 'administrator',
@@ -229,28 +491,222 @@
229 491 'administrator',
230 492 'mlsimport_plugin_options',
231 493 array( $this, 'display_plugin_setup_page' ),
232 494 MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png',
233 - 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
234 500 );
501 +
502 + // Import History submenu under the settings menu.
503 + add_submenu_page(
504 + 'mlsimport_plugin_options',
505 + esc_html__( 'Import History', 'mlsimport' ),
506 + esc_html__( 'Import History', 'mlsimport' ),
507 + 'administrator',
508 + 'mlsimport_history',
509 + array( $this, 'display_history_page' )
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 + }
235 537 }
236 538
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 + }
237 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;
238 568
569 + // The MLS-logo control opens the native WordPress media modal (wp.media).
570 + wp_enqueue_media();
239 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' );
240 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 + }
241 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 + }
242 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 + }
243 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 +
244 683 /**
684 + * Renders the Import History admin page.
245 685 *
686 + * @return void
687 + */
688 + public function display_history_page() {
689 + // Delegates the whole page to the history partial template.
690 + include_once plugin_dir_path( __FILE__ ) . 'partials/mlsimport-history.php';
691 + }
692 +
693 +
694 +
695 +
696 +
697 +
698 +
699 +
700 + /**
701 + * Add a "Settings" action link to this plugin's row on the Plugins page.
246 702 *
247 - *
248 - * Add settings action link to the plugins page.
249 - *
703 + * @param array $links Existing plugin action links.
704 + * @return array Links with the Settings link prepended.
250 705 * @since 1.0.0
251 706 */
252 707 public function add_action_links( $links ) {
708 + // Build the Settings link and place it before the default action links.
253 709 $settings_link = array(
254 710 '<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>',
255 711 );
256 712 return array_merge( $settings_link, $links );
@@ -263,15 +719,16 @@
263 719
264 720
265 721
266 722 /**
723 + * Render the main settings page for this plugin.
267 724 *
725 + * Loads the admin-display partial (whose filename is prefixed with the slug).
268 726 *
269 - * Render the settings page for this plugin.
270 - *
271 727 * @since 1.0.0
272 728 */
273 729 public function display_plugin_setup_page() {
730 + // Delegates the whole page to the slug-prefixed admin-display partial.
274 731 include_once 'partials/' . $this->plugin_name . '-admin-display.php';
275 732 }
276 733
277 734
@@ -279,16 +736,29 @@
279 736
280 737
281 738
282 739 /**
740 + * Sanitize/whitelist the main plugin options on save (register_setting callback).
283 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.
284 745 *
285 - * Validate plugin options fields
286 - *
746 + * @param array $input Raw submitted options.
747 + * @return array Whitelisted, escaped options.
287 748 * @since 1.0.0
288 749 */
289 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 + : '';
290 758
759 + // Whitelist of accepted option keys (value = label/help metadata, unused
760 + // beyond documentation here); anything not listed is dropped on save.
291 761 $valid = array();
292 762 $settings_list = array(
293 763 'auth_username' => array(
294 764 'name' => esc_html__( 'Api auth_username ', 'mlsimport' ),
@@ -318,9 +788,9 @@
318 788 'name' => esc_html__( 'title_format', 'mlsimport' ),
319 789 'details' => 'to be added',
320 790 ),
321 791 'mlsimport_username' => array(
322 - 'name' => esc_html__( 'MLSImport.com Username (not your email)', 'mlsimport' ),
792 + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ),
323 793 'details' => 'to be added',
324 794 ),
325 795 'mlsimport_password' => array(
326 796 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
@@ -339,18 +809,28 @@
339 809 'name' => esc_html__( 'MLSImport Tresle Client id', 'mlsimport' ),
340 810 'details' => 'to be added',
341 811 ),
342 812
343 - 'mlsimport_tresle_client_secret' => array(
344 - 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
345 - 'details' => 'to be added',
346 - ),
813 + 'mlsimport_tresle_client_secret' => array(
814 + 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
815 + 'details' => 'to be added',
816 + ),
347 817
348 - 'mlsimport_rapattoni_client_id' => array(
349 - 'name' => esc_html__( 'MLSImport Rapattoni Client id','mlsimport'),
350 - 'details' => 'to be added',
351 - ),
818 + 'mlsimport_connectmls_username' => array(
819 + 'name' => esc_html__( 'MLSImport ConnectMLS Username', 'mlsimport' ),
820 + 'details' => 'to be added',
821 + ),
352 822
823 + 'mlsimport_connectmls_password' => array(
824 + 'name' => esc_html__( 'MLSImport ConnectMLS Password', 'mlsimport' ),
825 + 'details' => 'to be added',
826 + ),
827 +
828 + 'mlsimport_rapattoni_client_id' => array(
829 + 'name' => esc_html__( 'MLSImport Rapattoni Client id','mlsimport'),
830 + 'details' => 'to be added',
831 + ),
832 +
353 833 'mlsimport_rapattoni_client_secret' => array(
354 834 'name' => esc_html__( 'MLSImport Rapattoni Secret', 'mlsimport' ),
355 835 'details' => 'to be added',
356 836 ),
@@ -382,9 +862,18 @@
382 862 'mlsimport_realtorca_client_secret' => array(
383 863 'name' => esc_html__( 'MLSImport Realtor.ca Secret', 'mlsimport' ),
384 864 'details' => 'to be added',
385 865 ),
866 + 'mlsimport_brightmls_client_id' => array(
867 + 'name' => esc_html__( 'MLSImport BrightMLS Client id', 'mlsimport' ),
868 + 'details' => 'to be added',
869 + ),
386 870
871 + 'mlsimport_brightmls_client_secret' => array(
872 + 'name' => esc_html__( 'MLSImport BrightMLS Secret', 'mlsimport' ),
873 + 'details' => 'to be added',
874 + ),
875 +
387 876 'mlsimport_theme_used' => array(
388 877 'name' => esc_html__( 'Your Wordpress Theme', 'mlsimport' ),
389 878 'details' => 'to be added',
390 879 ),
@@ -397,15 +886,37 @@
397 886 'details' => 'to be added',
398 887 ),
399 888 );
400 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.
401 894 foreach ( $settings_list as $key => $setting ) {
402 - $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 + }
403 902 }
404 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.
405 915 delete_option( 'mlsimport_connection_test' );
406 - delete_option( 'mlsimport_mls_metadata_populated' );
916 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
407 917
918 + // Reset cached encoding and drop cached token/schema transients.
408 919 update_option( 'mlsimport_encoding_array', '' );
409 920 delete_transient( 'mlsimport_token_request' );
410 921 delete_transient( 'mlsimport_schema' );
411 922 delete_transient( 'mlsimport_plugin_data_schema' );
@@ -418,49 +929,24 @@
418 929
419 930
420 931
421 932 /**
933 + * Validate the MLS-sync option group on save (register_setting callback).
422 934 *
935 + * Copies a fixed whitelist of sync/import parameter keys straight through.
423 936 *
424 - * Validate admin fields
425 - *
937 + * @param array $input Raw submitted sync settings.
938 + * @return array Whitelisted sync settings.
426 939 * @since 1.0.0
427 940 */
428 - public function validate_admin_fields_select( $input ) {
429 - $valid = array();
430 -
431 - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
432 - $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true );
433 -
434 - foreach ( $metadata_api_call as $key => $value ) {
435 - if ( isset( $input['mls-fields'][ $key ] ) ) {
436 - $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] );
437 - }
438 -
439 - if ( isset( $input['mls-fields-admin'][ $key ] ) ) {
440 - $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] );
441 - $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] );
442 - $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] );
443 - $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] );
444 - $valid['field_order'][ $key ] = esc_attr( $input['field_order'][ $key ] );
445 - }
446 - }
447 - //$valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] );
448 - return $valid;
449 - }
450 -
451 - /**
452 - *
453 - *
454 - * Validate Mls Sync fields
455 - *
456 - * @since 1.0.0
457 - */
458 941 public function validate_admin_mls_sync( $input ) {
459 942 $valid = array();
460 943
461 - $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',
462 947 'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' );
948 + // Pass each whitelisted key through unchanged.
463 949 foreach ( $field_import as $key ) {
464 950 $valid[ $key ] = $input[ $key ];
465 951 }
466 952
@@ -467,110 +953,40 @@
467 953 return $valid;
468 954 }
469 955
470 956
471 - /**
472 - *
473 - *
474 - * Validate Administrative options
475 - *
476 - * @since 1.0.0
477 - */
478 - public function validate_administrative_options( $input ) {
479 957
480 - $valid = array();
481 -
482 - $field_import = array( 'import' );
483 - foreach ( $field_import as $key ) {
484 - $valid[ $key ] = $input[ $key ];
485 - }
486 -
487 - return $valid;
488 - }
489 -
490 958 /**
491 - *
492 - *
493 - *
494 - * Validate Import Options fields
495 - *
496 - * @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.
497 961 */
498 - public function validate_admin_import_options( $input ) {
499 - $valid = array();
500 -
501 - $field_import = array( 'import_number' );
502 - foreach ( $field_import as $key ) {
503 - $valid[ $key ] = intval( $input[ $key ] );
504 - }
505 -
506 - if ( isset( $input['import'] ) && '' !== $input['import'] ) {
507 - $decode = json_decode( $input['import'] );
508 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
509 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
510 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
511 - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
512 - }
513 -
514 - return $valid;
515 - }
516 -
517 -
518 -
519 -
520 -
521 -
522 - /**
523 - *
524 - *
525 - * plugin options update
526 - */
527 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.
528 965 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
529 - register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) );
530 966 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
531 - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
532 - 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.
533 969 }
534 970
535 -
536 -
537 971 /**
538 - *
539 - *
540 - *
541 - *
542 - *
543 - *
544 - *
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.
545 974 */
546 - public function update_option_mlsimport_administrative_options() {
547 - $import = get_option( 'mlsimport_administrative_options' );
548 - if ( '' !== $import ) {
549 - $decode = json_decode( $import['import'], true );
550 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
551 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
552 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
553 - }
554 - }
555 -
556 - /**
557 - *
558 - *
559 - * plugin options update
560 - */
561 975 public function update_option_mlsimport_admin_fields_select() {
562 976
977 + // Delegate to the theme adapter to sync its custom fields.
563 978 $this->env_data->enviroment_custom_fields( $this->plugin_name );
564 979 }
565 980
566 981
567 982 /**
983 + * Register the "Hidden Fields" metabox on the theme's property post type.
568 984 *
569 - *
570 - * plugin options update
985 + * Only added when the theme adapter exposes get_property_post_type().
571 986 */
572 987 public function mlsimport_meta_options() {
988 + // Add the metabox to whatever post type the active theme uses for listings.
573 989 if ( method_exists( $this->env_data, 'get_property_post_type' ) ) {
574 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' );
575 991 }
576 992 }
@@ -575,67 +991,80 @@
575 991 }
576 992 }
577 993
578 994 /**
995 + * Render the "Hidden Fields" metabox for a single property post.
579 996 *
580 - *
581 - * 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.
582 1000 */
583 1001 public function mlsimport_hidden_fields() {
584 1002 global $post;
585 1003
586 - $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();
587 1006
1007 + // Which Import Task created / last updated this property, and its RESO key.
588 1008 $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true );
589 1009 $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true );
590 - $listing_key = get_post_meta( $post->ID, 'ListingKey', true );
591 - $mlsImportItemStatusDelete = get_post_meta($post->ID, 'mlsImportItemStatusDelete', true);
1010 + $listing_key = get_post_meta( $post->ID, '_mlsimport_listing_key', true );
592 1011
1012 + // Get the import task ID to retrieve protected statuses
1013 + // (prefer the inserting task, fall back to the updating task).
1014 + $import_task_id = !empty( $MLSimport_item_inserted ) ? $MLSimport_item_inserted : ( !empty( $MLSimport_item_updated ) ? $MLSimport_item_updated : null );
1015 + $mlsImportItemStatusProtect = $import_task_id ? get_post_meta( $import_task_id, 'mlsimport_item_standardstatusprotect', true ) : null;
593 1016
594 -
595 1017 // Check if the ListingKey exists
596 1018 if ( !empty( $listing_key ) ) {
597 - echo 'ListingKey: ' . $listing_key . '<br>';
1019 + echo 'ListingKey: ' . esc_html( $listing_key ) . '<br>';
598 1020 }
599 1021
600 1022 // Check if MLSimport_item_inserted exists
601 1023 if ( !empty( $MLSimport_item_inserted ) ) {
602 - echo 'Added via MLS item id: ' . $MLSimport_item_inserted . ' - ' . get_the_title( $MLSimport_item_inserted ) . '<br>';
1024 + echo 'Added via MLS item id: ' . esc_html( $MLSimport_item_inserted ) . ' - ' . esc_html( get_the_title( $MLSimport_item_inserted ) ) . '<br>';
603 1025 }
604 1026
605 1027 // Check if MLSimport_item_updated exists
606 1028 if ( !empty( $MLSimport_item_updated ) ) {
607 - echo 'Updated via MLS item id: ' . $MLSimport_item_updated . ' - ' . get_the_title( $MLSimport_item_updated ) . '<br>';
1029 + echo 'Updated via MLS item id: ' . esc_html( $MLSimport_item_updated ) . ' - ' . esc_html( get_the_title( $MLSimport_item_updated ) ) . '<br>';
608 1030 }
609 1031
610 -
611 - if(!empty($mlsImportItemStatusDelete)) {
612 - if(is_array($mlsImportItemStatusDelete)) {
613 -
614 - echo 'Do not delete if status: ' . implode(',' ,$mlsImportItemStatusDelete) . '<br>';
1032 + // Show any protected statuses (array or scalar) configured on the task.
1033 + if(!empty($mlsImportItemStatusProtect)) {
1034 + if(is_array($mlsImportItemStatusProtect)) {
1035 + echo 'Protected statuses: ' . esc_html( implode(', ', $mlsImportItemStatusProtect) ) . '<br>';
615 1036 } else {
616 -
617 - echo 'Do not delete if status: ' . esc_html($mlsImportItemStatusDelete) . '<br>';
618 -
1037 + echo 'Protected statuses: ' . esc_html($mlsImportItemStatusProtect) . '<br>';
619 1038 }
620 -
621 1039 }
622 1040
623 - 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).
624 1044 if ( 1 === intval($options['mls-fields-admin'][ $key ] ) ) {
1045 + // Prefer a custom label if one was set for this field.
1046 + $display_label = $key;
625 1047 if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) {
626 - $key = $options['mls-fields-label'][ $key ];
1048 + $display_label = $options['mls-fields-label'][ $key ];
627 1049 }
628 1050
629 - if ( 'ListingKey' !== $key ) {
630 - $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 );
631 1057 } else {
632 - $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 );
633 1062 }
634 1063 ?>
635 1064
636 - <strong><?php echo esc_html($key);?>:</strong>
637 - <?php echo esc_html( get_post_meta( $post->ID, $meta_key, true ) ); ?> </br>
1065 + <strong><?php echo esc_html($display_label);?>:</strong>
1066 + <?php echo esc_html( $field_value ); ?> </br>
638 1067 <?php
639 1068 }
640 1069 }
641 1070 ?>
@@ -640,9 +1069,10 @@
640 1069 }
641 1070 ?>
642 1071
643 1072 <h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2>
644 - <?php
1073 + <?php
1074 + // Property change history (only populated when history logging is enabled).
645 1075 $meta = get_post_meta( $post->ID, 'mlsimport_property_history', true );
646 1076 if ( '' === trim( $meta ) ) { ?>
647 1077 <strong>Property history is blank - you can enable it in Settings/ Tools page </strong>
648 1078 <?php
@@ -654,14 +1084,17 @@
654 1084
655 1085
656 1086
657 1087 /**
658 - * 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.
659 1090 */
660 1091 function mlsimport_delete_cache() {
661 1092
1093 + // CSRF: Tools-page nonce.
662 1094 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
663 1095
1096 + // Drop every cached token/metadata/schema transient.
664 1097 delete_transient( 'mlsimport_token_request' );
665 1098 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
666 1099 delete_transient( 'mls_import_meta_enums' );
667 1100 delete_transient( 'mls_import_meta' );
@@ -668,114 +1101,153 @@
668 1101 delete_transient( 'mlsimport_plugin_data_schema' );
669 1102 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
670 1103 delete_transient( 'mlsimport_saas_token' );
671 1104
672 - 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' );
673 1107
674 1108 die( 'deleted' );
675 1109 }
676 1110
677 1111 /**
678 - * 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).
679 1114 */
680 1115 function mlsimport_clear_fields_data() {
681 1116
1117 + // CSRF: Tools-page nonce.
682 1118 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
683 1119
684 - delete_option( 'mlsimport_mls_metadata_populated' );
685 - 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' );
686 1125
687 1126 die( 'deleted' );
688 1127 }
689 1128
690 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.
691 1132 *
1133 + * @return void Emits a JSON success payload of {slug,name,count} rows.
1134 + */
1135 + function mlsimport_get_taxonomy_terms() {
1136 + // CSRF + capability.
1137 + check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1138 + if ( ! current_user_can( 'administrator' ) ) {
1139 + wp_send_json_error( 'Unauthorized' );
1140 + }
1141 +
1142 + // Reject unknown taxonomies.
1143 + $taxonomy = sanitize_text_field( wp_unslash( $_POST['taxonomy'] ) );
1144 + if ( ! taxonomy_exists( $taxonomy ) ) {
1145 + wp_send_json_error( 'Invalid taxonomy' );
1146 + }
1147 +
1148 + // Fetch all terms (including empties) and flatten to slug/name/count.
1149 + $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'name' ) );
1150 + $result = array();
1151 + if ( ! is_wp_error( $terms ) ) {
1152 + foreach ( $terms as $term ) {
1153 + $result[] = array(
1154 + 'slug' => $term->slug,
1155 + 'name' => $term->name,
1156 + 'count' => $term->count,
1157 + );
1158 + }
1159 + }
1160 + wp_send_json_success( $result );
1161 + }
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.
692 1167 *
693 - *
694 - *
695 - * delete properties
1168 + * @return void Emits a JSON success payload {deleted,remaining,total,done}.
696 1169 */
697 1170 function mlsimport_delete_properties() {
698 - $error = false;
699 1171 global $mlsimport;
700 1172
1173 + // CSRF + capability.
701 1174 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
702 1175
1176 + if ( ! current_user_can( 'administrator' ) ) {
1177 + wp_send_json_error( 'Unauthorized' );
1178 + }
703 1179
704 - if ( current_user_can( 'administrator' ) ) :
705 - $mlsimport_delete_category = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) ) ;
706 - $mlsimport_delete_category_term = sanitize_title( sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category_term']) ) );
707 - $mlsimport_delete_timeout = intval( $_POST['mlsimport_delete_timeout'] );
1180 + // Selected taxonomy and its chosen term slugs.
1181 + $taxonomy = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) );
1182 + $terms = array();
708 1183
709 - if ( '' === $mlsimport_delete_category ) {
710 - $error_message = esc_html__('Category cannot be blank','mlsimport');
711 - $error = true;
1184 + // Collect and sanitize the selected term slugs.
1185 + if ( isset( $_POST['mlsimport_delete_category_term'] ) && is_array( $_POST['mlsimport_delete_category_term'] ) ) {
1186 + foreach ( $_POST['mlsimport_delete_category_term'] as $term ) {
1187 + $terms[] = sanitize_text_field( wp_unslash( $term ) );
712 1188 }
1189 + }
713 1190
714 - if ( '' === $mlsimport_delete_category_term ) {
715 - $error_message = esc_html__('Category Term cannot be blank','mlsimport');
716 - $error = true;
717 - }
1191 + // Require a taxonomy.
1192 + if ( '' === $taxonomy ) {
1193 + wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) );
1194 + }
718 1195
719 - $category = get_term_by( 'slug', $mlsimport_delete_category_term, $mlsimport_delete_category );
1196 + // Require at least one term.
1197 + if ( empty( $terms ) ) {
1198 + wp_send_json_error( esc_html__( 'Please select at least one term', 'mlsimport' ) );
1199 + }
720 1200
721 - if ( $error ) {
722 - print wp_json_encode(
723 - array(
724 - 'message' => esc_html($error_message),
725 - )
726 - );
727 - } else {
728 - $mlsimport_delete_category_term_array = array();
1201 + // Query one page of property IDs matching the term selection.
1202 + $post_type = $mlsimport->admin->env_data->get_property_post_type();
729 1203
730 - $mlsimport_delete_category_term_array[] = $mlsimport_delete_category_term;
731 - $tax_array = array(
732 - 'taxonomy' => $mlsimport_delete_category,
1204 + $args = array(
1205 + 'post_type' => $post_type,
1206 + 'post_status' => 'any',
1207 + 'posts_per_page' => 20,
1208 + 'tax_query' => array(
1209 + array(
1210 + 'taxonomy' => $taxonomy,
733 1211 'field' => 'slug',
734 - 'terms' => $mlsimport_delete_category_term_array,
735 - );
1212 + 'terms' => $terms,
1213 + ),
1214 + ),
1215 + 'fields' => 'ids',
1216 + );
736 1217
737 - $args = array(
738 - 'post_type' => array( 'estate_property', 'property' ),
739 - 'post_status' => 'any',
740 - 'paged' => 1,
741 - 'posts_per_page' => -1,
742 - 'tax_query' => array(
743 - $tax_array,
744 - ),
745 - 'fields' => 'ids',
746 - );
1218 + $prop_selection = new WP_Query( $args );
1219 + $deleted = 0;
747 1220
748 - $prop_selection = new WP_Query( $args );
1221 + // Delete each property in this batch via the theme importer's SQL delete.
1222 + foreach ( $prop_selection->posts as $delete_id ) {
1223 + $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $delete_id, ' delete from tools ' );
1224 + ++$deleted;
1225 + }
749 1226
750 - foreach ( $prop_selection->posts as $key => $delete_get_id ) {
751 - if ( 0 !== $mlsimport_delete_timeout ) {
752 - set_timeout( $mlsimport_delete_timeout );
753 - }
1227 + // Compute how many still match after this batch; done when none remain.
1228 + $remaining = $prop_selection->found_posts - $deleted;
1229 + $done = ( $remaining <= 0 );
754 1230
755 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql( $delete_get_id, ' delete from tools ' );
1231 + // Update term counts only when all deletions are complete
1232 + if ( $done ) {
1233 + // Recount every taxonomy on the property post type in one pass.
1234 + $all_taxonomies = get_object_taxonomies( $post_type );
1235 + foreach ( $all_taxonomies as $tax_name ) {
1236 + $all_terms = get_terms( array( 'taxonomy' => $tax_name, 'hide_empty' => false, 'fields' => 'ids' ) );
1237 + if ( ! is_wp_error( $all_terms ) && ! empty( $all_terms ) ) {
1238 + wp_update_term_count_now( $all_terms, $tax_name );
756 1239 }
1240 + }
1241 + }
757 1242
758 -
759 -
760 -
761 -
762 -
763 -
764 - wp_update_term_count_now( array( $category->term_id ), $mlsimport_delete_category );
765 -
766 - print wp_json_encode(
767 - array(
768 - '$category' => $category->term_id,
769 - 'arguments' => $args,
770 - 'posts' => $prop_selection->posts,
771 - 'found' => $prop_selection->found_posts,
772 - 'message' => 'Done...',
773 - )
774 - );
775 - }
776 - endif;
777 - die();
1243 + // Report progress back to the client loop.
1244 + wp_send_json_success( array(
1245 + 'deleted' => $deleted,
1246 + 'remaining' => max( 0, $remaining ),
1247 + 'total' => $prop_selection->found_posts,
1248 + 'done' => $done,
1249 + ) );
778 1250 }
779 1251
780 1252
781 1253
@@ -784,11 +1256,48 @@
784 1256
785 1257
786 1258
787 1259 /**
1260 + * Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes.
788 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.
789 1294 *
790 - * 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.
791 1300 */
792 1301 public function mlsimport_saas_setting_up() {
793 1302 // Do not output warnings during AJAX requests
794 1303 if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) ||
@@ -794,197 +1303,322 @@
794 1303 if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) ||
795 1304 ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
796 1305 return;
797 1306 }
798 -
799 - $is_onboarding = isset( $_GET['page'] ) && 'mlsimport-onboarding' === $_GET['page'];
800 - if ( ! $is_onboarding && intval( WP_MEMORY_LIMIT ) < 256 ) :
801 - if (intval(WP_MEMORY_LIMIT) < 256){ ?>
802 - <div class="mlsimport_warning long_warning">
803 - <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>
804 - </div>
805 - <?php
806 - }
807 -
808 - $max_time = ini_get('max_execution_time');
809 - if ($max_time < 600 && 0 !== $max_time){
810 - ?>
811 - <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>
812 -
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>
813 1343 <?php
814 1344 }
815 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>
816 1363
817 - endif; // emd pn boardingin check
818 -
1364 + <?php
1365 + }
819 1366 }
820 1367
821 - /**
822 - * Check if token validates with MLS
823 - *
824 - * @since 4.0.1
825 - * returns token fron mlsimport
826 - */
1368 + /**
1369 + * Test the configured MLS credentials against the SaaS API.
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 + *
1375 + * @since 4.0.1
1376 + * @return array|void The API response, or void on an early return.
1377 + */
827 1378 public function mlsimport_saas_check_mls_connection() {
828 1379
829 - $values = array();
830 - $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 );
831 1389
832 - $mls_id = '';
833 - if ( isset( $options['mlsimport_mls_name'] ) ) {
834 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
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 + );
835 1398 }
836 1399
837 - $mls_token = '';
838 - if ( isset( $options['mlsimport_mls_name'] ) ) {
839 - $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
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 + );
840 1408 }
1409 + $values = $payload_result['payload'];
841 1410
842 - $mlsimport_tresle_client_id = '';
843 - if ( isset( $options['mlsimport_tresle_client_id'] ) ) {
844 - $mlsimport_tresle_client_id = sanitize_text_field( trim( $options['mlsimport_tresle_client_id'] ) );
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 );
845 1422 }
846 1423
847 - $mlsimport_tresle_client_secret = '';
848 - if ( isset( $options['mlsimport_tresle_client_secret'] ) ) {
849 - $mlsimport_tresle_client_secret = sanitize_text_field( trim( $options['mlsimport_tresle_client_secret'] ) );
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 );
850 1430 }
851 -
852 - // rapattoni data
853 - $mlsimport_rapattoni_client_id = '';
854 - if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) {
855 - $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_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 );
856 1435 }
857 - $mlsimport_rapattoni_client_secret = '';
858 - if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) {
859 - $mlsimport_rapattoni_client_secret = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_secret'] ) );
860 - }
861 1436
862 - $mlsimport_rapattoni_username = '';
863 - if ( isset( $options['mlsimport_rapattoni_username'] ) ) {
864 - $mlsimport_rapattoni_username = sanitize_text_field( trim( $options['mlsimport_rapattoni_username'] ) );
865 - }
866 1437
867 - $mlsimport_rapattoni_password = '';
868 - if ( isset( $options['mlsimport_rapattoni_password'] ) ) {
869 - $mlsimport_rapattoni_password = sanitize_text_field( trim( $options['mlsimport_rapattoni_password'] ) );
870 - }
871 1438
872 - // paragon data
873 - $mlsimport_paragon_client_id = '';
874 - if ( isset( $options['mlsimport_paragon_client_id'] ) ) {
875 - $mlsimport_paragon_client_id = sanitize_text_field( trim( $options['mlsimport_paragon_client_id'] ) );
876 - }
877 - $mlsimport_paragon_client_secret = '';
878 - if ( isset( $options['mlsimport_paragon_client_secret'] ) ) {
879 - $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) );
880 - }
881 1439
882 - // realtor.ca data
883 - $mlsimport_realtorca_client_id = '';
884 - if ( isset( $options['mlsimport_realtorca_client_id'] ) ) {
885 - $mlsimport_realtorca_client_id = sanitize_text_field( trim( $options['mlsimport_realtorca_client_id'] ) );
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() );
1447 + } else {
1448 + delete_option( 'mlsimport_connection_test' );
1449 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
886 1450 }
887 - $mlsimport_realtorca_client_secret = '';
888 - if ( isset( $options['mlsimport_realtorca_client_secret'] ) ) {
889 - $mlsimport_realtorca_client_secret = sanitize_text_field( trim( $options['mlsimport_realtorca_client_secret'] ) );
890 - }
891 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 );
892 1456
1457 + return $answer;
1458 + }
893 1459
1460 + /**
1461 + * AJAX handler for the plugin-deactivation exit survey.
1462 + *
1463 + * Thin wrapper: it verifies the nonce and capability, sanitizes input,
1464 + * delegates the real work to mlsimport_exit_survey_record(), and POSTs
1465 + * the result to the SaaS API. The POST is fire-and-forget — a failed or
1466 + * not-yet-deployed endpoint must never stop the admin from deactivating.
1467 + */
1468 + public function mlsimport_exit_survey_submit() {
1469 + check_ajax_referer( 'mlsimport_exit_survey', 'security' );
1470 + if ( ! current_user_can( 'administrator' ) ) {
1471 + wp_send_json_error( 'Unauthorized' );
1472 + }
894 1473
1474 + $input = array(
1475 + 'reason' => sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) ),
1476 + 'details' => sanitize_textarea_field( wp_unslash( $_POST['details'] ?? '' ) ),
1477 + );
895 1478
1479 + // Only record a recognized reason; an unknown value is dropped
1480 + // silently rather than blocking the user or storing junk.
1481 + if ( $this->mlsimport_exit_survey_is_valid_reason( $input['reason'] ) ) {
1482 + $payload = $this->mlsimport_exit_survey_record( $input );
1483 + try {
1484 + ThemeImport::globalApiRequestSaas( 'user-activity', $payload, 'POST' );
1485 + } catch ( \Throwable $e ) {
1486 + // Swallow: deactivation proceeds regardless of transport failure.
1487 + }
1488 + }
896 1489
1490 + wp_send_json_success();
1491 + }
897 1492
898 - if ( trim( $mls_token ) === '' ) {
899 - if ( intval( $mls_id ) > 900 && intval( $mls_id ) < 3000 ) {
900 - if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) {
901 - return;
902 - }
903 - } elseif ( intval( $mls_id ) >= 5000 && intval( $mls_id ) < 6000 ) {
904 - if (
905 - trim( $mlsimport_rapattoni_client_id ) === '' ||
906 - trim( $mlsimport_rapattoni_client_secret ) === '' ||
907 - trim( $mlsimport_rapattoni_username ) === '' ||
908 - trim( $mlsimport_rapattoni_password ) === ''
909 - ) {
910 - return;
911 - }
912 - } elseif ( intval( $mls_id ) >= 6000 && intval( $mls_id ) < 7000 ) {
913 - if (
914 - trim( $mlsimport_paragon_client_id ) === '' ||
915 - trim( $mlsimport_paragon_client_secret ) === ''
916 - ) {
917 - return;
918 - }
919 - } elseif ( intval( $mls_id ) >= 7000 ) {
920 - if (
921 - trim( $mlsimport_realtorca_client_id ) === '' ||
922 - trim( $mlsimport_realtorca_client_secret ) === ''
923 - ) {
924 - return;
925 - }
926 - }
927 - }
1493 + /**
1494 + * Whether a submitted exit-survey reason is one of the known options.
1495 + *
1496 + * Pure predicate — no WordPress functions, no translation.
1497 + */
1498 + private function mlsimport_exit_survey_is_valid_reason( string $reason ): bool {
1499 + return in_array( $reason, $this->mlsimport_exit_survey_reasons(), true );
1500 + }
928 1501
929 - $values['mls_token'] = $mls_token;
930 - $values['mls_id'] = $mls_id;
931 - $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id;
932 - $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret;
1502 + /**
1503 + * Whether the current admin request is the Import Task (mlsimport_item)
1504 + * post edit screen. Used to scope the Select2 asset enqueue so the
1505 + * searchable-select library does not load across all of wp-admin.
1506 + *
1507 + * @param string $hook_suffix Current admin page hook suffix.
1508 + * @param string $post_type Post type of the screen being rendered.
1509 + */
1510 + private function mlsimport_is_import_task_edit_screen( string $hook_suffix, string $post_type ): bool {
1511 + return 'mlsimport_item' === $post_type
1512 + && in_array( $hook_suffix, array( 'post.php', 'post-new.php' ), true );
1513 + }
933 1514
934 - $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id;
935 - $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret;
936 - $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username;
937 - $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password;
1515 + /**
1516 + * Extra CSS class for an Import Task select field. City and County lists
1517 + * can hold 300+ entries, so they are upgraded to a searchable multi-select
1518 + * (Select2) via this marker class; every other field keeps the plain select.
1519 + *
1520 + * @param string $field_key Field key from the $field_import definition.
1521 + * @return string Leading-space class string, or '' when not searchable.
1522 + */
1523 + private function mlsimport_searchable_select_class( string $field_key ): string {
1524 + return in_array( $field_key, array( 'City', 'CountyOrParish' ), true )
1525 + ? ' mlsimport-searchable-select'
1526 + : '';
1527 + }
938 1528
939 - $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id;
940 - $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret;
1529 + /**
1530 + * Exit-survey testable core: resolve identity and count, build payload.
1531 + *
1532 + * Calls no dying functions — the AJAX wrapper handles nonce/capability
1533 + * and wp_send_json_*. Always returns the payload to transmit.
1534 + *
1535 + * @param array $input Sanitized survey input (reason, details).
1536 + */
1537 + private function mlsimport_exit_survey_record( array $input ): array {
1538 + $opts = get_option( 'mlsimport_admin_options', array() );
1539 + if ( empty( $opts['mlsimport_install_uuid'] ) ) {
1540 + $opts['mlsimport_install_uuid'] = wp_generate_uuid4();
1541 + update_option( 'mlsimport_admin_options', $opts );
1542 + }
941 1543
942 -
943 - $values['mlsimport_realtorca_client_id'] = $mlsimport_realtorca_client_id;
944 - $values['mlsimport_realtorca_client_secret'] = $mlsimport_realtorca_client_secret;
1544 + $count = (int) get_option( 'mlsimport_deactivation_count', 0 ) + 1;
1545 + update_option( 'mlsimport_deactivation_count', $count );
945 1546
1547 + $reason = (string) ( $input['reason'] ?? '' );
1548 + $options = $this->get_exit_survey_options();
946 1549
1550 + return array(
1551 + 'event_type' => 'exit_survey',
1552 + 'reason' => $reason,
1553 + 'reason_label' => $options[ $reason ] ?? '',
1554 + 'details' => (string) ( $input['details'] ?? '' ),
1555 + 'account' => (string) ( $opts['mlsimport_username'] ?? '' ),
1556 + 'install_uuid' => $opts['mlsimport_install_uuid'],
1557 + 'deactivation_count' => $count,
1558 + 'environment' => wp_get_environment_type(),
1559 + 'site_url' => home_url(),
1560 + 'admin_email' => (string) get_option( 'admin_email' ),
1561 + 'timestamp' => time(),
1562 + );
1563 + }
947 1564
1565 + /**
1566 + * The known exit-survey reason keys — the single source of truth.
1567 + *
1568 + * Pure: keys only, no labels, no translation. The label map
1569 + * (get_exit_survey_options) builds on top of this for the modal.
1570 + *
1571 + * @return string[]
1572 + */
1573 + private function mlsimport_exit_survey_reasons(): array {
1574 + return array(
1575 + 'built_website',
1576 + 'no_leads',
1577 + 'technical_issues',
1578 + 'too_expensive',
1579 + 'switched_tool',
1580 + 'other',
1581 + );
1582 + }
948 1583
1584 + /**
1585 + * Exit-survey reason key => display label, for the modal and payload.
1586 + *
1587 + * Uses translation, so it is not exercised by the pure unit suite —
1588 + * is_valid_reason() relies on mlsimport_exit_survey_reasons() instead.
1589 + *
1590 + * @return array<string,string>
1591 + */
1592 + private function get_exit_survey_options(): array {
1593 + return array(
1594 + 'built_website' => esc_html__( "Built the website, don't need ongoing sync", 'mlsimport' ),
1595 + 'no_leads' => esc_html__( 'Not getting leads from my site', 'mlsimport' ),
1596 + 'technical_issues' => esc_html__( "Technical issues I couldn't fix", 'mlsimport' ),
1597 + 'too_expensive' => esc_html__( 'Too expensive', 'mlsimport' ),
1598 + 'switched_tool' => esc_html__( 'Switched to another tool', 'mlsimport' ),
1599 + 'other' => esc_html__( 'Other', 'mlsimport' ),
1600 + );
1601 + }
949 1602
950 -
951 1603
952 - $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
953 1604
954 1605
955 1606
956 1607
957 - if ( isset( $answer['success'] ) && true === $answer['success'] ) {
958 - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
959 - update_option( 'mlsimport_connection_test', 'yes' );
960 - } else {
961 - delete_option( 'mlsimport_connection_test' );
962 - delete_option( 'mlsimport_mls_metadata_populated' );
963 - }
964 - } else {
965 - delete_option( 'mlsimport_connection_test' );
966 - delete_option( 'mlsimport_mls_metadata_populated' );
967 - }
968 -
969 - return $answer;
970 - }
971 -
972 -
973 -
974 -
975 -
976 -
977 1608 /**
978 - * 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.
979 1611 *
980 1612 * @since 4.0.1
981 - * returns token fron mlsimport
1613 + * @return string|array The token string, or the raw answer/'' on failure.
982 1614 */
983 1615 public function mlsimport_saas_get_mls_api_token_from_transient() {
984 1616
1617 + // Prefer the cached token.
985 1618 $token = get_transient( 'mlsimport_saas_token' );
986 1619
1620 + // Cache miss/empty: request a new token and cache it on success.
987 1621 if ( false === $token || '' === $token ) {
988 1622 $token_json_answer = $this->mlsimport_saas_get_mls_api_token();
989 1623
990 1624 if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) {
@@ -989,8 +1623,9 @@
989 1623
990 1624 if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) {
991 1625 $token = $token_json_answer['token'];
992 1626
1627 + // 3500s < the token's 1h life, leaving headroom before expiry.
993 1628 set_transient( 'mlsimport_saas_token', $token, 3500 );
994 1629 }
995 1630 }
996 1631
@@ -998,12 +1633,16 @@
998 1633 }
999 1634
1000 1635
1001 1636 /**
1002 - * call for token
1637 + * Request a fresh SaaS API token using the stored account username/password.
1003 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 + *
1004 1643 * @since 4.0.1
1005 - * returns token fron mlsimport
1644 + * @return array|string The 'token' API response, or '' when unconfigured.
1006 1645 */
1007 1646 protected function mlsimport_saas_get_mls_api_token() {
1008 1647 $values = array();
1009 1648 $options = get_option( $this->plugin_name . '_admin_options' );
@@ -1018,9 +1657,11 @@
1018 1657 }
1019 1658
1020 1659 $password = '';
1021 1660 if ( isset( $options['mlsimport_password'] ) ) {
1022 - $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'] );
1023 1664 }
1024 1665 $mls_name = '';
1025 1666 if ( isset( $options['mlsimport_mls_name'] ) ) {
1026 1667 $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
@@ -1030,8 +1671,9 @@
1030 1671 if ( isset( $options['mlsimport_mls_token'] ) ) {
1031 1672 $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
1032 1673 }
1033 1674
1675 + // Provider switch detected: purge all cross-provider cached state.
1034 1676 if ( $prev_mls !== '' && $prev_mls !== $mls_name ) {
1035 1677 delete_transient( 'mlsimport_token_request' );
1036 1678 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
1037 1679 delete_transient( 'mls_import_meta_enums' );
@@ -1039,29 +1681,38 @@
1039 1681 delete_transient( 'mlsimport_plugin_data_schema' );
1040 1682 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1041 1683 delete_transient( 'mlsimport_saas_token' );
1042 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.
1043 1689 delete_option( 'mlsimport_mls_metadata_populated' );
1044 - //error_log('deleting '.$prev_mls.' - '.$mls_name);
1045 1690
1046 1691 delete_option( 'mlsimport_admin_fields_select' );
1047 1692 }
1048 1693
1694 + // Remember the current MLS so the next call can detect a switch.
1049 1695 update_option( 'mlsimport_prev_mls_name', $mls_name );
1050 1696
1051 1697
1052 1698
1699 + // Credentials to exchange for a token.
1053 1700 $values['username'] = $username;
1054 1701 $values['password'] = $password;
1055 1702
1703 + // No account credentials -> nothing to request.
1056 1704 if ( '' === $username || '' === $password ) {
1057 1705 return '';
1058 1706 }
1059 1707
1708 + // POST to the SaaS 'token' endpoint and return its response.
1060 1709 $theme_Start = new ThemeImport();
1061 1710 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
1062 1711
1063 -
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 );
1064 1715
1065 1716 return $answer;
1066 1717 }
1067 1718
@@ -1070,13 +1721,14 @@
1070 1721
1071 1722
1072 1723
1073 1724 /**
1074 - * save meta options
1725 + * Register the "Set Import data" metabox on the mlsimport_item post type.
1075 1726 *
1076 1727 * @since 3.0.1
1077 1728 */
1078 1729 public function mlsimport_item_product_metaboxes() {
1730 + // The metabox renders the import-parameter form for an Import Task.
1079 1731 add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' );
1080 1732 }
1081 1733
1082 1734
@@ -1081,29 +1733,61 @@
1081 1733
1082 1734
1083 1735
1084 1736 /**
1737 + * Save the Import Task metabox fields to post meta (save_post callback).
1085 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.
1086 1743 *
1087 - *
1088 - * save meta options
1089 - *
1744 + * @param int $post_id Post being saved.
1745 + * @param WP_Post $post Post object.
1090 1746 * @since 3.0.1
1091 1747 */
1092 1748 public function mlsimport_item_product_save_metaboxes( $post_id, $post ) {
1093 1749
1750 + // Guard against non-post contexts.
1094 1751 if ( ! is_object( $post ) || ! isset( $post->post_type ) ) {
1095 1752 return;
1096 1753 }
1097 1754
1755 + // Only handle Import Task posts.
1098 1756 if ( 'mlsimport_item' !== $post->post_type ) {
1099 1757 return;
1100 1758 }
1101 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.
1102 1785 $allowed_keys = array(
1103 1786 'mlsimport_item_how_many',
1104 1787 'mlsimport_item_title_format',
1105 1788 'mlsimport_item_agent',
1789 + 'mlsimport_item_use_mls_agent',
1106 1790 'mlsimport_item_property_status',
1107 1791 'mlsimport_item_property_user',
1108 1792 'mlsimport_item_min_price',
1109 1793 'mlsimport_item_max_price',
@@ -1119,10 +1803,10 @@
1119 1803 'mlsimport_item_propertytype_check',
1120 1804 'mlsimport_item_propertytype',
1121 1805 'mlsimport_item_standardstatus_check',
1122 1806 'mlsimport_item_standardstatus',
1123 - 'mlsimport_item_standardstatusdelete_check',
1124 - 'mlsimport_item_standardstatusdelete',
1807 + 'mlsimport_item_standardstatusprotect_check',
1808 + 'mlsimport_item_standardstatusprotect',
1125 1809
1126 1810 'mlsimport_item_internetentirelistingdisplayyn',
1127 1811 'mlsimport_item_internetaddressdisplayyn',
1128 1812 'mlsimport_item_stat_cron',
@@ -1127,12 +1811,14 @@
1127 1811 'mlsimport_item_internetaddressdisplayyn',
1128 1812 'mlsimport_item_stat_cron',
1129 1813 'mlsimport_item_listagentkey',
1130 1814 'mlsimport_item_listagentmlsid',
1815 + 'mlsimport_item_buyeragentmlsid',
1131 1816 'mlsimport_item_listofficekey',
1132 1817 'mlsimport_item_postalcode',
1133 1818 'mlsimport_item_listofficemlsid',
1134 1819 'mlsimport_item_listingid',
1820 + 'mlsimport_item_listingkey',
1135 1821 'mlsimport_item_extracity',
1136 1822 'mlsimport_item_extracounty',
1137 1823 'mlsimport_item_exclude_listofficemlsid',
1138 1824 'mlsimport_item_exclude_listofficekey',
@@ -1137,8 +1823,9 @@
1137 1823 'mlsimport_item_exclude_listofficemlsid',
1138 1824 'mlsimport_item_exclude_listofficekey',
1139 1825 'mlsimport_item_exclude_listagentmlsid',
1140 1826 'mlsimport_item_exclude_listagentkey',
1827 + 'mlsimport_item_customparameters',
1141 1828 'mlsimport_item_mlsareamajor',
1142 1829 'mlsimport_item_subdivisionname',
1143 1830 );
1144 1831
@@ -1144,8 +1831,9 @@
1144 1831
1145 1832
1146 1833
1147 1834
1835 + // Store each posted key (recursively sanitized; key sanitized too).
1148 1836 foreach ( $allowed_keys as $key => $key_value ) {
1149 1837 if( isset($_POST[$key_value]) ){
1150 1838 $postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ;
1151 1839 update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta );
@@ -1152,10 +1840,13 @@
1152 1840 }
1153 1841
1154 1842 }
1155 1843
1844 + // Keys that must be reset to '' when omitted from the POST (cleared).
1156 1845 $blank_keys = array(
1846 + 'mlsimport_item_use_mls_agent',
1157 1847 'mlsimport_item_standardstatus',
1848 + 'mlsimport_item_standardstatusprotect',
1158 1849 'mlsimport_item_city',
1159 1850 'mlsimport_item_countyorparish',
1160 1851 'mlsimport_item_propertysubtype',
1161 1852 'mlsimport_item_propertytype',
@@ -1160,13 +1851,16 @@
1160 1851 'mlsimport_item_propertysubtype',
1161 1852 'mlsimport_item_propertytype',
1162 1853 'mlsimport_item_standardstatus',
1163 1854 'mlsimport_item_listingid',
1855 + 'mlsimport_item_listingkey',
1856 + 'mlsimport_item_customparameters',
1164 1857 'mlsimport_item_mlsareamajor',
1165 1858 'mlsimport_item_subdivisionname',
1166 1859
1167 1860 );
1168 1861
1862 + // Reset any whitelisted-blank key that was not submitted this save.
1169 1863 foreach ( $blank_keys as $key ) {
1170 1864 if ( ! isset( $_POST[ $key ] ) ) {
1171 1865 update_post_meta( $post_id, $key, '' );
1172 1866 }
@@ -1176,53 +1870,104 @@
1176 1870 }
1177 1871
1178 1872
1179 1873 /**
1180 - * Display Meta Options
1874 + * Render the Import Task metabox content.
1181 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 + *
1182 1886 * @param WP_Post $post The post object.
1183 1887 */
1184 - public function mlsimport_saas_display_meta_options($post) {
1185 - wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename');
1186 - global $mlsimport;
1187 -
1188 - $postId = $post->ID;
1189 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
1190 -
1191 - $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1192 - $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1193 - $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1194 - $status = get_option('mlsimport_force_stop_' . $postId);
1195 - $fieldImport = $this->mlsimport_saas_return_mls_fields();
1196 - $options = get_option('mlsimport_admin_options');
1197 - $mlsimportMlsId = isset($options['mlsimport_mls_name']) && $options['mlsimport_mls_name'] !== ''
1198 - ? intval($options['mlsimport_mls_name'])
1199 - : 0;
1200 -
1201 - $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1202 -//print_r($mlsRequest);
1888 + public function mlsimport_saas_display_meta_options($post) {
1889 + // Nonce for the metabox save.
1890 + wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename');
1891 + global $mlsimport;
1203 1892
1204 - if (isset($mlsRequest['success']) && !$mlsRequest['success']) {
1205 - echo '<div class="mlsimport_warning">' . esc_html($mlsRequest['message']) . '</div>';
1206 - }
1207 -
1208 - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1209 - if ($foundItems === 'none') {
1210 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
1211 - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1212 - }
1213 -
1214 - echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport);
1215 - }
1893 + // Ensure a token, read the cached connection flag, print env warnings.
1894 + $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
1895 + $is_mls_connected = get_option('mlsimport_connection_test', '');
1896 + $mlsimport->admin->mlsimport_saas_setting_up();
1216 1897
1898 + // If not marked connected, run the connection test once and re-read the flag.
1899 + if ('yes' !== $is_mls_connected) {
1900 + $mlsimport->admin->mlsimport_saas_check_mls_connection();
1901 + $is_mls_connected = get_option('mlsimport_connection_test', '');
1902 + }
1217 1903
1904 + // No token -> account not authenticated; stop with a notice
1905 + // that names the reason (no subscription vs wrong password).
1906 + if (trim($token) === '') {
1907 + echo mlsimport_account_not_connected_html(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped by the builder.
1908 + return;
1909 + }
1218 1910
1911 + // Token OK but MLS connection failed -> stop with a notice.
1912 + if ('yes' !== $is_mls_connected) {
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>';
1914 + return;
1915 + }
1219 1916
1917 + // Load current task settings for the form.
1918 + $postId = $post->ID;
1919 + $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1920 + $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1921 + $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1922 + $status = get_option('mlsimport_force_stop_' . $postId);
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);
1928 +
1929 + // Ask the MLS how many listings currently match this task.
1930 + $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1931 + // print_r($mlsRequest);
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.
1940 + $hasError = isset($mlsRequest['success']) && !$mlsRequest['success'];
1941 + if ($hasError) {
1942 + $errorMessage = $mlsRequest['message'] ?? $mlsRequest['error']['message'] ?? esc_html__('The MLS request failed.', 'mlsimport');
1943 + echo '<div class="mlsimport_warning">' . esc_html($errorMessage) . '</div>';
1944 + }
1945 +
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;
1956 +
1957 + // Build and print the parameter form.
1958 + echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError);
1959 + }
1960 +
1961 +
1962 +
1963 +
1220 1964 /**
1221 1965 * Generate Meta Options HTML
1222 1966 *
1223 1967 * @param int $postId The post ID.
1224 - * @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.
1225 1970 * @param string $lastDate The last date checked.
1226 1971 * @param string $mlsimportItemHowMany How many items to import.
1227 1972 * @param string $mlsimportItemStatCron The status of the cron job.
1228 1973 * @param int $mlsimportMlsId The MLS import ID.
@@ -1228,12 +1973,40 @@
1228 1973 * @param int $mlsimportMlsId The MLS import ID.
1229 1974 * @param array $fieldImport The fields to import.
1230 1975 * @return string The generated HTML.
1231 1976 */
1232 - private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport) {
1977 + private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError = false) {
1233 1978
1979 +
1980 + // Buffer all HTML and return it as a string.
1234 1981 ob_start();
1235 1982
1983 + // Decode the saved MLS enums so City/County/PropertyType options can
1984 + // carry their human-readable labels alongside the raw values.
1985 + $metadata_api_call_city = array();
1986 + $metadata_api_call_county = array();
1987 + $metadata_api_call_property_type = array();
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 ) );
1991 + if ('' !== $mlsimport_mls_metadata_mls_enums) {
1992 + $metadata_api_call_full = json_decode($mlsimport_mls_metadata_mls_enums, true);
1993 + if (isset($metadata_api_call_full['global_array']['PropertyEnums'])) {
1994 + $property_enums = $metadata_api_call_full['global_array']['PropertyEnums'];
1995 + if (isset($property_enums['City']) && is_array($property_enums['City'])) {
1996 + $metadata_api_call_city = $property_enums['City'];
1997 + }
1998 +
1999 + if (isset($property_enums['CountyOrParish']) && is_array($property_enums['CountyOrParish'])) {
2000 + $metadata_api_call_county = $property_enums['CountyOrParish'];
2001 + }
2002 +
2003 + if (isset($property_enums['PropertyType']) && is_array($property_enums['PropertyType'])) {
2004 + $metadata_api_call_property_type = $property_enums['PropertyType'];
2005 + }
2006 + }
2007 + }
2008 +
1236 2009 ?>
1237 2010 <div class="mlsimport_item_search_url" style="display:none;"><?php echo esc_html__('Last date/time we check :', 'mlsimport') . ' ' . esc_html($lastDate); ?></div>
1238 2011 <ul>
1239 2012 <li>1. Set the import parameters.</li>
@@ -1243,17 +2016,58 @@
1243 2016 </ul>
1244 2017
1245 2018 <?php if (is_numeric($foundItems) && $foundItems >= 500): ?>
1246 2019 <div class="mlsimport_notification">
1247 - <?php esc_html_e('You found a large number of listings. While MlsImport import can handle such a large number, you need to make sure that your server can do this operation. This import will take some time. Make sure your server has the capacity, there are no time limits for a long-running process and consider splitting the import between multiple MLS Import items.', 'mlsimport'); ?>
2020 + <?php esc_html_e('You found a large number of listings. While MlsImport import can handle such a large number, you need to make sure that your server can do this operation. This import will take some time. Make sure your server has the capacity, there are no time limits for a long-running process and consider splitting the import between multiple MLS Import Tasks.', 'mlsimport'); ?>
1248 2021 </div>
1249 2022 <?php endif; ?>
1250 2023
1251 2024 <div class="mlsimport_import_no">
1252 - <?php esc_html_e('We found', 'mlsimport'); ?>
1253 - <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; ?>
1254 2032 </div>
1255 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 +
1256 2070 <fieldset class="mlsimport-fieldset">
1257 2071 <label class="mlsimport-label" for="mlsimport_item_how_many">
1258 2072 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
1259 2073 </label>
@@ -1269,20 +2083,20 @@
1269 2083 <span class="slider round"></span>
1270 2084 </label>
1271 2085 </fieldset>
1272 2086
1273 - <?php if ($mlsimportItemStatCron !== ''): ?>
1274 - <div id="mlsimport_item_status"></div>
1275 - <div id="mlsimport_item_progress" class="mlsimport-progress-bar">
1276 - <div class="mlsimport-progress-bar-inner" style="width:0%;"></div>
1277 - </div>
1278 - <input class="button mlsimport_button save_data " type="button" id="mlsimport-start_item"
1279 - data-post-number="<?php echo intval($foundItems); ?>"
1280 - data-post_id="<?php echo intval($postId); ?>" value="Start Import">
1281 - <input class="button mlsimport_button error_action" type="button" id="mlsimport_stop_item"
1282 - data-post-number="<?php echo intval($foundItems); ?>"
1283 - data-post_id="<?php echo intval($postId); ?>" value="Stop Import">
1284 - <?php endif; ?>
2087 + <?php if ($mlsimportItemStatCron !== '' && !$hasError): ?>
2088 + <div id="mlsimport_item_status">Ready to import!</div>
2089 + <div id="mlsimport_item_progress" class="mlsimport-progress-bar">
2090 + <div class="mlsimport-progress-bar-inner" style="width:0%;"></div>
2091 + </div>
2092 + <input class="button mlsimport_button save_data " type="button" id="mlsimport-start_item"
2093 + data-post-number="<?php echo intval($foundItems); ?>"
2094 + data-post_id="<?php echo intval($postId); ?>" value="Start Import">
2095 + <input class="button mlsimport_button error_action" type="button" id="mlsimport_stop_item"
2096 + data-post-number="<?php echo intval($foundItems); ?>"
2097 + data-post_id="<?php echo intval($postId); ?>" value="Stop Import">
2098 + <?php endif; ?>
1285 2099
1286 2100 <input type="hidden" id="mlsimport_item_actions" value="<?php echo esc_attr(wp_create_nonce("mlsimport_item_actions")); ?>"/>
1287 2101 <div class="mlsimport_param_wrapper"><h2><?php esc_html_e('Import Parameters', 'mlsimport'); ?></h2>
1288 2102
@@ -1317,8 +2131,23 @@
1317 2131 ?>
1318 2132 </select>
1319 2133 </fieldset>
1320 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 +
1321 2150 <?php
1322 2151 $mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true));
1323 2152 if ('' === $mlsimportItemPropertyStatus) {
1324 2153 $mlsimportItemPropertyStatus = 'publish';
@@ -1368,31 +2197,36 @@
1368 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); ?>">
1369 2198 </fieldset>
1370 2199
1371 2200 <?php
1372 - $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 );
1373 2214
1374 - $mlsId = '';
1375 - if (isset($options['mlsimport_mls_name'])) {
1376 - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
1377 - }
1378 -
1379 - if ($mlsId > 5000) {
1380 - $fieldImport['PropertyType']['multiple'] = 'no';
1381 - }
1382 -
1383 -
1384 - if ($mlsId >= 7000) {
1385 - // there is no such thing for realtor.ca
1386 - unset($fieldImport['PropertyType']);
1387 - }
1388 -
2215 + // Render one fieldset per import parameter.
1389 2216 foreach ($fieldImport as $key => $field):
2217 + // Skip fields flagged hidden.
2218 + if (!empty($field['hidden'])) {
2219 + continue;
2220 + }
2221 + // Derive the meta key + its companion "_check" (select-all) key.
1390 2222 $nameCheck = strtolower('mlsimport_item_' . $key . '_check');
1391 2223 $name = strtolower('mlsimport_item_' . $key);
1392 2224
2225 + // Current saved value + select-all flag for this field.
1393 2226 $value = get_post_meta($postId, $name, true);
1394 2227 $valueCheck = get_post_meta($postId, $nameCheck, true);
2228 + // extraCity/extraCounty render as a toggle button, not a plain label.
1395 2229 $extraClass = '';
1396 2230 if ('extraCity' === $key || 'extraCounty' === $key) {
1397 2231 $extraClass = ' mlsimport_hidden_field_button button mlsimport_button';
1398 2232 }
@@ -1405,13 +2239,15 @@
1405 2239 <div class="mlsimport-input-wrapper" style="display:none">
1406 2240 <?php endif; ?>
1407 2241 <p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?>
1408 2242 <?php
2243 + // Whether the "select all" checkbox is currently on.
1409 2244 $isCheckboxAdmin = 0;
1410 2245 if (1 === intval($valueCheck)) {
1411 2246 $isCheckboxAdmin = 1;
1412 2247 }
1413 2248
2249 + // Fields that must NOT offer a "select all" checkbox.
1414 2250 $selectAllNone = [
1415 2251 'InternetAddressDisplayYN',
1416 2252 'InternetEntireListingDisplayYN',
1417 2253 'PostalCode',
@@ -1416,13 +2252,14 @@
1416 2252 'InternetEntireListingDisplayYN',
1417 2253 'PostalCode',
1418 2254 'ListAgentKey',
1419 2255 'ListAgentMlsId',
1420 - 'ListOfficeKey',
1421 - 'ListOfficeMlsId',
1422 - 'StandardStatus',
1423 - 'StandardStatusDelete',
2256 + 'BuyerAgentMlsId',
2257 + 'ListOfficeKey',
2258 + 'ListOfficeMlsId',
2259 + 'StandardStatus',
1424 2260 'ListingId',
2261 + 'ListingKey',
1425 2262 'extraCity',
1426 2263 'extraCounty',
1427 2264 'Exclude_ListOfficeKey',
1428 2265 'Exclude_ListOfficeMlsId',
@@ -1427,13 +2264,18 @@
1427 2264 'Exclude_ListOfficeKey',
1428 2265 'Exclude_ListOfficeMlsId',
1429 2266 'Exclude_ListAgentKey',
1430 2267 'Exclude_ListAgentMlsId',
2268 + 'CustomParameters',
1431 2269 'MLSAreaMajor',
1432 2270 'SubdivisionName',
1433 2271 ];
1434 2272
1435 - 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) {
1436 2278 $selectAllNone[] = 'PropertyType';
1437 2279 }
1438 2280
1439 2281 if (!in_array($key, $selectAllNone)): ?>
@@ -1450,8 +2292,9 @@
1450 2292 $permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending'];
1451 2293
1452 2294 if ($field['type'] === 'select'): ?>
1453 2295 <?php
2296 + // Multi-select fields need the multiple attr + [] name.
1454 2297 $multiple = '';
1455 2298 if ('yes' === $field['multiple']) {
1456 2299 $multiple = 'multiple';
1457 2300 $name .= '[]';
@@ -1456,8 +2299,9 @@
1456 2299 $multiple = 'multiple';
1457 2300 $name .= '[]';
1458 2301 }
1459 2302
2303 + // Default StandardStatus to Active when nothing saved.
1460 2304 if ('StandardStatus' === $key && '' === $value) {
1461 2305 $value = ['Active'];
1462 2306 }
1463 2307
@@ -1462,29 +2306,68 @@
1462 2306 }
1463 2307
1464 2308
1465 2309
2310 + // City/County lists can hold 300+ entries — render a filter
2311 + // input above the full native multi-select listbox.
2312 + $searchableClass = $this->mlsimport_searchable_select_class($key);
2313 + $isSearchable = '' !== $searchableClass;
2314 + $searchPlaceholder = $isSearchable
2315 + ? esc_html__('Type to search…', 'mlsimport')
2316 + : '';
2317 +
1466 2318 // Additional conditions can be placed here.
1467 2319 ?>
1468 - <select class="mlsimport-select mlsimport-2025-select" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" <?php echo esc_attr($multiple); ?>>
1469 - <?php foreach ($field['values'] as $selectKey): ?>
2320 + <?php if ($isSearchable): ?>
2321 + <div class="mlsimport-selected-chips" aria-live="polite"></div>
2322 + <input type="text" class="mlsimport-select-search" placeholder="<?php echo esc_attr($searchPlaceholder); ?>" aria-label="<?php echo esc_attr($searchPlaceholder); ?>" autocomplete="off">
2323 + <?php endif; ?>
2324 + <select class="mlsimport-select mlsimport-2025-select<?php echo esc_attr($searchableClass); ?>" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>"<?php echo $isSearchable ? ' size="12"' : ''; ?> <?php echo esc_attr($multiple); ?>>
2325 + <?php foreach ($field['values'] as $selectKey): ?>
1470 2326
1471 - <?php if ('' !== $selectKey): ?>
1472 - <option value="<?php echo esc_attr($selectKey); ?>"
1473 - <?php
1474 - if ($key === "StandardStatusDelete" && $value==null ) {
1475 -
1476 - print 'selected';
1477 - }
1478 - ?>
1479 - <?php if (is_array($value) ? in_array($selectKey, $value) : $selectKey === $value) echo 'selected'; ?>>
1480 - <?php echo esc_html($selectKey); ?>
1481 - </option>
1482 - <?php endif; ?>
2327 + <?php if ('' !== $selectKey): ?>
2328 + <?php
2329 + // Match saved value against the raw key AND its
2330 + // enum-mapped label, so either form stays selected.
2331 + $option_value = $selectKey;
2332 + $option_label = $selectKey;
2333 + $comparison_values = array($option_value);
1483 2334
1484 - <?php endforeach; ?>
1485 - </select>
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.
2338 + if ('City' === $key && isset($metadata_api_call_city[$selectKey])) {
2339 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_city);
2340 + $comparison_values[] = $metadata_api_call_city[$selectKey];
2341 + } elseif ('CountyOrParish' === $key && isset($metadata_api_call_county[$selectKey])) {
2342 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_county);
2343 + $comparison_values[] = $metadata_api_call_county[$selectKey];
2344 + } elseif ('PropertyType' === $key && isset($metadata_api_call_property_type[$selectKey])) {
2345 + $option_label = mlsimport_enum_option_label($selectKey, $metadata_api_call_property_type);
2346 + $comparison_values[] = $metadata_api_call_property_type[$selectKey];
2347 + }
1486 2348
2349 + $comparison_values = array_values(array_unique(array_filter($comparison_values, static function ($compare_value) {
2350 + return '' !== $compare_value && null !== $compare_value;
2351 + })));
2352 +
2353 + // Selected if any comparison value matches the saved
2354 + // value (array for multi-selects, scalar otherwise).
2355 + $is_selected = false;
2356 + if (is_array($value)) {
2357 + $is_selected = count(array_intersect($comparison_values, $value)) > 0;
2358 + } else {
2359 + $is_selected = in_array($value, $comparison_values, true);
2360 + }
2361 + ?>
2362 + <option value="<?php echo esc_attr($option_value); ?>" <?php echo $is_selected ? 'selected' : ''; ?>>
2363 + <?php echo esc_html($option_label); ?>
2364 + </option>
2365 + <?php endif; ?>
2366 +
2367 + <?php endforeach; ?>
2368 + </select>
2369 +
1487 2370 <?php elseif ($field['type'] === 'input'): ?>
1488 2371 <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>">
1489 2372 <?php endif; ?>
1490 2373 <?php if ('extraCity' === $key || 'extraCounty' === $key): ?>
@@ -1494,8 +2377,9 @@
1494 2377 <?php endforeach; ?>
1495 2378
1496 2379 </div>
1497 2380 <?php
2381 + // Return the buffered form markup.
1498 2382 return ob_get_clean();
1499 2383 }
1500 2384
1501 2385
@@ -1502,18 +2386,22 @@
1502 2386
1503 2387
1504 2388
1505 2389
2390 + // Placeholder hook target for injecting additional Import Task fields (no-op).
1506 2391 public function mlsimport_add_extra_fields() {
1507 2392 }
1508 2393
1509 2394 /**
2395 + * Per-field help text override, keyed by MLS + meta field.
1510 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.
1511 2399 *
1512 - *
1513 - *
1514 - *
1515 - *
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
1516 2404 */
1517 2405 function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) {
1518 2406 // 111 - Rae Edmonton
1519 2407
@@ -1525,15 +2413,18 @@
1525 2413 }
1526 2414
1527 2415
1528 2416 /**
2417 + * Return the "last checked" timestamp for an Import Task, seeding it if unset.
1529 2418 *
1530 - *
1531 - * Get Last date
2419 + * @param int $item_id Import Task post id.
2420 + * @return string A 'Y-m-d\TH:i' timestamp.
1532 2421 */
1533 2422 public function mlsimport_saas_get_last_date( $item_id ) {
2423 + // Stored watermark used as the modification-time filter for syncs.
1534 2424 $last_date = get_post_meta( $item_id, 'mlsimport_last_date', true );
1535 2425
2426 + // First run: initialize it.
1536 2427 if ( '' === $last_date ) {
1537 2428 $last_date = $this->mlsimport_saas_update_last_date( $item_id );
1538 2429 }
1539 2430 return $last_date;
@@ -1540,14 +2431,19 @@
1540 2431 }
1541 2432
1542 2433
1543 2434 /**
2435 + * Set the Import Task's "last checked" watermark to 2 hours ago and store it.
1544 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.
1545 2439 *
1546 - * Save Last date
2440 + * @param int $item_id Import Task post id.
2441 + * @return string The stored 'Y-m-d\TH:i' timestamp.
1547 2442 */
1548 2443 public function mlsimport_saas_update_last_date( $item_id ) {
1549 2444
2445 + // Current site time minus 2 hours, formatted as an ISO-ish local stamp.
1550 2446 $unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 );
1551 2447 print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time );
1552 2448 update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save );
1553 2449
@@ -1562,241 +2458,118 @@
1562 2458 * Check and process MLSimport item for modified listings in the last 2 hours.
1563 2459 * Optimized for memory: logs memory, unsets large arrays, and triggers garbage collection.
1564 2460 *
1565 2461 * @param int $item_id
1566 - * @return void
2462 + * @return int Number of listings found in the MLS feed, or 0 on failure.
1567 2463 */
1568 - public function mlsimport_saas_start_cron_links_per_item( $item_id ) {
1569 - // Log memory before start
1570 - //error_log("[MLSimport] Start $item_id, memory: " . (memory_get_usage(true) / 1024 / 1024) . " MB");
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 );
1571 2481
1572 - $last_date = $this->mlsimport_saas_get_last_date( $item_id );
1573 - print 'MLSitem id: ' . $item_id . ' - ';
1574 - esc_html_e('date to consider: ','mlsimport');
1575 - print esc_html($last_date) . '. ';
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 + }
1576 2493
1577 - // Make request to MLS API
1578 - $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date );
1579 - //error_log("[MLSimport] After mlsimport_make_listing_requests, memory: " . (memory_get_usage(true) / 1024 / 1024) . " MB");
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' ) );
1580 2499
1581 - $found_items = 0;
1582 - if ( isset( $mlsrequest['results'] ) ) {
1583 - $found_items = intval( $mlsrequest['results'] );
1584 - } else {
1585 - delete_transient( 'mlsimport_saas_token' );
1586 - }
1587 - print esc_html__('We found ','mlsimport') . esc_html( $found_items ) . ' listings.</br>' . PHP_EOL;
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();
1588 2521
1589 - // Only process if items found
1590 - if ( $found_items > 0 ) {
1591 -
1592 - $item_id_array = array(
1593 - 'item_id' => $item_id,
1594 - 'how_many' => 0,
1595 - 'max_number' => $found_items,
1596 - 'batch_counter' => 1,
1597 - );
2522 + return (int) $result['found'];
2523 + }
1598 2524
1599 - // Potentially large array, log memory before/after
1600 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date );
1601 - //error_log("[MLSimport] After generate_import_requests_per_item, memory: " . (memory_get_usage(true) / 1024 / 1024) . " MB");
1602 2525
1603 - // Store in post meta (beware if array is huge)
1604 - update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' );
1605 - update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move );
1606 2526
1607 - // Save last date for next run
1608 - $this->mlsimport_saas_update_last_date( $item_id );
1609 2527
1610 - // Prepare and pass only necessary arguments to background process
1611 - $attachments_to_send = array(
1612 - 'args' => array(
1613 - 'attachments_to_move' => $item_id,
1614 - 'item_id_array' => $item_id_array,
1615 - ),
1616 - );
1617 2528
1618 - $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] );
1619 2529
1620 - // Unset large arrays/objects after use
1621 - unset($attachments_to_move, $attachments_to_send, $mlsrequest, $item_id_array);
1622 - gc_collect_cycles();
1623 - //error_log("[MLSimport] End processing $item_id, memory: " . (memory_get_usage(true) / 1024 / 1024) . " MB");
1624 - }
1625 - }
1626 -
1627 -
1628 -
1629 -
1630 -
1631 -
1632 2530 /**
1633 - * 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).
1634 2540 */
1635 2541 public function mlsimport_saas_start_doing_reconciliation() {
1636 - global $mlsimport, $wpdb;
1637 -
1638 -
1639 - // Get all MLS keys in memory (we assume this is necessary for lookup)
1640 - $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data();
1641 - $listingKey_in_MLS = $mls_data['all_data'] ?? [];
1642 -
1643 - unset($mls_data);
1644 - gc_collect_cycles();
1645 -
1646 - if (empty($listingKey_in_MLS)) {
1647 - return;
1648 - }
1649 - // Flip for fast lookup
1650 - $listingKey_in_MLS = array_flip($listingKey_in_MLS);
1651 -
1652 - // Batch fetch local listings
1653 - $batch = 1000;
1654 - $offset = 0;
1655 - $to_delete = 0;
1656 - $counter = 0;
1657 -
1658 - $mlsimport_preload_all_mls_item_status_meta = $this->mlsimport_preload_all_mls_item_status_meta();
1659 - //print_r($mlsimport_preload_all_mls_item_status_meta);
1660 -
1661 - do {
1662 - $local = $wpdb->get_results(
1663 - $wpdb->prepare(
1664 - "SELECT
1665 - p.ID,
1666 - listingkey_meta.meta_value AS listingkey,
1667 - inserted_meta.meta_value AS mlsimport_item_inserted
1668 - FROM {$wpdb->posts} p
1669 - INNER JOIN {$wpdb->postmeta} listingkey_meta
1670 - ON p.ID = listingkey_meta.post_id
1671 - AND listingkey_meta.meta_key = %s
1672 - LEFT JOIN {$wpdb->postmeta} inserted_meta
1673 - ON p.ID = inserted_meta.post_id
1674 - AND inserted_meta.meta_key = %s
1675 - WHERE p.post_status NOT IN ('draft', 'trash')
1676 - LIMIT %d OFFSET %d",
1677 - 'ListingKey',
1678 - 'MLSimport_item_inserted',
1679 - $batch,
1680 - $offset
1681 - ),
1682 - ARRAY_A
1683 - );
1684 -
1685 - $count = count($local);
1686 -
1687 -
1688 - foreach ($local as $item) {
1689 - $listingkey = $item['listingkey']; // not 'meta_value' anymore
1690 - $property_id = $item['ID'];
1691 - $mlsimportItemId = $item['mlsimport_item_inserted'];
1692 - ++$counter;
1693 - // IN MLS
1694 - if (isset($listingKey_in_MLS[$listingkey])) {
1695 -
1696 - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
1697 - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
1698 - } else {
1699 - $mlsimport_item_standardstatus = null;
1700 - }
1701 -
1702 - $keep_when_in_mls = $mlsimport->admin->theme_importer->check_if_delete_when_status_when_in_mls($property_id,$mlsimport_item_standardstatus);
1703 - if (!$keep_when_in_mls) {
1704 - ++$to_delete;
1705 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
1706 - }
1707 - } else {
1708 - // NOT IN MLS
1709 -
1710 - if (!empty($mlsimportItemId) && isset($mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId])) {
1711 - $mlsimport_item_standardstatus = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatus'] ?? null;
1712 - $mlsimport_item_standardstatusdelete = $mlsimport_preload_all_mls_item_status_meta[$mlsimportItemId]['mlsimport_item_standardstatusdelete'] ?? null;
1713 - } else {
1714 - $mlsimport_item_standardstatus = null;
1715 - $mlsimport_item_standardstatusdelete = null;
1716 - }
1717 - $keep = $mlsimport->admin->theme_importer->check_if_delete_when_status($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusdelete);
1718 - if (!$keep) {
1719 - ++$to_delete;
1720 - $mlsimport->admin->theme_importer->mlsimportSaasDeletePropertyViaMysql($property_id, $listingkey);
1721 - }
1722 - }
1723 -
1724 - // Memory housekeeping
1725 - unset($listingkey, $property_id, $mlsimportItemId, $mlsImportItemStatus, $mlsImportItemStatusDelete, $keep_when_in_mls, $keep);
1726 - if ($counter % 250 == 0) {
1727 - gc_collect_cycles();
1728 - }
1729 - }
1730 -
1731 - unset($local);
1732 - gc_collect_cycles();
1733 -
1734 - $offset += $batch;
1735 - } while ($count === $batch);
1736 -
1737 -
1738 - print esc_html(' to delete:' . $to_delete);
1739 -
1740 - // Final cleanup
1741 - unset($listingKey_in_MLS);
1742 - gc_collect_cycles();
1743 -
1744 -
1745 - 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();
1746 2546 }
1747 2547
1748 -
1749 -
1750 -/**
1751 - * Preload all status meta for ALL mlsimport_item posts in ONE QUERY.
1752 - * Returns: [mlsimport_item_id => ['mlsimport_item_standardstatus' => ..., 'mlsimport_item_standardstatusdelete' => ...], ...]
1753 - */
1754 -function mlsimport_preload_all_mls_item_status_meta() {
1755 - global $wpdb;
1756 -
1757 - // Only 1 query: join posts and postmeta, grab both metas
1758 - $sql = "
1759 - SELECT p.ID as post_id, pm.meta_key, pm.meta_value
1760 - FROM {$wpdb->posts} p
1761 - LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
1762 - WHERE p.post_type = 'mlsimport_item'
1763 - AND pm.meta_key IN ('mlsimport_item_standardstatus', 'mlsimport_item_standardstatusdelete')
1764 - ";
1765 -
1766 - $rows = $wpdb->get_results($sql);
1767 -
1768 - $meta = [];
1769 - foreach ($rows as $row) {
1770 - if (!isset($meta[$row->post_id])) {
1771 - $meta[$row->post_id] = [
1772 - 'mlsimport_item_standardstatus' => null,
1773 - 'mlsimport_item_standardstatusdelete' => null
1774 - ];
1775 - }
1776 - $meta[$row->post_id][$row->meta_key] = maybe_unserialize($row->meta_value);
1777 - }
1778 - return $meta;
1779 -}
1780 -
1781 -
1782 -
1783 2548 /**
2549 + * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
1784 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.
1785 2555 *
1786 - * 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.
1787 2558 */
1788 - public function mlsimport_saas_get_mls_reconciliation_data() {
2559 + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) {
1789 2560
1790 - $arguments = array();
1791 - $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' );
1792 2564 return $answer;
1793 2565 }
1794 2566
1795 2567 /**
2568 + * Return all published posts' values for a given meta key, with their post ids.
1796 2569 *
1797 - *
1798 - * Reconciliation get local data
2570 + * @param string $key Meta key to fetch.
2571 + * @return array Rows of {meta_value, ID}.
1799 2572 */
1800 2573 public function mlsimport_saas_get_all_meta_values($key) {
1801 2574 global $wpdb;
1802 2575 $result = $wpdb->get_results(
@@ -1816,52 +2589,98 @@
1816 2589 }
1817 2590
1818 2591
1819 2592
1820 - /*
1821 - * Do api Listing Requests
2593 + /**
2594 + * Run a single listings request for an Import Task and return the API result.
1822 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.
1823 2600 *
1824 - *
1825 - *
1826 - * */
1827 - public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '' ) {
1828 - $options = get_option( $this->plugin_name . '_admin_options' );
1829 - $mls_id = '';
1830 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1831 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1832 - }
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 + */
2608 + public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
2609 + // Build the full RESO query argument set from the task's meta.
2610 + $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top, $is_hourly_sync );
1833 2611
1834 - $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top );
1835 -
1836 -
1837 - if (
1838 - $mls_id > 5000 && $mls_id < 6000 &&
1839 - ( ! isset( $arguments['property_type'] ) or
1840 - ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or
1841 - ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] )
1842 - )
1843 - ) {
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'];
1844 2616 return array(
1845 2617 'success' => false,
1846 - 'type' => 'rapattoni',
1847 - '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' ),
1848 2620 );
1849 2621 }
1850 2622
2623 + // Guard against an over-long query string (too many parameters selected).
1851 2624 $potential_leght = strlen( wp_json_encode( $arguments ) );
1852 2625 if ( $potential_leght > 1750 ) {
1853 2626 return array(
1854 2627 'success' => false,
1855 2628 'potential_leght' => $potential_leght,
1856 - 'message' => esc_html__( 'You have too many parameters selected. Split the import beween multiple MLS Import items: For ex : Import per County instead of selecting 10 cities or import listing between certain price range.', 'mlsimport' ),
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' ),
1857 2630 );
1858 2631 }
1859 2632
1860 - //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);
1861 2645 //print '----------------------------'.PHP_EOL;
1862 - $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.
1863 2667 $answer['potential_leght'] = $potential_leght;
2668 +
2669 + // Record the pre-filter MLS feed count for telemetry. Every import path
2670 + // — manual, hourly cron, and onboarding — routes through this method, so
2671 + // recording here is the single rule that keeps the metric complete.
2672 + if ( isset( $answer['results'] ) ) {
2673 + mlsimport_telemetry_set( 'last_feed_found', (int) $answer['results'] );
2674 + }
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 +
1864 2683 return ( $answer );
1865 2684 }
1866 2685
1867 2686
@@ -1868,26 +2687,38 @@
1868 2687
1869 2688
1870 2689
1871 2690
1872 - /*
1873 - * Create Api query arguments
2691 + /**
2692 + * Assemble the RESO listings query arguments from an Import Task's meta.
1874 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).
1875 2700 *
1876 - *
1877 - *
1878 - *
1879 - * */
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 + */
2708 + public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '', $is_hourly_sync = false ) {
1880 2709
1881 - public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '' ) {
1882 -
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.
1883 2713 $options = get_option( $this->plugin_name . '_admin_options' );
1884 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1885 - $mls_id = intval( $options['mlsimport_mls_name'] );
1886 - } else {
2714 + $mls_id = mlsimport_task_mls_id( (int) $item_id );
2715 + if ( $mls_id <= 0 ) {
1887 2716 return '';
1888 2717 }
1889 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).
1890 2721 if ( isset( $options['mlsimport_theme_used'] ) ) {
1891 2722 $theme_id = intval( $options['mlsimport_theme_used'] );
1892 2723 } else {
1893 2724 return '';
@@ -1892,12 +2723,18 @@
1892 2723 } else {
1893 2724 return '';
1894 2725 }
1895 2726
2727 + // Base parameters every request carries.
1896 2728 $values = array();
1897 2729 $values['mls_id'] = $mls_id;
1898 2730 $values['theme_id'] = $theme_id;
2731 + // Flag hourly-sync calls so the backend can treat them differently.
2732 + if ( $is_hourly_sync ) {
2733 + $values['hourly_sync'] = 1;
2734 + }
1899 2735
2736 + // Pagination (only when a page size was supplied).
1900 2737 if ( '' !== $top ) {
1901 2738 $values['top'] = $top;
1902 2739 $values['skip'] = intval( $skip );
1903 2740 }
@@ -1902,8 +2739,9 @@
1902 2739 $values['skip'] = intval( $skip );
1903 2740 }
1904 2741
1905 2742 // // add price
2743 + // Price range (only when both bounds are set).
1906 2744 $mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true );
1907 2745 $mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true );
1908 2746 if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) {
1909 2747 $values['list_price_min'] = floatval( $mlsimport_item_min_price );
@@ -1926,11 +2764,11 @@
1926 2764 $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values );
1927 2765
1928 2766 // add status
1929 2767
1930 - if ( 111 !== $mls_id ) { // edmonton check
1931 - $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
1932 - }
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 );
1933 2771
1934 2772 // add property_subtype
1935 2773 $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values );
1936 2774
@@ -1936,19 +2774,8 @@
1936 2774
1937 2775 // add property_type
1938 2776 $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values );
1939 2777
1940 - // rapattoni exception
1941 - if ( $mls_id > 5000 &&
1942 - ( isset($values['property_type']) && $values['property_type'] !='' ) ) {
1943 -
1944 - $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values );
1945 - $temp = $values['property_type'];
1946 - $temp = str_replace( ' ', '', $temp );
1947 - $values['property_type'] = array();
1948 - $values['property_type'][] = $temp;
1949 - }
1950 -
1951 2778 // add internet_entirelisting_displayyn
1952 2779 $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values );
1953 2780
1954 2781 // add internet_address_displayyn
@@ -1953,14 +2780,16 @@
1953 2780
1954 2781 // add internet_address_displayyn
1955 2782 $values = $this->mls_import_saas_add_to_parms_input( 'InternetAddressDisplayYN', $item_id, 'internet_address_displayyn', $values );
1956 2783
1957 - // add ListAgentKey
1958 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentKey', $item_id, 'list_agentkey', $values );
1959 - // add ListAgentKey
1960 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentMlsId', $item_id, 'list_agentmlsid', $values );
1961 - // add ListOfficeKey
1962 - $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeKey', $item_id, 'list_officekey', $values );
2784 + // add ListAgentKey
2785 + $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentKey', $item_id, 'list_agentkey', $values );
2786 + // add ListAgentKey
2787 + $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentMlsId', $item_id, 'list_agentmlsid', $values );
2788 + // add BuyerAgentMlsId
2789 + $values = $this->mls_import_saas_add_to_parms_input( 'BuyerAgentMlsId', $item_id, 'buyer_agentmlsid', $values );
2790 + // add ListOfficeKey
2791 + $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeKey', $item_id, 'list_officekey', $values );
1963 2792 // add ListOfficeMlsId
1964 2793 $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeMlsId', $item_id, 'list_officemlsid', $values );
1965 2794
1966 2795 // add ListingId
@@ -1965,8 +2794,12 @@
1965 2794
1966 2795 // add ListingId
1967 2796 $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values );
1968 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 +
1969 2802 //add Exclude_ListOfficeKey
1970 2803 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values );
1971 2804 // add Exclude_ListOfficeMlsId
1972 2805 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values );
@@ -1976,33 +2809,47 @@
1976 2809 //add Exclude_ListAgentKey
1977 2810 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentKey', $item_id, 'exclude_list_agentkey', $values );
1978 2811 // add Exclude_ListAgentMlsId
1979 2812 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentMlsId', $item_id, 'exclude_list_agentmlsid', $values );
2813 + // add CustomParameters
2814 + $values = $this->mls_import_saas_add_to_parms_input( 'CustomParameters', $item_id, 'custom_parameters', $values );
1980 2815
1981 2816
1982 - // if we have realtorca
1983 - if ($mls_id >= 7000 && $last_date!=='') {
1984 - $dateTime_realtorca = new DateTime($last_date, new DateTimeZone('UTC'));
1985 - // Format with seconds and UTC timezone marker
1986 - $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() );
1987 2827 }
1988 2828
1989 - if ( '' !== $last_date ) {
1990 - $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'] );
1991 2832 }
1992 2833
1993 - return( $values );
2834 + return $prepared['arguments'];
1994 2835 }
1995 2836
1996 2837
1997 2838
1998 - /*
2839 + /**
2840 + * Copy a single scalar Import Task meta value into the arguments array.
1999 2841 *
2000 - * add input items to parameters array
2842 + * Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name.
2001 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.
2002 2849 */
2003 -
2004 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.
2005 2852 $name = strtolower( 'mlsimport_item_' . $key );
2006 2853 $value = get_post_meta( $post_id, $name, true );
2007 2854 if ( '' !== $value ) {
2008 2855 $all_values[ $new_name ] = $value;
@@ -2011,15 +2858,24 @@
2011 2858 return $all_values;
2012 2859 }
2013 2860
2014 2861
2015 - /*
2862 + /**
2863 + * Copy a multi-value (list) Import Task meta value into the arguments array.
2016 2864 *
2017 - * add list items to parameters array
2018 - *
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.
2019 2875 */
2020 -
2021 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.
2022 2878 $name_check = strtolower( 'mlsimport_item_' . $key . '_check' );
2023 2879 $name = strtolower( 'mlsimport_item_' . $key );
2024 2880
2025 2881 $value = get_post_meta( $post_id, $name, true );
@@ -2064,8 +2920,9 @@
2064 2920 }
2065 2921 }
2066 2922 }
2067 2923
2924 + // Only include the list when "select all" is off and there is a value.
2068 2925 $value_check = get_post_meta( $post_id, $name_check, true );
2069 2926
2070 2927 if ( 0 === intval($value_check) && '' !== $value ) {
2071 2928 $all_values[ $new_name ] = $value;
@@ -2070,9 +2927,9 @@
2070 2927 if ( 0 === intval($value_check) && '' !== $value ) {
2071 2928 $all_values[ $new_name ] = $value;
2072 2929 }
2073 2930
2074 - // status exception
2931 + // status exception: always send status, regardless of the check flag.
2075 2932 if ( 'status' === $new_name ) {
2076 2933 $all_values[ $new_name ] = $value;
2077 2934 }
2078 2935
@@ -2080,23 +2937,27 @@
2080 2937 }
2081 2938
2082 2939
2083 2940
2084 - /*
2941 + /**
2942 + * Build the Import Task field definition list (labels, types, enum values).
2085 2943 *
2086 - * All Enums fiels to be used on MLS import Item
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.
2087 2949 *
2088 - *
2089 - *
2090 - *
2091 - *
2092 - *
2093 - * */
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 ) {
2094 2954
2095 - 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 );
2096 2958
2097 - $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' );
2098 -
2959 + // Warn the user when no metadata is available yet.
2099 2960 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
2100 2961 ?>
2101 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
2102 2963 <?php
@@ -2101,8 +2962,9 @@
2101 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
2102 2963 <?php
2103 2964 }
2104 2965
2966 + // Decode and reach into the enum container.
2105 2967 $metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true );
2106 2968
2107 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
2108 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
@@ -2107,8 +2969,9 @@
2107 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
2108 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
2109 2971 }
2110 2972
2973 + // Extract each enum list as a flat array of option keys (empty if absent).
2111 2974 $city_array = array();
2112 2975 if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) {
2113 2976 $city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] );
2114 2977 }
@@ -2134,36 +2997,26 @@
2134 2997 }
2135 2998
2136 2999
2137 3000 $standardstatus_array = array();
2138 - $standardstatus_delete_array=array();
2139 3001 if ( isset( $metadata_api_call['PropertyEnums']['StandardStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['StandardStatus'] ) ) {
2140 3002 $standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
2141 - $standardstatus_delete_array= array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
2142 3003 }
2143 3004
2144 3005 // if we do not have standart status
3006 + // Fall back to MlsStatus values when the MLS exposes no StandardStatus.
2145 3007 if ( empty( $standardstatus_array ) ) {
2146 3008 $standardstatus_array = $mlsstatus_array;
2147 - $standardstatus_delete_array = $mlsstatus_array;
2148 -
2149 3009 }
2150 3010
2151 -
2152 - $permited_status=array('active','active under contract','coming soon','activeundercontract','comingsoon','pending');
2153 - $permited_status_lower = array_map('strtolower', $permited_status);
2154 -
2155 - // Filter out permitted statuses from array1 values
2156 - // $standardstatus_delete_array = array_filter($standardstatus_delete_array, function ($value) use ($permited_status_lower) {
2157 - // return !in_array(strtolower($value), $permited_status_lower);
2158 -// });
2159 -
2160 3011
2161 3012
2162 3013
3014 + // Free-text "extra" inputs render empty; they hold comma-separated values.
2163 3015 $extracounty_values = '';
2164 3016 $extracity_values = '';
2165 3017
3018 + // Ordered field definitions consumed by the Import Task metabox renderer.
2166 3019 $field_import = array(
2167 3020 'City' => array(
2168 3021 'label' => esc_html__( 'Select cities', 'mlsimport' ),
2169 3022 'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ),
@@ -2212,9 +3065,9 @@
2212 3065 ),
2213 3066
2214 3067 'PostalCode' => array(
2215 3068 'label' => esc_html__( 'Select Postal Code', 'mlsimport' ),
2216 - 'description' => esc_html__( 'Select the PostalCode from where to import listings. Works with only one PostalCode.', 'mlsimport' ),
3069 + 'description' => esc_html__( 'Enter one or more postal codes to import listings from, separated by commas (e.g. 12345, 23456).', 'mlsimport' ),
2217 3070 'type' => 'input',
2218 3071 'multiple' => 'no',
2219 3072 ),
2220 3073
@@ -2238,14 +3091,14 @@
2238 3091 'type' => 'select',
2239 3092 'multiple' => 'yes',
2240 3093 'values' => $standardstatus_array,
2241 3094 ),
2242 - 'StandardStatusDelete' => array(
2243 - 'label' => esc_html__( 'Delete Statuses', 'mlsimport' ),
2244 - 'description' => __( 'Properties with these statuses will be deleted from your website after they are removed from MLS database. If you edit the field after importing, the changes will NOT apply to listings that have already been imported.', 'mlsimport' ),
3095 + 'StandardStatusProtect' => array(
3096 + 'label' => esc_html__( 'Protected Statuses', 'mlsimport' ),
3097 + 'description' => __( 'Properties with these statuses will NEVER be deleted from your website during reconciliation, even if they are no longer found in the MLS. Use this to protect Closed, Expired, or other non-active listings from automatic deletion.', 'mlsimport' ),
2245 3098 'type' => 'select',
2246 3099 'multiple' => 'yes',
2247 - 'values' => $standardstatus_delete_array,
3100 + 'values' => $standardstatus_array,
2248 3101 ),
2249 3102
2250 3103 'InternetEntireListingDisplayYN' => array(
2251 3104 'label' => esc_html__( 'Internet Entire Listing Display ', 'mlsimport'),
@@ -2272,20 +3125,26 @@
2272 3125 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
2273 3126 'type' => 'input',
2274 3127 'multiple' => 'no',
2275 3128 ),
2276 - 'ListAgentMlsId' => array(
2277 - 'label' => esc_html__( 'ListAgentMlsId', 'mlsimport' ),
2278 - 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
2279 - 'type' => 'input',
2280 - 'multiple' => 'no',
3129 + 'ListAgentMlsId' => array(
3130 + 'label' => esc_html__( 'ListAgentMlsId', 'mlsimport' ),
3131 + 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
3132 + 'type' => 'input',
3133 + 'multiple' => 'no',
3134 + ),
3135 + 'BuyerAgentMlsId' => array(
3136 + 'label' => esc_html__( 'BuyerAgentMlsId', 'mlsimport' ),
3137 + 'description' => esc_html__( 'Import listings from a specific Buyer Agent (contact your MLS for this information)', 'mlsimport' ),
3138 + 'type' => 'input',
3139 + 'multiple' => 'no',
3140 + ),
3141 + 'ListOfficeKey' => array(
3142 + 'label' => esc_html__( 'ListOfficeKey', 'mlsimport' ),
3143 + 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport'),
3144 + 'type' => 'input',
3145 + 'multiple' => 'no',
2281 3146 ),
2282 - 'ListOfficeKey' => array(
2283 - 'label' => esc_html__( 'ListOfficeKey', 'mlsimport' ),
2284 - 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport'),
2285 - 'type' => 'input',
2286 - 'multiple' => 'no',
2287 - ),
2288 3147 'ListOfficeMlsId' => array(
2289 3148 'label' => esc_html__( 'ListOfficeMlsId', 'mlsimport' ),
2290 3149 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport' ),
2291 3150 'type' => 'input',
@@ -2296,8 +3155,14 @@
2296 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'),
2297 3156 'type' => 'input',
2298 3157 'multiple' => 'no',
2299 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 + ),
2300 3165 'Exclude_ListOfficeMlsId' => array(
2301 3166 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ),
2302 3167 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ),
2303 3168 'type' => 'input',
@@ -2322,8 +3187,14 @@
2322 3187 'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentKey ', 'mlsimport'),
2323 3188 'type' => 'input',
2324 3189 'multiple' => 'no',
2325 3190 ),
3191 + 'CustomParameters' => array(
3192 + 'label' => esc_html__( 'Custom parameters', 'mlsimport' ),
3193 + 'description' => esc_html__( 'Add raw query fragment parameters (for example: $filter=WaterfrontYN eq true). They will be forwarded to the RESO API request.', 'mlsimport' ),
3194 + 'type' => 'input',
3195 + 'multiple' => 'no',
3196 + ),
2326 3197
2327 3198
2328 3199 );
2329 3200 return $field_import;
@@ -2335,326 +3206,206 @@
2335 3206
2336 3207
2337 3208
2338 3209 /**
3210 + * AJAX: kick off a manual import for one Import Task.
2339 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}.
2340 3216 *
2341 - * AYsnc Test
3217 + * @return void Emits JSON.
2342 3218 */
2343 3219 public function mlsimport_move_files_per_item() {
2344 3220 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2345 - $post_id = 0;
2346 - $how_many = 0;
2347 - $max_number = 0;
2348 - if(isset( $_POST['post_id'] )){
2349 - $post_id = intval( $_POST['post_id'] );
2350 - }
2351 - if(isset( $_POST['how_many'] )){
2352 - $how_many = intval( $_POST['how_many'] );
2353 - }
2354 - if(isset( $_POST['post_number'] )){
2355 - $max_number = intval( $_POST['post_number'] );
2356 - }
2357 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;
2358 3226
2359 - $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 + }
2360 3231
2361 -
2362 - update_option( 'mlsimport_force_stop_' . $post_id, 'no', false );
2363 -
2364 - $item_id_array = array(
2365 - 'item_id' => $post_id,
2366 - 'how_many' => $how_many,
2367 - 'max_number' => $max_number,
2368 - '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 + )
2369 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 + }
2370 3252
2371 - //error_log(json_encode($item_id_array));
2372 - //error_log("starting post id ".$post_id);
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 );
2373 3255
2374 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' );
2375 -
2376 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array );
3256 + $this->mlsimport_enqueue_import_worker( (string) $start['run_id'] );
2377 3257
2378 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move );
2379 -
2380 - // net stat data
2381 - update_post_meta( $post_id, 'mlsimport_progress_properties', 0 );
2382 - update_post_meta( $post_id, 'mlsimport_progress_batches', 0 );
2383 - update_post_meta( $post_id, 'mlsimport_progress_memory', 0 );
2384 -
2385 -
2386 -
2387 - $attachments_to_send = array(
2388 - 'args' => array(
2389 - 'attachments_to_move' => $post_id,
2390 - 'item_id_array' => $item_id_array,
2391 - 'is_onboard' =>$is_onboard,
2392 - ),
3258 + wp_send_json(
3259 + array(
3260 + 'success' => true,
3261 + 'run_id' => (string) $start['run_id'],
3262 + )
2393 3263 );
2394 -
2395 - mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL );
2396 - mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL );
2397 -
2398 - update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' );
2399 -
2400 - // old
2401 - as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send );
2402 -
2403 - // Remove any pending async jobs for this item and enqueue a unique one
2404 - //bad ideea
2405 - // as_unschedule_all_actions( 'mlsimport_background_process_per_item', $attachments_to_send );
2406 - //as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send, '', true );
2407 -
2408 -
2409 - spawn_cron();
2410 -
2411 - unset( $attachments_to_send ); print'before die';
2412 - die();
2413 3264 }
2414 3265
2415 3266 /**
2416 - * Process MLS Import attachments via background cron.
2417 - * Memory-optimized with detailed memory usage logging.
2418 - *
2419 - * @param array $input_arg
2420 - * @return void
2421 - */
2422 - public function mlsimport_background_process_per_item_cron_function( $input_arg ) {
2423 - global $mlsimport;
2424 -
2425 - $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL;
2426 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2427 - mlsimport_saas_single_write_import_custom_logs( '[Memory] Start: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
2428 -
2429 - // Load attachments to move from post meta
2430 - $attachments_to_move = get_post_meta(
2431 - $input_arg['item_id_array']['item_id'],
2432 - 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'],
2433 - true
2434 - );
2435 - $log = '[Memory] After loading attachments: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
2436 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2437 -
2438 - if (!empty($attachments_to_move) && is_array($attachments_to_move)) {
2439 - foreach ($attachments_to_move as $key => $import_arguments) {
2440 - // Optionally clear any cache for this batch
2441 - if ( isset($GLOBALS['wp_object_cache']) ) {
2442 - $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
2443 - }
2444 -
2445 - $log = '[Memory] Before API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
2446 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2447 -
2448 - // API call
2449 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas('listings', $import_arguments, 'POST');
2450 -
2451 - $log = '[Memory] After API batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
2452 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2453 -
2454 - // Parse/process response
2455 - $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem(
2456 - $api_call_array, $input_arg['item_id_array'], $key
2457 - );
2458 -
2459 - // Free per-iteration memory
2460 - unset($api_call_array, $import_arguments);
2461 - gc_collect_cycles();
2462 -
2463 - $log = '[Memory] After cleanup batch ' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB' . PHP_EOL;
2464 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2465 - }
2466 - }
2467 -
2468 - mlsimport_saas_single_write_import_custom_logs('[Memory] End: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron' );
2469 - mlsimport_saas_single_write_import_custom_logs('CRON JOB Import Completed ' . PHP_EOL, 'cron');
2470 - mlsimport_debuglogs_per_plugin('CRON JOB Import Completed ' . PHP_EOL);
2471 - update_post_meta($input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed');
2472 -
2473 - unset($attachments_to_move, $input_arg, $log);
2474 - gc_collect_cycles();
2475 - }
2476 -
2477 -
2478 -
2479 -
2480 - /**
3267 + * Queue the background import worker for an accepted Import Run.
2481 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.
2482 3278 *
2483 - * Generate import Requests per item
3279 + * @param string $run_id Accepted run identity to hand to the worker.
3280 + * @return void
2484 3281 */
2485 - public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '' ) {
2486 - $import_step = 25;
2487 -
2488 - $prop_id = $item_id_array['item_id'];
2489 - $max_found = $item_id_array['max_number'];
2490 - $how_many = $item_id_array['how_many'];
2491 - if ( 0=== intval($how_many) ) {
2492 - $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 );
2493 3300 }
2494 - if ( $how_many > $max_found ) {
2495 - $how_many = $max_found;
2496 - }
2497 3301
2498 - $search_url_step = '';
2499 - $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 + }
2500 3310
2501 - $skip = 0;
2502 - if ( $how_many > 10000 ) {
2503 - $how_many = 10000;
2504 - }
2505 - //error_log('updating for '.$prop_id.' with'. $how_many);
2506 - 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 + }
2507 3319
2508 - if ( $how_many < $import_step ) {
2509 - $import_step = $how_many;
2510 - }
2511 3320
2512 - while ( $skip < $how_many ) {
2513 3321
2514 - //$batch_step = min( $import_step, $how_many - $skip );
2515 -
2516 - //$search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $import_step );
2517 - //$skip = $skip + $import_step;
2518 - //$urls_array[] = $search_url_step;
2519 3322
2520 - // Determine how many items to request for this batch.
2521 - $batch_step = min( $import_step, $how_many - $skip );
2522 3323
2523 - // Build the request arguments using the remaining count.
2524 - $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $batch_step );
2525 3324
2526 - $skip += $batch_step;
2527 - $urls_array[] = $search_url_step;
2528 3325
2529 3326
2530 - }
2531 - return $urls_array;
2532 - }
2533 3327
2534 3328
2535 3329
2536 3330
2537 -
2538 -
2539 -
2540 3331 /**
2541 - * 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
2542 3339 */
2543 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 + }
2544 3346
2545 - $mlsimportItemId = $input_arg['item_id_array']['item_id'];
2546 - $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> ';
2547 - mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL );
2548 -
2549 -
2550 - // Get from MLS Import the big argument array only once
2551 - $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true );
2552 -
2553 - // Retrieve all meta data in one go to reduce database queries
2554 - $mlsimport_item_option_data = array(
2555 - 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ),
2556 - 'mlsimport_item_standardstatusdelete' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusdelete', true ),
2557 - 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ),
2558 - 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ),
2559 - '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 + }
2560 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 + );
2561 3373
2562 - $total_batches = count( $attachments_to_move );
2563 -
2564 - //error_log('$total_batches '. json_encode($total_batches) );
2565 -
2566 -
2567 -
2568 - // removed because $this
2569 - global $mlsimport;
2570 -
2571 - $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
2572 - mlsimport_saas_single_write_import_custom_logs( $log );
2573 -
2574 -
2575 - foreach ( $attachments_to_move as $key => $import_arguments ) {
2576 - // reconsider use
2577 - // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
2578 - $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId );
2579 - if ( 'no' === $status ) {
2580 - // Clear memory before processing each batch
2581 - wp_cache_flush();
2582 - gc_collect_cycles();
2583 -
2584 - // wp_cache_flush();
2585 - $mem_usage = memory_get_usage( true );
2586 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
2587 -
2588 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $key + 1 );
2589 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $mem_usage_show );
2590 -
2591 -
2592 -
2593 - mlsimport_saas_single_write_import_custom_logs( $log );
2594 - $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL;
2595 - //error_log($log);
2596 -
2597 -
2598 - // Combine logs and reduce function calls
2599 - mlsimport_saas_single_write_import_custom_logs( $log );
2600 - mlsimport_debuglogs_per_plugin( $log );
2601 - print esc_html($log);
2602 -
2603 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
2604 -
2605 - $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data );
2606 -
2607 -
2608 -
2609 - // Explicitly unset large variables after each batch
2610 - unset($api_call_array);
2611 -
2612 - // Force garbage collection again after processing
2613 - wp_cache_flush();
2614 - gc_collect_cycles();
2615 -
2616 -
2617 - // Add a small delay to allow memory to be freed
2618 - if (($key + 1) < $total_batches) {
2619 - usleep(100000); // 100ms pause between batches
2620 - }
2621 -
2622 - } else {
2623 -
2624 - $final_mem_usage = memory_get_usage( true );
2625 - $final_mem_usage_show = round( $final_mem_usage / 1048576, 2 );
2626 -
2627 -
2628 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2629 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2630 -
2631 - //new stats
2632 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_batches', $total_batches );
2633 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_memory', $final_mem_usage_show );
2634 -
2635 -
2636 - mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' );
2637 - mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' );
2638 - break; // Exit the loop if forced to stop
2639 - }
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
2640 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 );
2641 3386
2642 - mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL );
2643 - mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL );
2644 -
2645 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2646 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2647 -
2648 - // Final cleanup
2649 - unset($attachments_to_move);
2650 - unset($mlsimport_item_option_data);
2651 - unset($input_arg);
2652 - unset($log);
2653 -
2654 - // One final garbage collection
2655 - wp_cache_flush();
2656 - 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();
2657 3408 }
2658 3409
2659 3410
2660 3411
@@ -2664,71 +3415,71 @@
2664 3415
2665 3416
2666 3417
2667 3418 /**
3419 + * AJAX: poll import status/logs for a task (drives the progress UI).
2668 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).
2669 3424 *
2670 - *
2671 - * update log function
3425 + * @return void Emits JSON then dies.
2672 3426 */
2673 3427 public function mlsimport_logger_per_item() {
2674 - //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 + }
2675 3439 $post_id=0;
2676 3440 if(isset($_POST['post_id'] )){
2677 3441 $post_id = intval( $_POST['post_id'] );
2678 3442 }
2679 3443
2680 - $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true );
2681 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
2682 - $logs = file_get_contents( $path );
2683 -
2684 - //get neww status data
2685 - $current = intval( get_post_meta( $post_id, 'mlsimport_progress_properties', true ) );
2686 - $total = intval( get_post_meta( $post_id, 'mlsimport_progress_batches', true ) );
2687 - $memory = get_post_meta( $post_id, 'mlsimport_progress_memory', true );
2688 - $mlsimport_task_to_import = intval( get_post_meta($post_id, 'mlsimport_task_to_import',true));
2689 -
2690 -
2691 -
2692 - $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) );
2693 - $force_status = get_option( 'mlsimport_force_stop_' . $post_id );
2694 -
2695 - if ( 'no' !== $force_status ) {
2696 - echo wp_json_encode(
2697 - array(
2698 - 'is_done' => 'done',
2699 - 'status' => $status,
2700 - 'logs' => $logs,
2701 - )
2702 - );
2703 - 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' );
2704 3456 }
2705 3457
2706 - if ( '' === $status || 'completed' === $status ) {
2707 - echo wp_json_encode(
2708 - array(
2709 - 'is_done' => 'done',
2710 - 'status' => $status,
2711 - 'logs' => $logs,
2712 - )
2713 - );
2714 - } else {
2715 - // return from log
2716 - echo wp_json_encode(
2717 - array(
2718 - 'is_done' => 'wip',
2719 - 'status' => $status,
2720 - 'logs' => $logs,
2721 - 'mlsimport_progress_properties' => $current,
2722 - 'mlsimport_progress_batches' => $total,
2723 - 'memory' => $memory,
2724 - 'mlsimport_task_to_import'=>$mlsimport_task_to_import,
2725 - '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 ) : '';
2726 3463
2727 - )
2728 - );
2729 - }
2730 - 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 + );
2731 3482 }
2732 3483
2733 3484
2734 3485
@@ -2735,58 +3486,105 @@
2735 3486
2736 3487
2737 3488
2738 3489 /**
3490 + * AJAX: request a force-stop of a running import for one task.
2739 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.
2740 3494 *
2741 - *
2742 - *
2743 - * Force Stop Import
3495 + * @return void Emits JSON success.
2744 3496 */
2745 3497 public function mlsimport_stop_import_per_item() {
2746 -
2747 3498
3499 +
3500 + // CSRF + read the task id.
2748 3501 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2749 3502 $post_id=0;
2750 3503 if(isset($_POST['post_id'] )){
2751 3504 $post_id = intval( $_POST['post_id'] );
2752 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.
2753 3513 update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false );
2754 - // ensure caches are cleared so running processes see the update immediately
2755 - if ( function_exists( 'wp_cache_delete' ) ) {
2756 - wp_cache_delete( 'mlsimport_force_stop_' . $post_id, 'options' );
2757 - }
2758 3514 mlsimport_saas_single_write_import_custom_logs( 'Stopped for ' . $post_id . PHP_EOL );
2759 3515 mlsimport_debuglogs_per_plugin( 'Stopped for ' . $post_id . PHP_EOL );
2760 - wp_send_json_success();
3516 + wp_send_json_success( array( 'accepted' => (bool) $stop['accepted'] ) );
2761 3517 }
2762 3518
2763 3519
2764 3520
2765 - /*
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.
2766 3524 *
2767 - * 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.
2768 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).
2769 3538 *
2770 - *
2771 - *
2772 - **/
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 + }
2773 3547
2774 - public function mlsimport_saas_get_metadata_function() {
2775 - check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
2776 - $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 );
2777 3550
2778 - $values = array();
2779 - $options = get_option( $this->plugin_name . '_admin_options' );
2780 - $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 + }
2781 3565
2782 - $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 );
2783 3568
2784 - 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 + }
2785 3579
2786 - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
2787 - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
2788 - 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'] ) );
2789 3587 }
2790 3588
2791 3589
2792 3590
@@ -2799,17 +3597,24 @@
2799 3597
2800 3598
2801 3599
2802 3600 /**
3601 + * Append a timestamped message to the cron log file.
2803 3602 *
3603 + * Arrays are JSON-encoded; ensures the WP filesystem is initialized before
3604 + * writing (append + exclusive lock).
2804 3605 *
2805 - * write debug logs
3606 + * @param string|array $message Message to log.
3607 + * @return void
2806 3608 */
2807 3609 public function mlsimport_debuglog_cron( $message ) {
3610 + // Encode arrays for readability.
2808 3611 if ( is_array( $message ) ) {
2809 3612 $message = wp_json_encode( $message );
2810 3613 }
3614 + // Prefix with a human-readable timestamp.
2811 3615 $message = date( 'F j, Y, g:i a' ) . ' -> ' . $message;
3616 + // Ensure WP_Filesystem is available (harmless if already set up).
2812 3617 global $wp_filesystem;
2813 3618 if ( empty( $wp_filesystem ) ) {
2814 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2815 3620 WP_Filesystem();
@@ -2814,8 +3619,9 @@
2814 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2815 3620 WP_Filesystem();
2816 3621 }
2817 3622
3623 + // Append to the cron log with an exclusive lock.
2818 3624 $path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log';
2819 3625
2820 3626 file_put_contents( $path, $message, FILE_APPEND | LOCK_EX );
2821 3627 }