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 +2235 -960 5.8.37.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,60 +124,117 @@
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 - }
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 + );
199 + }
105 200
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();
201 + /**
202 + * Register the stylesheets for the admin area.
203 + *
204 + * Enqueues the main admin CSS plus the onboarding and field-selector styles.
205 + *
206 + * @since 1.0.0
207 + */
208 + public function enqueue_styles() {
209 + // Main admin stylesheet.
210 + wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/mlsimport-admin.css', array(), MLSIMPORT_VERSION, 'all' );
211 + // Onboarding wizard styles.
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.
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' );
115 220 }
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 - }
124 221 }
125 222
126 223 /**
224 + * Whether the current request renders the settings page's Connections tab.
127 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.
128 231 *
129 - *
130 - * Register the stylesheets for the admin area.
131 - *
132 - * @since 1.0.0
232 + * @return bool True when the Connections tab is being rendered.
133 233 */
134 - public function enqueue_styles() {
135 - wp_enqueue_style( $this->plugin_name, plugin_dir_url( __FILE__ ) . 'css/mlsimport-admin.css', array(), $this->version, 'all' );
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'] ) ) : '' );
136 237 }
137 238
138 239
139 240
@@ -138,31 +239,93 @@
138 239
139 240
140 241
141 242 /**
243 + * Register the JavaScript for the admin area.
142 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.
143 249 *
144 - *
145 - * Register the JavaScript for the admin area.
146 - *
250 + * @param string $hook_suffix Current admin page hook suffix.
147 251 * @since 1.0.0
148 252 */
149 253 public function enqueue_scripts($hook_suffix) {
254 + // jQuery UI autocomplete backs the MLS-name search box.
150 255 wp_enqueue_script( 'jquery-ui-autocomplete' );
256 + // Pull the cached MLS list (used later for the autocomplete bootstrap).
151 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.
152 289 wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true );
153 290 wp_localize_script(
154 291 'mlsimport-admin',
155 292 'mlsimport_vars',
156 293 array(
157 - 'ajax_url' => admin_url( 'admin-ajax.php' )
294 + 'ajax_url' => admin_url( 'admin-ajax.php' ),
295 + 'provider_families' => $provider_browser_config,
158 296 )
159 297 );
160 298
161 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
162 - isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' &&
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 + );
315 +
316 +
317 +
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' &&
163 325 isset($_GET['tab']) && $_GET['tab'] === 'field_options') {
164 - $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 );
165 328 if ( 'yes' !== $mlsimport_mls_metadata_populated ) {
166 329 $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
167 330 wp_add_inline_script('mlsimport-admin', $inline_script);
168 331 }
@@ -167,17 +330,145 @@
167 330 wp_add_inline_script('mlsimport-admin', $inline_script);
168 331 }
169 332 }
170 333
171 - if ('toplevel_page_mlsimport_plugin_options' === $hook_suffix &&
172 - ( isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && isset($_GET['tab']) && $_GET['tab'] === 'display_options') ||
173 - (isset($_GET['page']) && $_GET['page'] === 'mlsimport_plugin_options' && !isset($_GET['tab']) ) ) {
174 -
175 - $mls_import_list = mlsimport_saas_request_list();
176 - $inline_script = 'jQuery(document).ready(function($){ var autofill='.wp_kses_post($mls_import_list).';mlsimport_autocomplte_mls_selection(autofill); });';
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', '' );
347 + if ('yes' !== $mlsimport_mls_metadata_populated) {
348 + $inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });';
177 349 wp_add_inline_script('mlsimport-admin', $inline_script);
350 + }
178 351 }
179 352
353 +
354 +
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 + );
443 + }
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 +
180 471 }
181 472
182 473
183 474
@@ -183,16 +474,18 @@
183 474
184 475
185 476
186 477 /**
478 + * Register the administration menu for this plugin into the WordPress Dashboard menu.
187 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.
188 483 *
189 - *
190 - * Register the administration menu for this plugin into the WordPress Dashboard menu.
191 - *
192 484 * @since 1.0.0
193 485 */
194 486 public function add_plugin_admin_menu() {
487 + // Top-level settings menu (capability: administrator).
195 488 add_menu_page(
196 489 esc_html__( 'MLS Import Settings', 'mlsimport'),
197 490 esc_html__( 'MLS Import Settings', 'mlsimport' ),
198 491 'administrator',
@@ -198,28 +491,222 @@
198 491 'administrator',
199 492 'mlsimport_plugin_options',
200 493 array( $this, 'display_plugin_setup_page' ),
201 494 MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png',
202 - 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
203 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 + }
204 537 }
205 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 + }
206 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;
207 568
569 + // The MLS-logo control opens the native WordPress media modal (wp.media).
570 + wp_enqueue_media();
208 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' );
209 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 + }
210 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 + }
211 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 + }
212 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 +
213 683 /**
684 + * Renders the Import History admin page.
214 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.
215 702 *
216 - *
217 - * Add settings action link to the plugins page.
218 - *
703 + * @param array $links Existing plugin action links.
704 + * @return array Links with the Settings link prepended.
219 705 * @since 1.0.0
220 706 */
221 707 public function add_action_links( $links ) {
708 + // Build the Settings link and place it before the default action links.
222 709 $settings_link = array(
223 710 '<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>',
224 711 );
225 712 return array_merge( $settings_link, $links );
@@ -232,15 +719,16 @@
232 719
233 720
234 721
235 722 /**
723 + * Render the main settings page for this plugin.
236 724 *
725 + * Loads the admin-display partial (whose filename is prefixed with the slug).
237 726 *
238 - * Render the settings page for this plugin.
239 - *
240 727 * @since 1.0.0
241 728 */
242 729 public function display_plugin_setup_page() {
730 + // Delegates the whole page to the slug-prefixed admin-display partial.
243 731 include_once 'partials/' . $this->plugin_name . '-admin-display.php';
244 732 }
245 733
246 734
@@ -248,16 +736,29 @@
248 736
249 737
250 738
251 739 /**
740 + * Sanitize/whitelist the main plugin options on save (register_setting callback).
252 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.
253 745 *
254 - * Validate plugin options fields
255 - *
746 + * @param array $input Raw submitted options.
747 + * @return array Whitelisted, escaped options.
256 748 * @since 1.0.0
257 749 */
258 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 + : '';
259 758
759 + // Whitelist of accepted option keys (value = label/help metadata, unused
760 + // beyond documentation here); anything not listed is dropped on save.
260 761 $valid = array();
261 762 $settings_list = array(
262 763 'auth_username' => array(
263 764 'name' => esc_html__( 'Api auth_username ', 'mlsimport' ),
@@ -287,13 +788,13 @@
287 788 'name' => esc_html__( 'title_format', 'mlsimport' ),
288 789 'details' => 'to be added',
289 790 ),
290 791 'mlsimport_username' => array(
291 - 'name' => esc_html__( 'MLSImport Username', 'mlsimport' ),
792 + 'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ),
292 793 'details' => 'to be added',
293 794 ),
294 795 'mlsimport_password' => array(
295 - 'name' => esc_html__( 'MLSImport Password', 'mlsimport' ),
796 + 'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ),
296 797 'details' => 'to be added',
297 798 ),
298 799 'mlsimport_mls_name' => array(
299 800 'name' => esc_html__( 'MLSImport Name', 'mlsimport' ),
@@ -308,18 +809,28 @@
308 809 'name' => esc_html__( 'MLSImport Tresle Client id', 'mlsimport' ),
309 810 'details' => 'to be added',
310 811 ),
311 812
312 - 'mlsimport_tresle_client_secret' => array(
313 - 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
314 - 'details' => 'to be added',
315 - ),
813 + 'mlsimport_tresle_client_secret' => array(
814 + 'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ),
815 + 'details' => 'to be added',
816 + ),
316 817
317 - 'mlsimport_rapattoni_client_id' => array(
318 - 'name' => esc_html__( 'MLSImport Rapattoni Client id','mlsimport'),
319 - 'details' => 'to be added',
320 - ),
818 + 'mlsimport_connectmls_username' => array(
819 + 'name' => esc_html__( 'MLSImport ConnectMLS Username', 'mlsimport' ),
820 + 'details' => 'to be added',
821 + ),
321 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 +
322 833 'mlsimport_rapattoni_client_secret' => array(
323 834 'name' => esc_html__( 'MLSImport Rapattoni Secret', 'mlsimport' ),
324 835 'details' => 'to be added',
325 836 ),
@@ -342,9 +853,27 @@
342 853 'mlsimport_paragon_client_secret' => array(
343 854 'name' => esc_html__( 'MLSImport Paragon Secret', 'mlsimport' ),
344 855 'details' => 'to be added',
345 856 ),
857 + 'mlsimport_realtorca_client_id' => array(
858 + 'name' => esc_html__( 'MLSImport Realtor.ca Client id','mlsimport' ),
859 + 'details' => 'to be added',
860 + ),
346 861
862 + 'mlsimport_realtorca_client_secret' => array(
863 + 'name' => esc_html__( 'MLSImport Realtor.ca Secret', 'mlsimport' ),
864 + 'details' => 'to be added',
865 + ),
866 + 'mlsimport_brightmls_client_id' => array(
867 + 'name' => esc_html__( 'MLSImport BrightMLS Client id', 'mlsimport' ),
868 + 'details' => 'to be added',
869 + ),
870 +
871 + 'mlsimport_brightmls_client_secret' => array(
872 + 'name' => esc_html__( 'MLSImport BrightMLS Secret', 'mlsimport' ),
873 + 'details' => 'to be added',
874 + ),
875 +
347 876 'mlsimport_theme_used' => array(
348 877 'name' => esc_html__( 'Your Wordpress Theme', 'mlsimport' ),
349 878 'details' => 'to be added',
350 879 ),
@@ -357,15 +886,37 @@
357 886 'details' => 'to be added',
358 887 ),
359 888 );
360 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.
361 894 foreach ( $settings_list as $key => $setting ) {
362 - $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 + }
363 902 }
364 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.
365 915 delete_option( 'mlsimport_connection_test' );
366 - delete_option( 'mlsimport_mls_metadata_populated' );
916 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id );
367 917
918 + // Reset cached encoding and drop cached token/schema transients.
368 919 update_option( 'mlsimport_encoding_array', '' );
369 920 delete_transient( 'mlsimport_token_request' );
370 921 delete_transient( 'mlsimport_schema' );
371 922 delete_transient( 'mlsimport_plugin_data_schema' );
@@ -378,49 +929,24 @@
378 929
379 930
380 931
381 932 /**
933 + * Validate the MLS-sync option group on save (register_setting callback).
382 934 *
935 + * Copies a fixed whitelist of sync/import parameter keys straight through.
383 936 *
384 - * Validate admin fields
385 - *
937 + * @param array $input Raw submitted sync settings.
938 + * @return array Whitelisted sync settings.
386 939 * @since 1.0.0
387 940 */
388 - public function validate_admin_fields_select( $input ) {
389 - $valid = array();
390 -
391 - $mlsimport_mls_metadata_mls_data = get_option( 'mlsimport_mls_metadata_mls_data', '' );
392 - $metadata_api_call = json_decode( $mlsimport_mls_metadata_mls_data, true );
393 -
394 - foreach ( $metadata_api_call as $key => $value ) {
395 - if ( isset( $input['mls-fields'][ $key ] ) ) {
396 - $valid['mls-fields'][ $key ] = esc_attr( $input['mls-fields'][ $key ] );
397 - }
398 -
399 - if ( isset( $input['mls-fields-admin'][ $key ] ) ) {
400 - $valid['mls-fields-admin'][ $key ] = esc_attr( $input['mls-fields-admin'][ $key ] );
401 - $valid['mls-fields-label'][ $key ] = esc_attr( $input['mls-fields-label'][ $key ] );
402 - $valid['mls-fields-map-postmeta'][ $key ] = esc_attr( $input['mls-fields-map-postmeta'][ $key ] );
403 - $valid['mls-fields-map-taxonomy'][ $key ] = esc_attr( $input['mls-fields-map-taxonomy'][ $key ] );
404 -
405 - }
406 - }
407 - $valid['mls-fields-admin']['force_rand'] = esc_attr( $input['mls-fields-admin']['force_rand'] );
408 - return $valid;
409 - }
410 -
411 - /**
412 - *
413 - *
414 - * Validate Mls Sync fields
415 - *
416 - * @since 1.0.0
417 - */
418 941 public function validate_admin_mls_sync( $input ) {
419 942 $valid = array();
420 943
421 - $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',
422 947 'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' );
948 + // Pass each whitelisted key through unchanged.
423 949 foreach ( $field_import as $key ) {
424 950 $valid[ $key ] = $input[ $key ];
425 951 }
426 952
@@ -427,110 +953,40 @@
427 953 return $valid;
428 954 }
429 955
430 956
431 - /**
432 - *
433 - *
434 - * Validate Administrative options
435 - *
436 - * @since 1.0.0
437 - */
438 - public function validate_administrative_options( $input ) {
439 957
440 - $valid = array();
441 -
442 - $field_import = array( 'import' );
443 - foreach ( $field_import as $key ) {
444 - $valid[ $key ] = $input[ $key ];
445 - }
446 -
447 - return $valid;
448 - }
449 -
450 958 /**
451 - *
452 - *
453 - *
454 - * Validate Import Options fields
455 - *
456 - * @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.
457 961 */
458 - public function validate_admin_import_options( $input ) {
459 - $valid = array();
460 -
461 - $field_import = array( 'import_number' );
462 - foreach ( $field_import as $key ) {
463 - $valid[ $key ] = intval( $input[ $key ] );
464 - }
465 -
466 - if ( isset( $input['import'] ) && '' !== $input['import'] ) {
467 - $decode = json_decode( $input['import'] );
468 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
469 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
470 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
471 - update_option( 'mlsimport_admin_use_transients', $decode['mlsimport_admin_use_transients'] );
472 - }
473 -
474 - return $valid;
475 - }
476 -
477 -
478 -
479 -
480 -
481 -
482 - /**
483 - *
484 - *
485 - * plugin options update
486 - */
487 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.
488 965 register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) );
489 - register_setting( $this->plugin_name . '_admin_fields_select', $this->plugin_name . '_admin_fields_select', array( $this, 'validate_admin_fields_select' ) );
490 966 register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) );
491 - register_setting( $this->plugin_name . '_admin_import_options', $this->plugin_name . '_admin_import_options', array( $this, 'validate_admin_import_options' ) );
492 - 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.
493 969 }
494 970
495 -
496 -
497 971 /**
498 - *
499 - *
500 - *
501 - *
502 - *
503 - *
504 - *
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.
505 974 */
506 - public function update_option_mlsimport_administrative_options() {
507 - $import = get_option( 'mlsimport_administrative_options' );
508 - if ( '' !== $import ) {
509 - $decode = json_decode( $import['import'], true );
510 - update_option( 'mlsimport_admin_fields_select', $decode['mlsimport_admin_fields_select'] );
511 - update_option( 'mlsimport_admin_mls_sync', $decode['mlsimport_admin_mls_sync'] );
512 - update_option( 'mlsimport_admin_import_options', $decode['mlsimport_admin_import_options'] );
513 - }
514 - }
515 -
516 - /**
517 - *
518 - *
519 - * plugin options update
520 - */
521 975 public function update_option_mlsimport_admin_fields_select() {
522 976
977 + // Delegate to the theme adapter to sync its custom fields.
523 978 $this->env_data->enviroment_custom_fields( $this->plugin_name );
524 979 }
525 980
526 981
527 982 /**
983 + * Register the "Hidden Fields" metabox on the theme's property post type.
528 984 *
529 - *
530 - * plugin options update
985 + * Only added when the theme adapter exposes get_property_post_type().
531 986 */
532 987 public function mlsimport_meta_options() {
988 + // Add the metabox to whatever post type the active theme uses for listings.
533 989 if ( method_exists( $this->env_data, 'get_property_post_type' ) ) {
534 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' );
535 991 }
536 992 }
@@ -535,31 +991,80 @@
535 991 }
536 992 }
537 993
538 994 /**
995 + * Render the "Hidden Fields" metabox for a single property post.
539 996 *
540 - *
541 - * 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.
542 1000 */
543 1001 public function mlsimport_hidden_fields() {
544 1002 global $post;
545 - $options = get_option( $this->plugin_name . '_admin_fields_select' );
546 - echo 'ListingKey: '.get_post_meta( $post->ID, 'ListingKey', true ).'<br>';
547 - foreach ( $options['mls-fields-admin'] as $key => $value ) {
1003 +
1004 + // The active projection keeps disappeared MLS fields out of the metabox.
1005 + $options = mlsimport_active_field_configuration();
1006 +
1007 + // Which Import Task created / last updated this property, and its RESO key.
1008 + $MLSimport_item_inserted = get_post_meta( $post->ID, 'MLSimport_item_inserted', true );
1009 + $MLSimport_item_updated = get_post_meta( $post->ID, 'MLSimport_item_updated', true );
1010 + $listing_key = get_post_meta( $post->ID, '_mlsimport_listing_key', true );
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;
1016 +
1017 + // Check if the ListingKey exists
1018 + if ( !empty( $listing_key ) ) {
1019 + echo 'ListingKey: ' . esc_html( $listing_key ) . '<br>';
1020 + }
1021 +
1022 + // Check if MLSimport_item_inserted exists
1023 + if ( !empty( $MLSimport_item_inserted ) ) {
1024 + echo 'Added via MLS item id: ' . esc_html( $MLSimport_item_inserted ) . ' - ' . esc_html( get_the_title( $MLSimport_item_inserted ) ) . '<br>';
1025 + }
1026 +
1027 + // Check if MLSimport_item_updated exists
1028 + if ( !empty( $MLSimport_item_updated ) ) {
1029 + echo 'Updated via MLS item id: ' . esc_html( $MLSimport_item_updated ) . ' - ' . esc_html( get_the_title( $MLSimport_item_updated ) ) . '<br>';
1030 + }
1031 +
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>';
1036 + } else {
1037 + echo 'Protected statuses: ' . esc_html($mlsImportItemStatusProtect) . '<br>';
1038 + }
1039 + }
1040 +
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).
548 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;
549 1047 if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) {
550 - $key = $options['mls-fields-label'][ $key ];
1048 + $display_label = $options['mls-fields-label'][ $key ];
551 1049 }
552 1050
553 - if ( 'ListingKey' !== $key ) {
554 - $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 );
555 1057 } else {
556 - $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 );
557 1062 }
558 1063 ?>
559 1064
560 - <strong><?php echo esc_html($key);?>:</strong>
561 - <?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>
562 1067 <?php
563 1068 }
564 1069 }
565 1070 ?>
@@ -564,9 +1069,10 @@
564 1069 }
565 1070 ?>
566 1071
567 1072 <h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2>
568 - <?php
1073 + <?php
1074 + // Property change history (only populated when history logging is enabled).
569 1075 $meta = get_post_meta( $post->ID, 'mlsimport_property_history', true );
570 1076 if ( '' === trim( $meta ) ) { ?>
571 1077 <strong>Property history is blank - you can enable it in Settings/ Tools page </strong>
572 1078 <?php
@@ -578,14 +1084,17 @@
578 1084
579 1085
580 1086
581 1087 /**
582 - * 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.
583 1090 */
584 - function mlsimport_delete_cache() {
1091 + function mlsimport_delete_cache() {
585 1092
1093 + // CSRF: Tools-page nonce.
586 1094 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
587 1095
1096 + // Drop every cached token/metadata/schema transient.
588 1097 delete_transient( 'mlsimport_token_request' );
589 1098 delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
590 1099 delete_transient( 'mls_import_meta_enums' );
591 1100 delete_transient( 'mls_import_meta' );
@@ -592,268 +1101,532 @@
592 1101 delete_transient( 'mlsimport_plugin_data_schema' );
593 1102 delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
594 1103 delete_transient( 'mlsimport_saas_token' );
595 1104
596 - delete_option( 'mlsimport_mls_metadata_populated' );
597 - die( 'deleted' );
1105 + // Force a fresh metadata pull next load (current connection only, #275).
1106 + mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated' );
1107 +
1108 + die( 'deleted' );
1109 + }
1110 +
1111 + /**
1112 + * AJAX (Tools page): reset the field-mapping configuration so the field
1113 + * selector starts fresh (also clears the metadata-populated flag).
1114 + */
1115 + function mlsimport_clear_fields_data() {
1116 +
1117 + // CSRF: Tools-page nonce.
1118 + check_ajax_referer( 'mlsimport_tool_actions', 'security' );
1119 +
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' );
1125 +
1126 + die( 'deleted' );
1127 + }
1128 +
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.
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 );
598 1161 }
599 1162
600 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.
601 1167 *
602 - *
603 - *
604 - *
605 - * delete properties
1168 + * @return void Emits a JSON success payload {deleted,remaining,total,done}.
606 1169 */
607 1170 function mlsimport_delete_properties() {
608 - $error = false;
609 1171 global $mlsimport;
610 1172
1173 + // CSRF + capability.
611 1174 check_ajax_referer( 'mlsimport_tool_actions', 'security' );
612 1175
1176 + if ( ! current_user_can( 'administrator' ) ) {
1177 + wp_send_json_error( 'Unauthorized' );
1178 + }
613 1179
614 - if ( current_user_can( 'administrator' ) ) :
615 - $mlsimport_delete_category = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) ) ;
616 - $mlsimport_delete_category_term = sanitize_title( sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category_term']) ) );
617 - $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();
618 1183
619 - if ( '' === $mlsimport_delete_category ) {
620 - $error_message = esc_html__('Category cannot be blank','mlsimport');
621 - $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 ) );
622 1188 }
1189 + }
623 1190
624 - if ( '' === $mlsimport_delete_category_term ) {
625 - $error_message = esc_html__('Category Term cannot be blank','mlsimport');
626 - $error = true;
627 - }
1191 + // Require a taxonomy.
1192 + if ( '' === $taxonomy ) {
1193 + wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) );
1194 + }
628 1195
629 - $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 + }
630 1200
631 - if ( $error ) {
632 - print wp_json_encode(
633 - array(
634 - 'message' => esc_html($error_message),
635 - )
636 - );
637 - } else {
638 - $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();
639 1203
640 - $mlsimport_delete_category_term_array[] = $mlsimport_delete_category_term;
641 - $tax_array = array(
642 - '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,
643 1211 'field' => 'slug',
644 - 'terms' => $mlsimport_delete_category_term_array,
645 - );
1212 + 'terms' => $terms,
1213 + ),
1214 + ),
1215 + 'fields' => 'ids',
1216 + );
646 1217
647 - $args = array(
648 - 'post_type' => array( 'estate_property', 'property' ),
649 - 'post_status' => 'any',
650 - 'paged' => 1,
651 - 'posts_per_page' => -1,
652 - 'tax_query' => array(
653 - $tax_array,
654 - ),
655 - 'fields' => 'ids',
656 - );
1218 + $prop_selection = new WP_Query( $args );
1219 + $deleted = 0;
657 1220
658 - $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 + }
659 1226
660 - foreach ( $prop_selection->posts as $key => $delete_get_id ) {
661 - if ( 0 !== $mlsimport_delete_timeout ) {
662 - set_timeout( $mlsimport_delete_timeout );
663 - }
1227 + // Compute how many still match after this batch; done when none remain.
1228 + $remaining = $prop_selection->found_posts - $deleted;
1229 + $done = ( $remaining <= 0 );
664 1230
665 - $mlsimport->admin->theme_importer->mlsimport_saas_delete_property_via_mysql( $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 );
666 1239 }
1240 + }
1241 + }
667 1242
668 - wp_update_term_count_now( array( $category->term_id ), $mlsimport_delete_category );
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 + ) );
1250 + }
669 1251
670 - print wp_json_encode(
671 - array(
672 - '$category' => $category->term_id,
673 - 'arguments' => $args,
674 - 'posts' => $prop_selection->posts,
675 - 'found' => $prop_selection->found_posts,
676 - 'message' => 'Done...',
677 - )
678 - );
679 - }
680 - endif;
681 - die();
682 - }
683 1252
684 1253
685 1254
686 1255
1256 +
1257 +
1258 +
687 1259 /**
1260 + * Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes.
688 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.
689 1294 *
690 - * 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.
691 1300 */
692 1301 public function mlsimport_saas_setting_up() {
693 - if (intval(WP_MEMORY_LIMIT) < 256): ?>
694 - <div class="mlsimport_warning long_warning">
695 - <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>
696 - </div>
697 - <?php endif; ?>
698 -
1302 + // Do not output warnings during AJAX requests
1303 + if ( ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) ||
1304 + ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
1305 + return;
1306 + }
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>
699 1343 <?php
700 - $max_input_vars = ini_get('max_input_vars');
701 - if ($max_input_vars < 2000):
1344 + }
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 ) {
702 1350 ?>
703 - <div class="mlsimport_warning long_warning">Your <strong>max_input_vars</strong> setting in php is set to <strong><?php echo esc_html($max_input_vars); ?></strong>. When importing real estate listings from an MLS, we work with a lot of data that needs to be saved. Please increase this value to at least <strong>2000</strong> or higher in order to save all details. Please refer to <a href="https://themezly.com/docs/how-to-increase-the-max-input-vars-limit/" target="_blank">How to Increase the Max Input Vars Limit</a></div>
704 - <?php endif; ?>
705 -
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>
1363 +
706 1364 <?php
707 - $max_time = ini_get('max_execution_time');
708 - if ($max_time < 600 && 0 !== $max_time):
709 - ?>
710 - <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>
711 - <?php endif;
712 -
1365 + }
713 1366 }
714 1367
715 - /**
716 - * Check if token validates with MLS
717 - *
718 - * @since 4.0.1
719 - * returns token fron mlsimport
720 - */
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 + */
721 1378 public function mlsimport_saas_check_mls_connection() {
722 1379
723 - $values = array();
724 - $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 );
725 1389
726 - $mls_id = '';
727 - if ( isset( $options['mlsimport_mls_name'] ) ) {
728 - $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 + );
729 1398 }
730 1399
731 - $mls_token = '';
732 - if ( isset( $options['mlsimport_mls_name'] ) ) {
733 - $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 + );
734 1408 }
1409 + $values = $payload_result['payload'];
735 1410
736 - $mlsimport_tresle_client_id = '';
737 - if ( isset( $options['mlsimport_tresle_client_id'] ) ) {
738 - $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 );
739 1422 }
740 1423
741 - $mlsimport_tresle_client_secret = '';
742 - if ( isset( $options['mlsimport_tresle_client_secret'] ) ) {
743 - $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 );
744 1430 }
1431 + // The stable not_entitled rejection marks THIS connection only; the error
1432 + // itself still returns to the caller below, so it is never silent.
1433 + if ( mlsimport_response_not_entitled( $answer ) ) {
1434 + mlsimport_mark_connection_not_entitled( (int) $mls_id );
1435 + }
745 1436
746 - // rapattoni data
747 - $mlsimport_rapattoni_client_id = '';
748 - if ( isset( $options['mlsimport_rapattoni_client_id'] ) ) {
749 - $mlsimport_rapattoni_client_id = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_id'] ) );
1437 +
1438 +
1439 +
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' );
750 1450 }
751 - $mlsimport_rapattoni_client_secret = '';
752 - if ( isset( $options['mlsimport_rapattoni_client_secret'] ) ) {
753 - $mlsimport_rapattoni_client_secret = sanitize_text_field( trim( $options['mlsimport_rapattoni_client_secret'] ) );
754 - }
755 1451
756 - $mlsimport_rapattoni_username = '';
757 - if ( isset( $options['mlsimport_rapattoni_username'] ) ) {
758 - $mlsimport_rapattoni_username = sanitize_text_field( trim( $options['mlsimport_rapattoni_username'] ) );
759 - }
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 );
760 1456
761 - $mlsimport_rapattoni_password = '';
762 - if ( isset( $options['mlsimport_rapattoni_password'] ) ) {
763 - $mlsimport_rapattoni_password = sanitize_text_field( trim( $options['mlsimport_rapattoni_password'] ) );
764 - }
1457 + return $answer;
1458 + }
765 1459
766 - // paragon data
767 - $mlsimport_paragon_client_id = '';
768 - if ( isset( $options['mlsimport_paragon_client_id'] ) ) {
769 - $mlsimport_paragon_client_id = sanitize_text_field( trim( $options['mlsimport_paragon_client_id'] ) );
770 - }
771 - $mlsimport_paragon_client_secret = '';
772 - if ( isset( $options['mlsimport_paragon_client_secret'] ) ) {
773 - $mlsimport_paragon_client_secret = sanitize_text_field( trim( $options['mlsimport_paragon_client_secret'] ) );
774 - }
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 + }
775 1473
776 - if ( trim( $mls_token ) === '' ) {
777 - if ( intval( $mls_id ) > 900 && intval( $mls_id ) < 3000 ) {
778 - if ( trim( $mlsimport_tresle_client_id ) === '' || trim( $mlsimport_tresle_client_secret ) === '' ) {
779 - return;
780 - }
781 - } elseif ( intval( $mls_id ) >= 5000 && intval( $mls_id ) < 6000 ) {
782 - if (
783 - trim( $mlsimport_rapattoni_client_id ) === '' ||
784 - trim( $mlsimport_rapattoni_client_secret ) === '' ||
785 - trim( $mlsimport_rapattoni_username ) === '' ||
786 - trim( $mlsimport_rapattoni_password ) === ''
787 - ) {
788 - return;
789 - }
790 - } elseif ( intval( $mls_id ) >= 6000 ) {
791 - if (
792 - trim( $mlsimport_paragon_client_id ) === '' ||
793 - trim( $mlsimport_paragon_client_secret ) === ''
794 - ) {
795 - return;
796 - }
797 - }
798 - }
1474 + $input = array(
1475 + 'reason' => sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) ),
1476 + 'details' => sanitize_textarea_field( wp_unslash( $_POST['details'] ?? '' ) ),
1477 + );
799 1478
800 - $values['mls_token'] = $mls_token;
801 - $values['mls_id'] = $mls_id;
802 - $values['mlsimport_tresle_client_id'] = $mlsimport_tresle_client_id;
803 - $values['mlsimport_tresle_client_secret'] = $mlsimport_tresle_client_secret;
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 + }
804 1489
805 - $values['mlsimport_rapattoni_client_id'] = $mlsimport_rapattoni_client_id;
806 - $values['mlsimport_rapattoni_client_secret'] = $mlsimport_rapattoni_client_secret;
807 - $values['mlsimport_rapattoni_username'] = $mlsimport_rapattoni_username;
808 - $values['mlsimport_rapattoni_password'] = $mlsimport_rapattoni_password;
1490 + wp_send_json_success();
1491 + }
809 1492
810 - $values['mlsimport_paragon_client_id'] = $mlsimport_paragon_client_id;
811 - $values['mlsimport_paragon_client_secret'] = $mlsimport_paragon_client_secret;
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 + }
812 1501
813 - $answer = $this->theme_importer->globalApiRequestSaas( 'clients', $values, 'PATCH' );
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 + }
814 1514
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 + }
815 1528
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 + }
816 1543
1544 + $count = (int) get_option( 'mlsimport_deactivation_count', 0 ) + 1;
1545 + update_option( 'mlsimport_deactivation_count', $count );
817 1546
818 - if ( isset( $answer['succes'] ) && true === $answer['succes'] ) {
819 - if ( isset( $answer['tested'] ) && true === $answer['tested'] ) {
820 - update_option( 'mlsimport_connection_test', 'yes' );
821 - } else {
822 - delete_option( 'mlsimport_connection_test' );
823 - delete_option( 'mlsimport_mls_metadata_populated' );
824 - }
825 - } else {
826 - delete_option( 'mlsimport_connection_test' );
827 - delete_option( 'mlsimport_mls_metadata_populated' );
828 - }
1547 + $reason = (string) ( $input['reason'] ?? '' );
1548 + $options = $this->get_exit_survey_options();
829 1549
830 - return $answer;
831 - }
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 + }
832 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 + }
833 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 + }
834 1602
835 1603
836 1604
837 1605
1606 +
1607 +
838 1608 /**
839 - * 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.
840 1611 *
841 1612 * @since 4.0.1
842 - * returns token fron mlsimport
1613 + * @return string|array The token string, or the raw answer/'' on failure.
843 1614 */
844 1615 public function mlsimport_saas_get_mls_api_token_from_transient() {
845 1616
1617 + // Prefer the cached token.
846 1618 $token = get_transient( 'mlsimport_saas_token' );
847 1619
1620 + // Cache miss/empty: request a new token and cache it on success.
848 1621 if ( false === $token || '' === $token ) {
849 1622 $token_json_answer = $this->mlsimport_saas_get_mls_api_token();
850 -
851 1623
852 - if ( isset( $token_json_answer['succes'] ) && true === $token_json_answer['succes'] ) {
1624 + if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) {
853 1625 $token = $token_json_answer['token'];
854 1626
855 - set_transient( 'mlsimport_saas_token', $token, 6 * 60 * 60 );
1627 + // 3500s < the token's 1h life, leaving headroom before expiry.
1628 + set_transient( 'mlsimport_saas_token', $token, 3500 );
856 1629 }
857 1630 }
858 1631
859 1632 return $token;
@@ -860,17 +1633,25 @@
860 1633 }
861 1634
862 1635
863 1636 /**
864 - * call for token
1637 + * Request a fresh SaaS API token using the stored account username/password.
865 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 + *
866 1643 * @since 4.0.1
867 - * returns token fron mlsimport
1644 + * @return array|string The 'token' API response, or '' when unconfigured.
868 1645 */
869 1646 protected function mlsimport_saas_get_mls_api_token() {
870 1647 $values = array();
871 1648 $options = get_option( $this->plugin_name . '_admin_options' );
1649 +
1650 + // Check if the MLS provider has changed since the last run
1651 + $prev_mls = get_option( 'mlsimport_prev_mls_name', '' );
872 1652
1653 +
873 1654 $username = '';
874 1655 if ( isset( $options['mlsimport_username'] ) ) {
875 1656 $username = sanitize_text_field( trim( $options['mlsimport_username'] ) );
876 1657 }
@@ -876,9 +1657,11 @@
876 1657 }
877 1658
878 1659 $password = '';
879 1660 if ( isset( $options['mlsimport_password'] ) ) {
880 - $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'] );
881 1664 }
882 1665 $mls_name = '';
883 1666 if ( isset( $options['mlsimport_mls_name'] ) ) {
884 1667 $mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
@@ -888,19 +1671,48 @@
888 1671 if ( isset( $options['mlsimport_mls_token'] ) ) {
889 1672 $mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) );
890 1673 }
891 1674
1675 + // Provider switch detected: purge all cross-provider cached state.
1676 + if ( $prev_mls !== '' && $prev_mls !== $mls_name ) {
1677 + delete_transient( 'mlsimport_token_request' );
1678 + delete_transient( 'mlsimport_metadata_api_call_data_service_property' );
1679 + delete_transient( 'mls_import_meta_enums' );
1680 + delete_transient( 'mls_import_meta' );
1681 + delete_transient( 'mlsimport_plugin_data_schema' );
1682 + delete_transient( 'mlsimport_ready_to_go_mlsimport_data' );
1683 + delete_transient( 'mlsimport_saas_token' );
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.
1689 + delete_option( 'mlsimport_mls_metadata_populated' );
1690 +
1691 + delete_option( 'mlsimport_admin_fields_select' );
1692 + }
1693 +
1694 + // Remember the current MLS so the next call can detect a switch.
1695 + update_option( 'mlsimport_prev_mls_name', $mls_name );
1696 +
1697 +
1698 +
1699 + // Credentials to exchange for a token.
892 1700 $values['username'] = $username;
893 1701 $values['password'] = $password;
894 1702
1703 + // No account credentials -> nothing to request.
895 1704 if ( '' === $username || '' === $password ) {
896 1705 return '';
897 1706 }
898 1707
1708 + // POST to the SaaS 'token' endpoint and return its response.
899 1709 $theme_Start = new ThemeImport();
900 1710 $answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' );
901 1711
902 -
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 );
903 1715
904 1716 return $answer;
905 1717 }
906 1718
@@ -909,13 +1721,14 @@
909 1721
910 1722
911 1723
912 1724 /**
913 - * save meta options
1725 + * Register the "Set Import data" metabox on the mlsimport_item post type.
914 1726 *
915 1727 * @since 3.0.1
916 1728 */
917 1729 public function mlsimport_item_product_metaboxes() {
1730 + // The metabox renders the import-parameter form for an Import Task.
918 1731 add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' );
919 1732 }
920 1733
921 1734
@@ -920,29 +1733,61 @@
920 1733
921 1734
922 1735
923 1736 /**
1737 + * Save the Import Task metabox fields to post meta (save_post callback).
924 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.
925 1743 *
926 - *
927 - * save meta options
928 - *
1744 + * @param int $post_id Post being saved.
1745 + * @param WP_Post $post Post object.
929 1746 * @since 3.0.1
930 1747 */
931 1748 public function mlsimport_item_product_save_metaboxes( $post_id, $post ) {
932 1749
1750 + // Guard against non-post contexts.
933 1751 if ( ! is_object( $post ) || ! isset( $post->post_type ) ) {
934 1752 return;
935 1753 }
936 1754
1755 + // Only handle Import Task posts.
937 1756 if ( 'mlsimport_item' !== $post->post_type ) {
938 1757 return;
939 1758 }
940 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.
941 1785 $allowed_keys = array(
942 1786 'mlsimport_item_how_many',
943 1787 'mlsimport_item_title_format',
944 1788 'mlsimport_item_agent',
1789 + 'mlsimport_item_use_mls_agent',
945 1790 'mlsimport_item_property_status',
946 1791 'mlsimport_item_property_user',
947 1792 'mlsimport_item_min_price',
948 1793 'mlsimport_item_max_price',
@@ -958,31 +1803,37 @@
958 1803 'mlsimport_item_propertytype_check',
959 1804 'mlsimport_item_propertytype',
960 1805 'mlsimport_item_standardstatus_check',
961 1806 'mlsimport_item_standardstatus',
962 - 'mlsimport_item_standardstatusdelete_check',
963 - 'mlsimport_item_standardstatusdelete',
1807 + 'mlsimport_item_standardstatusprotect_check',
1808 + 'mlsimport_item_standardstatusprotect',
964 1809
965 1810 'mlsimport_item_internetentirelistingdisplayyn',
966 1811 'mlsimport_item_internetaddressdisplayyn',
967 1812 'mlsimport_item_stat_cron',
968 - 'mlsimport_item_listagentkey',
969 - 'mlsimport_item_listagentmlsid',
970 - 'mlsimport_item_listofficekey',
971 - 'mlsimport_item_postalcode',
972 - 'mlsimport_item_listofficemlsid',
973 - 'mlsimport_item_listingid',
974 - 'mlsimport_item_extracity',
975 - 'mlsimport_item_extracounty',
976 - 'mlsimport_item_exclude_listofficemlsid',
977 - 'mlsimport_item_exclude_listofficekey',
978 - 'mlsimport_item_exclude_listagentmlsid',
979 - 'mlsimport_item_exclude_listagentkey',
980 - );
1813 + 'mlsimport_item_listagentkey',
1814 + 'mlsimport_item_listagentmlsid',
1815 + 'mlsimport_item_buyeragentmlsid',
1816 + 'mlsimport_item_listofficekey',
1817 + 'mlsimport_item_postalcode',
1818 + 'mlsimport_item_listofficemlsid',
1819 + 'mlsimport_item_listingid',
1820 + 'mlsimport_item_listingkey',
1821 + 'mlsimport_item_extracity',
1822 + 'mlsimport_item_extracounty',
1823 + 'mlsimport_item_exclude_listofficemlsid',
1824 + 'mlsimport_item_exclude_listofficekey',
1825 + 'mlsimport_item_exclude_listagentmlsid',
1826 + 'mlsimport_item_exclude_listagentkey',
1827 + 'mlsimport_item_customparameters',
1828 + 'mlsimport_item_mlsareamajor',
1829 + 'mlsimport_item_subdivisionname',
1830 + );
981 1831
982 1832
983 1833
984 1834
1835 + // Store each posted key (recursively sanitized; key sanitized too).
985 1836 foreach ( $allowed_keys as $key => $key_value ) {
986 1837 if( isset($_POST[$key_value]) ){
987 1838 $postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ;
988 1839 update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta );
@@ -989,19 +1840,27 @@
989 1840 }
990 1841
991 1842 }
992 1843
1844 + // Keys that must be reset to '' when omitted from the POST (cleared).
993 1845 $blank_keys = array(
1846 + 'mlsimport_item_use_mls_agent',
994 1847 'mlsimport_item_standardstatus',
1848 + 'mlsimport_item_standardstatusprotect',
995 1849 'mlsimport_item_city',
996 1850 'mlsimport_item_countyorparish',
997 1851 'mlsimport_item_propertysubtype',
998 - 'mlsimport_item_propertytype',
999 - 'mlsimport_item_standardstatus',
1000 - 'mlsimport_item_listingid',
1852 + 'mlsimport_item_propertytype',
1853 + 'mlsimport_item_standardstatus',
1854 + 'mlsimport_item_listingid',
1855 + 'mlsimport_item_listingkey',
1856 + 'mlsimport_item_customparameters',
1857 + 'mlsimport_item_mlsareamajor',
1858 + 'mlsimport_item_subdivisionname',
1001 1859
1002 1860 );
1003 1861
1862 + // Reset any whitelisted-blank key that was not submitted this save.
1004 1863 foreach ( $blank_keys as $key ) {
1005 1864 if ( ! isset( $_POST[ $key ] ) ) {
1006 1865 update_post_meta( $post_id, $key, '' );
1007 1866 }
@@ -1011,51 +1870,104 @@
1011 1870 }
1012 1871
1013 1872
1014 1873 /**
1015 - * Display Meta Options
1874 + * Render the Import Task metabox content.
1016 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 + *
1017 1886 * @param WP_Post $post The post object.
1018 1887 */
1019 - public function mlsimport_saas_display_meta_options($post) {
1020 - wp_nonce_field(plugin_basename(__FILE__), 'estate_agent_noncename');
1021 - global $mlsimport;
1022 -
1023 - $postId = $post->ID;
1024 - $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
1025 -
1026 - $mlsimportItemHowMany = esc_html(get_post_meta($postId, 'mlsimport_item_how_many', true));
1027 - $mlsimportItemStatCron = esc_html(get_post_meta($postId, 'mlsimport_item_stat_cron', true));
1028 - $lastDate = get_post_meta($postId, 'mlsimport_last_date', true);
1029 - $status = get_option('mlsimport_force_stop_' . $postId);
1030 - $fieldImport = $this->mlsimport_saas_return_mls_fields();
1031 - $options = get_option('mlsimport_admin_options');
1032 - $mlsimportMlsId = isset($options['mlsimport_mls_name']) && $options['mlsimport_mls_name'] !== ''
1033 - ? intval($options['mlsimport_mls_name'])
1034 - : 0;
1035 -
1036 - $mlsRequest = $this->mlsimport_make_listing_requests($postId);
1037 - if (isset($mlsRequest['success']) && !$mlsRequest['success']) {
1038 - echo '<div class="mlsimport_warning">' . esc_html($mlsRequest['message']) . '</div>';
1039 - }
1040 -
1041 - $foundItems = isset($mlsRequest['results']) ? intval($mlsRequest['results']) : 'none';
1042 - if ($foundItems === 'none') {
1043 - $mlsimport->admin->mlsimport_saas_check_mls_connection();
1044 - esc_html_e('Your Token was expired. Please refresh the page to renew it wait while we renew it.', 'mlsimport');
1045 - }
1046 -
1047 - echo $this->generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport);
1048 - }
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;
1049 1892
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();
1050 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 + }
1051 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 + }
1052 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 + }
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 +
1053 1964 /**
1054 1965 * Generate Meta Options HTML
1055 1966 *
1056 1967 * @param int $postId The post ID.
1057 - * @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.
1058 1970 * @param string $lastDate The last date checked.
1059 1971 * @param string $mlsimportItemHowMany How many items to import.
1060 1972 * @param string $mlsimportItemStatCron The status of the cron job.
1061 1973 * @param int $mlsimportMlsId The MLS import ID.
@@ -1061,11 +1973,40 @@
1061 1973 * @param int $mlsimportMlsId The MLS import ID.
1062 1974 * @param array $fieldImport The fields to import.
1063 1975 * @return string The generated HTML.
1064 1976 */
1065 - private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport) {
1977 + private function generateMetaOptionsHtml($postId, $foundItems, $lastDate, $mlsimportItemHowMany, $mlsimportItemStatCron, $mlsimportMlsId, $fieldImport, $hasError = false) {
1978 +
1979 +
1980 + // Buffer all HTML and return it as a string.
1066 1981 ob_start();
1067 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 +
1068 2009 ?>
1069 2010 <div class="mlsimport_item_search_url" style="display:none;"><?php echo esc_html__('Last date/time we check :', 'mlsimport') . ' ' . esc_html($lastDate); ?></div>
1070 2011 <ul>
1071 2012 <li>1. Set the import parameters.</li>
@@ -1075,22 +2016,64 @@
1075 2016 </ul>
1076 2017
1077 2018 <?php if (is_numeric($foundItems) && $foundItems >= 500): ?>
1078 2019 <div class="mlsimport_notification">
1079 - <?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'); ?>
1080 2021 </div>
1081 2022 <?php endif; ?>
1082 2023
1083 2024 <div class="mlsimport_import_no">
1084 - <?php esc_html_e('We found', 'mlsimport'); ?>
1085 - <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; ?>
1086 2032 </div>
1087 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 +
1088 2070 <fieldset class="mlsimport-fieldset">
1089 2071 <label class="mlsimport-label" for="mlsimport_item_how_many">
1090 2072 <?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?>
1091 2073 </label>
1092 - <input type="text" id="mlsimport_item_how_many" name="mlsimport_item_how_many" value="<?php echo esc_attr($mlsimportItemHowMany); ?>"/>
2074 + <input type="text" id="mlsimport_item_how_many" name="mlsimport_item_how_many"
2075 + class="mlsimport-input mlsimport-2025-input " value="<?php echo esc_attr($mlsimportItemHowMany); ?>"/>
1093 2076 </fieldset>
1094 2077
1095 2078 <fieldset class="mlsimport-fieldset mlsimport_auto_switch">
1096 2079 <?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?>
@@ -1095,22 +2078,25 @@
1095 2078 <fieldset class="mlsimport-fieldset mlsimport_auto_switch">
1096 2079 <?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?>
1097 2080 <label class="mlsimport_switch">
1098 2081 <input type="hidden" value="0" name="mlsimport_item_stat_cron">
1099 - <input type="checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>>
2082 + <input type="checkbox" class="mlsimport-import-checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>>
1100 2083 <span class="slider round"></span>
1101 2084 </label>
1102 2085 </fieldset>
1103 2086
1104 - <?php if ($mlsimportItemStatCron !== ''): ?>
1105 - <div id="mlsimport_item_status"></div>
1106 - <input class="button mlsimport_button" type="button" id="mlsimport-start_item"
1107 - data-post-number="<?php echo intval($foundItems); ?>"
1108 - data-post_id="<?php echo intval($postId); ?>" value="Start Import">
1109 - <input class="button mlsimport_button" type="button" id="mlsimport_stop_item"
1110 - data-post-number="<?php echo intval($foundItems); ?>"
1111 - data-post_id="<?php echo intval($postId); ?>" value="Stop Import">
1112 - <?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; ?>
1113 2099
1114 2100 <input type="hidden" id="mlsimport_item_actions" value="<?php echo esc_attr(wp_create_nonce("mlsimport_item_actions")); ?>"/>
1115 2101 <div class="mlsimport_param_wrapper"><h2><?php esc_html_e('Import Parameters', 'mlsimport'); ?></h2>
1116 2102
@@ -1123,9 +2109,11 @@
1123 2109 <?php esc_html_e('Title Format', 'mlsimport'); ?>
1124 2110 </label>
1125 2111
1126 2112 <p class="mlsimport-exp"><?php esc_html_e('You can use {Address}, {City}, {CountyOrParish}, {StateOrProvince}, {PostalCode}, {PropertyType}, {Bedrooms}, {Bathrooms}, {ListingKey}, {ListingId},{StreetNumberNumeric} or {StreetName}', 'mlsimport'); ?></p>
1127 - <input type="text" id="mlsimport_item_title_format" name="mlsimport_item_title_format" value="<?php echo '' !== $mlsimportItemTitleFormat ? trim(esc_html($mlsimportItemTitleFormat)) : esc_html('{Address},{City},{CountyOrParish},{PropertyType}'); ?>"/>
2113 + <input type="text" id="mlsimport_item_title_format" name="mlsimport_item_title_format"
2114 + class="mlsimport-input mlsimport-2025-input"
2115 + value="<?php echo '' !== $mlsimportItemTitleFormat ? trim(esc_html($mlsimportItemTitleFormat)) : esc_html('{Address},{City},{CountyOrParish},{PropertyType}'); ?>"/>
1128 2116 </fieldset>
1129 2117
1130 2118 <?php
1131 2119 $mlsimportItemAgent = esc_html(get_post_meta($postId, 'mlsimport_item_agent', true));
@@ -1134,9 +2122,9 @@
1134 2122 <fieldset class="mlsimport-fieldset">
1135 2123 <label class="mlsimport-label" for="mlsimport_item_agent">
1136 2124 <?php esc_html_e('Select Agent', 'mlsimport'); ?>
1137 2125 </label>
1138 - <select class="mlsimport-select" name="mlsimport_item_agent" id="mlsimport_item_agent">
2126 + <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_agent" id="mlsimport_item_agent">
1139 2127 <?php
1140 2128 $permitedTags = mlsimport_allowed_html_tags_content();
1141 2129 $selectAgent =$this->theme_importer->mlsimportSaasThemeImportSelectAgent($mlsimportItemAgent);
1142 2130 print wp_kses($selectAgent, $permitedTags);
@@ -1143,8 +2131,23 @@
1143 2131 ?>
1144 2132 </select>
1145 2133 </fieldset>
1146 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 +
1147 2150 <?php
1148 2151 $mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true));
1149 2152 if ('' === $mlsimportItemPropertyStatus) {
1150 2153 $mlsimportItemPropertyStatus = 'publish';
@@ -1154,9 +2157,9 @@
1154 2157 <fieldset class="mlsimport-fieldset">
1155 2158 <label class="mlsimport-label" for="mlsimport_item_property_status">
1156 2159 <?php esc_html_e('Select Property Status on import', 'mlsimport'); ?>
1157 2160 </label>
1158 - <select class="mlsimport-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status">
2161 + <select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status">
1159 2162 <?php foreach ($statusArray as $value): ?>
1160 2163 <option value="<?php echo esc_attr($value); ?>" <?php if ($value === $mlsimportItemPropertyStatus) echo esc_html('selected'); ?>>
1161 2164 <?php echo esc_html($value); ?>
1162 2165 </option>
@@ -1170,9 +2173,9 @@
1170 2173 <fieldset class="mlsimport-fieldset">
1171 2174 <label class="mlsimport-label" for="mlsimport_item_property_user">
1172 2175 <?php esc_html_e('User', 'mlsimport'); ?>
1173 2176 </label>
1174 - <select class="mlsimport-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user">
2177 + <select class="mlsimport-select mlsimport-2025-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user">
1175 2178 <?php
1176 2179 $selectUser = $this->theme_importer->mlsimportSaasThemeImportSelectUser($mlsimportItemPropertyUser);
1177 2180 print wp_kses($selectUser, $permitedTags);
1178 2181 ?>
@@ -1189,33 +2192,44 @@
1189 2192 <fieldset class="mlsimport-fieldset">
1190 2193 <label class="mlsimport-label">
1191 2194 <?php esc_html_e('Price Between', 'mlsimport'); ?>
1192 2195 </label>
1193 - <input type="text" class="mlsimport-select" id="mlsimport_item_min_price" name="mlsimport_item_min_price" value="<?php echo esc_attr($mlsimportItemMinPrice); ?>"> and
1194 - <input type="text" class="mlsimport-select" id="mlsimport_item_max_price" name="mlsimport_item_max_price" value="<?php echo esc_attr($mlsimportItemMaxPrice); ?>">
2196 + <input type="text" class="mlsimport-select mlsimport-input mlsimport-2025-input " id="mlsimport_item_min_price" name="mlsimport_item_min_price" value="<?php echo esc_attr($mlsimportItemMinPrice); ?>"> and
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); ?>">
1195 2198 </fieldset>
1196 2199
1197 2200 <?php
1198 - $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 );
1199 2214
1200 - $mlsId = '';
1201 - if (isset($options['mlsimport_mls_name'])) {
1202 - $mlsId = sanitize_text_field(trim($options['mlsimport_mls_name']));
1203 - }
1204 -
1205 - if ($mlsId > 5000) {
1206 - $fieldImport['PropertyType']['multiple'] = 'no';
1207 - }
1208 -
2215 + // Render one fieldset per import parameter.
1209 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.
1210 2222 $nameCheck = strtolower('mlsimport_item_' . $key . '_check');
1211 2223 $name = strtolower('mlsimport_item_' . $key);
1212 2224
2225 + // Current saved value + select-all flag for this field.
1213 2226 $value = get_post_meta($postId, $name, true);
1214 2227 $valueCheck = get_post_meta($postId, $nameCheck, true);
2228 + // extraCity/extraCounty render as a toggle button, not a plain label.
1215 2229 $extraClass = '';
1216 2230 if ('extraCity' === $key || 'extraCounty' === $key) {
1217 - $extraClass = ' mlsimport_hidden_field_button';
2231 + $extraClass = ' mlsimport_hidden_field_button button mlsimport_button';
1218 2232 }
1219 2233 ?>
1220 2234 <fieldset class="mlsimport-fieldset">
1221 2235 <label class="mlsimport-label <?php echo esc_attr($extraClass); ?>" for="<?php echo esc_attr($name); ?>">
@@ -1225,33 +2239,43 @@
1225 2239 <div class="mlsimport-input-wrapper" style="display:none">
1226 2240 <?php endif; ?>
1227 2241 <p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?>
1228 2242 <?php
2243 + // Whether the "select all" checkbox is currently on.
1229 2244 $isCheckboxAdmin = 0;
1230 2245 if (1 === intval($valueCheck)) {
1231 2246 $isCheckboxAdmin = 1;
1232 2247 }
1233 2248
1234 - $selectAllNone = [
1235 - 'InternetAddressDisplayYN',
1236 - 'InternetEntireListingDisplayYN',
1237 - 'PostalCode',
1238 - 'ListAgentKey',
1239 - 'ListAgentMlsId',
1240 - 'ListOfficeKey',
1241 - 'ListOfficeMlsId',
1242 - 'StandardStatus',
1243 - 'StandardStatusDelete',
1244 - 'ListingId',
1245 - 'extraCity',
1246 - 'extraCounty',
1247 - 'Exclude_ListOfficeKey',
1248 - 'Exclude_ListOfficeMlsId',
1249 - 'Exclude_ListAgentKey',
1250 - 'Exclude_ListAgentMlsId',
1251 - ];
2249 + // Fields that must NOT offer a "select all" checkbox.
2250 + $selectAllNone = [
2251 + 'InternetAddressDisplayYN',
2252 + 'InternetEntireListingDisplayYN',
2253 + 'PostalCode',
2254 + 'ListAgentKey',
2255 + 'ListAgentMlsId',
2256 + 'BuyerAgentMlsId',
2257 + 'ListOfficeKey',
2258 + 'ListOfficeMlsId',
2259 + 'StandardStatus',
2260 + 'ListingId',
2261 + 'ListingKey',
2262 + 'extraCity',
2263 + 'extraCounty',
2264 + 'Exclude_ListOfficeKey',
2265 + 'Exclude_ListOfficeMlsId',
2266 + 'Exclude_ListAgentKey',
2267 + 'Exclude_ListAgentMlsId',
2268 + 'CustomParameters',
2269 + 'MLSAreaMajor',
2270 + 'SubdivisionName',
2271 + ];
1252 2272
1253 - 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) {
1254 2278 $selectAllNone[] = 'PropertyType';
1255 2279 }
1256 2280
1257 2281 if (!in_array($key, $selectAllNone)): ?>
@@ -1259,9 +2283,9 @@
1259 2283 esc_html_e('- Or Select All ', 'mlsimport');
1260 2284
1261 2285 ?>
1262 2286 <input type="hidden" name="<?php echo esc_attr($nameCheck); ?>" value="0"/>
1263 - <input type="checkbox" name="<?php echo esc_attr($nameCheck); ?>" value="1" <?php print esc_attr(checked($isCheckboxAdmin, 1, 0)); ?>/>
2287 + <input type="checkbox" class="mlsimport-import-checkbox" name="<?php echo esc_attr($nameCheck); ?>" value="1" <?php print esc_attr(checked($isCheckboxAdmin, 1, 0)); ?>/>
1264 2288 <?php endif; ?>
1265 2289 </p>
1266 2290
1267 2291 <?php
@@ -1268,8 +2292,9 @@
1268 2292 $permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending'];
1269 2293
1270 2294 if ($field['type'] === 'select'): ?>
1271 2295 <?php
2296 + // Multi-select fields need the multiple attr + [] name.
1272 2297 $multiple = '';
1273 2298 if ('yes' === $field['multiple']) {
1274 2299 $multiple = 'multiple';
1275 2300 $name .= '[]';
@@ -1274,8 +2299,9 @@
1274 2299 $multiple = 'multiple';
1275 2300 $name .= '[]';
1276 2301 }
1277 2302
2303 + // Default StandardStatus to Active when nothing saved.
1278 2304 if ('StandardStatus' === $key && '' === $value) {
1279 2305 $value = ['Active'];
1280 2306 }
1281 2307
@@ -1280,31 +2306,70 @@
1280 2306 }
1281 2307
1282 2308
1283 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 +
1284 2318 // Additional conditions can be placed here.
1285 2319 ?>
1286 - <select class="mlsimport-select" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" <?php echo esc_attr($multiple); ?>>
1287 - <?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): ?>
1288 2326
1289 - <?php if ('' !== $selectKey): ?>
1290 - <option value="<?php echo esc_attr($selectKey); ?>"
1291 - <?php
1292 - if ($key === "StandardStatusDelete" && $value==null ) {
1293 -
1294 - print 'selected';
1295 - }
1296 - ?>
1297 - <?php if (is_array($value) ? in_array($selectKey, $value) : $selectKey === $value) echo 'selected'; ?>>
1298 - <?php echo esc_html($selectKey); ?>
1299 - </option>
1300 - <?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);
1301 2334
1302 - <?php endforeach; ?>
1303 - </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 + }
1304 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 +
1305 2370 <?php elseif ($field['type'] === 'input'): ?>
1306 - <input type="text" class="mlsimport-select" id="<?php echo esc_attr($name); ?>" name="<?php echo esc_attr($name); ?>" value="<?php echo esc_attr($value); ?>">
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); ?>">
1307 2372 <?php endif; ?>
1308 2373 <?php if ('extraCity' === $key || 'extraCounty' === $key): ?>
1309 2374 </div>
1310 2375 <?php endif; ?>
@@ -1312,8 +2377,9 @@
1312 2377 <?php endforeach; ?>
1313 2378
1314 2379 </div>
1315 2380 <?php
2381 + // Return the buffered form markup.
1316 2382 return ob_get_clean();
1317 2383 }
1318 2384
1319 2385
@@ -1320,18 +2386,22 @@
1320 2386
1321 2387
1322 2388
1323 2389
2390 + // Placeholder hook target for injecting additional Import Task fields (no-op).
1324 2391 public function mlsimport_add_extra_fields() {
1325 2392 }
1326 2393
1327 2394 /**
2395 + * Per-field help text override, keyed by MLS + meta field.
1328 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.
1329 2399 *
1330 - *
1331 - *
1332 - *
1333 - *
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
1334 2404 */
1335 2405 function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) {
1336 2406 // 111 - Rae Edmonton
1337 2407
@@ -1343,32 +2413,39 @@
1343 2413 }
1344 2414
1345 2415
1346 2416 /**
2417 + * Return the "last checked" timestamp for an Import Task, seeding it if unset.
1347 2418 *
1348 - *
1349 - * Get Last date
2419 + * @param int $item_id Import Task post id.
2420 + * @return string A 'Y-m-d\TH:i' timestamp.
1350 2421 */
1351 2422 public function mlsimport_saas_get_last_date( $item_id ) {
2423 + // Stored watermark used as the modification-time filter for syncs.
1352 2424 $last_date = get_post_meta( $item_id, 'mlsimport_last_date', true );
1353 2425
2426 + // First run: initialize it.
1354 2427 if ( '' === $last_date ) {
1355 2428 $last_date = $this->mlsimport_saas_update_last_date( $item_id );
1356 2429 }
1357 -
1358 2430 return $last_date;
1359 2431 }
1360 2432
1361 2433
1362 2434 /**
2435 + * Set the Import Task's "last checked" watermark to 2 hours ago and store it.
1363 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.
1364 2439 *
1365 - * Save Last date
2440 + * @param int $item_id Import Task post id.
2441 + * @return string The stored 'Y-m-d\TH:i' timestamp.
1366 2442 */
1367 2443 public function mlsimport_saas_update_last_date( $item_id ) {
1368 2444
2445 + // Current site time minus 2 hours, formatted as an ISO-ish local stamp.
1369 2446 $unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 );
1370 - $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time );
2447 + print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time );
1371 2448 update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save );
1372 2449
1373 2450 return $last_date_to_save;
1374 2451 }
@@ -1374,192 +2451,236 @@
1374 2451 }
1375 2452
1376 2453
1377 2454
1378 - /**
1379 - *
1380 - *
1381 - * Check if there are modified items in the last 2h
1382 - */
1383 - public function mlsimport_saas_start_cron_links_per_item( $item_id ) {
1384 2455
1385 - $last_date = $this->mlsimport_saas_get_last_date( $item_id );
1386 2456
1387 - esc_html_e('we work with','mlsimport').' '. esc_html($last_date ). PHP_EOL;
1388 -
1389 - $mlsrequest = $this->mlsimport_make_listing_requests( $item_id, $last_date );
1390 - $found_items = 0;
1391 - if ( isset( $mlsrequest['results'] ) ) {
1392 - $found_items = intval( $mlsrequest['results'] );
1393 - } else {
1394 - delete_transient( 'mlsimport_saas_token' );
2457 + /**
2458 + * Check and process MLSimport item for modified listings in the last 2 hours.
2459 + * Optimized for memory: logs memory, unsets large arrays, and triggers garbage collection.
2460 + *
2461 + * @param int $item_id
2462 + * @return int Number of listings found in the MLS feed, or 0 on failure.
2463 + */
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;
1395 2479 }
2480 + mlsimport_alert_resolve( $incident );
1396 2481
1397 - print esc_html__('we found ','mlsimport') .esc_html( $found_items ). '</br>' . PHP_EOL;
1398 -
1399 - $attachments_to_move = array();
1400 -
1401 - if ( $found_items > 0 ) {
1402 - $item_id_array = array(
1403 - 'item_id' => $item_id,
1404 - 'how_many' => 0,
1405 - 'max_number' => $found_items,
1406 - 'batch_counter' => 1,
1407 - );
1408 -
1409 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date );
1410 -
1411 - update_post_meta( $item_id, 'mlsimport_spawn_status_cron_job', 'started' );
1412 - update_post_meta( $item_id, 'mlsimport_cron_attach_to_move_' . $item_id, $attachments_to_move );
1413 -
1414 - // save last date for next run
1415 - $this->mlsimport_saas_update_last_date( $item_id );
1416 -
1417 - $attachments_to_send = array(
1418 - 'args' => array(
1419 - 'attachments_to_move' => $item_id,
1420 - 'item_id_array' => $item_id_array,
1421 - ),
1422 - );
1423 -
1424 - $this->mlsimport_background_process_per_item_cron_function( $attachments_to_send['args'] );
1425 -
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;
1426 2492 }
1427 - }
1428 2493
2494 + // This task now holds the slot: that is an attempt, whatever happens
2495 + // next. The stamp is the hourly queue's second key (issue #330), so a
2496 + // task that eats its hour goes to the back of the line even when the
2497 + // run never completes and the success watermark never moves.
2498 + update_post_meta( $item_id, 'mlsimport_last_attempt', wp_date( 'Y-m-d\TH:i' ) );
1429 2499
1430 -
1431 -
1432 - /**
1433 - *
1434 - *
1435 - * Reconciliation log
1436 - */
1437 - public function mlsimport_saas_start_doing_reconciliation() {
1438 - global $mlsimport;
1439 - print 'start';
1440 - $listingKey_in_Local = $this->mlsimport_saas_get_all_meta_values( 'ListingKey' );
1441 -
1442 - if ( empty( $listingKey_in_Local ) ) {
1443 - return;
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
1444 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();
1445 2521
2522 + return (int) $result['found'];
2523 + }
1446 2524
1447 - $mls_data = $this->mlsimport_saas_get_mls_reconciliation_data();
1448 - $listingKey_in_MLS = $mls_data['all_data'];
1449 2525
1450 - if ( empty( $listingKey_in_MLS ) ) {
1451 - return;
1452 - }
1453 2526
1454 - $to_delete = 0;
1455 - $counter = 0;
1456 - foreach ( $listingKey_in_Local as $key => $item ) {
1457 - $listingkey = $item->meta_value;
1458 - $property_id = $item->iD;
1459 - ++$counter;
1460 -
1461 - if ( in_array( $listingkey, $listingKey_in_MLS ) ) {
1462 - print wp_kses_post('</br>'.$listingkey . ' IS FOUND');
1463 - } else {
1464 - $delete_status= get_post_meta( $property_id, 'mlsImportItemStatusDelete', true );
1465 2527
1466 - if($delete_status=='' || $delete_status=='delete'){
1467 - ++$to_delete;
1468 - print wp_kses_post('</br>' .$listingkey. ' ------------------------- NOT FOUND delete: '.$property_id.' <-');
1469 - // $mlsimport->admin->theme_importer->mlsimport_saas_delete_property_via_mysql( $property_id, $listingkey );
1470 - }else{
1471 - print wp_kses_post('</br>' .$listingkey. ' ------------------------- NOT FOUND BUT MARKED AS KEEP: '.$property_id.' / '.$delete_status.'<-');
1472 - }
1473 2528
1474 - }
1475 - }
1476 2529
1477 - print esc_html(' to delete:' .$to_delete);
1478 - return;
1479 - }
2530 +/**
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).
2540 + */
2541 +public function mlsimport_saas_start_doing_reconciliation() {
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();
2546 +}
1480 2547
1481 -
1482 2548 /**
2549 + * Fetch the reconciliation feed (all current ListingKeys) from the SaaS API.
1483 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.
1484 2555 *
1485 - * 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.
1486 2558 */
1487 - public function mlsimport_saas_get_mls_reconciliation_data() {
2559 + public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) {
1488 2560
1489 - $arguments = array();
1490 - $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' );
1491 2564 return $answer;
1492 2565 }
1493 2566
1494 2567 /**
2568 + * Return all published posts' values for a given meta key, with their post ids.
1495 2569 *
1496 - *
1497 - * Reconciliation get local data
2570 + * @param string $key Meta key to fetch.
2571 + * @return array Rows of {meta_value, ID}.
1498 2572 */
1499 - public function mlsimport_saas_get_all_meta_values( $key ) {
1500 - global $wpdb;
2573 + public function mlsimport_saas_get_all_meta_values($key) {
2574 + global $wpdb;
2575 + $result = $wpdb->get_results(
2576 + $wpdb->prepare(
2577 + "
2578 + SELECT pm.meta_value, p.ID
2579 + FROM {$wpdb->postmeta} pm
2580 + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
2581 + WHERE pm.meta_key = %s
2582 + AND p.post_status = 'publish'
2583 + ",
2584 + $key
2585 + ),
2586 + ARRAY_A // Lighter than OBJECT, unless you need objects
2587 + );
2588 + return $result;
2589 + }
1501 2590
1502 - $result = $wpdb->get_results(
1503 - $wpdb->prepare(
1504 - "
1505 - SELECT DISTINCT pm.meta_value,p.iD FROM {$wpdb->postmeta} pm
1506 - LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id
1507 - WHERE pm.meta_key = %s
1508 - AND p.post_status = 'publish'",
1509 - $key
1510 - )
1511 - );
1512 2591
1513 - return $result;
1514 - }
1515 2592
1516 -
1517 -
1518 -
1519 -
1520 - /*
1521 - * Do api Listing Requests
2593 + /**
2594 + * Run a single listings request for an Import Task and return the API result.
1522 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.
1523 2600 *
1524 - *
1525 - *
1526 - * */
1527 - public function mlsimport_make_listing_requests( $item_id, $last_date = '', $skip = '', $top = '' ) {
1528 - $options = get_option( $this->plugin_name . '_admin_options' );
1529 - $mls_id = '';
1530 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1531 - $mls_id = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) );
1532 - }
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 );
1533 2611
1534 - $arguments = $this->mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date, $skip, $top );
1535 -
1536 -
1537 - if (
1538 - $mls_id > 5000 && $mls_id < 6000 &&
1539 - ( ! isset( $arguments['property_type'] ) or
1540 - ( isset( $arguments['property_type'] ) && '' === $arguments['property_type'] ) or
1541 - ( isset( $arguments['property_type'][0] ) && '' === $arguments['property_type'][0] )
1542 - )
1543 - ) {
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'];
1544 2616 return array(
1545 2617 'success' => false,
1546 - 'type' => 'rapattoni',
1547 - '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' ),
1548 2620 );
1549 2621 }
1550 2622
2623 + // Guard against an over-long query string (too many parameters selected).
1551 2624 $potential_leght = strlen( wp_json_encode( $arguments ) );
1552 2625 if ( $potential_leght > 1750 ) {
1553 2626 return array(
1554 2627 'success' => false,
1555 2628 'potential_leght' => $potential_leght,
1556 - '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' ),
1557 2630 );
1558 2631 }
2632 +
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 + }
1559 2643
1560 - $answer = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $arguments, 'POST' );
2644 + //print_r($arguments);
2645 + //print '----------------------------'.PHP_EOL;
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.
1561 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 +
1562 2683 return ( $answer );
1563 2684 }
1564 2685
1565 2686
@@ -1566,26 +2687,38 @@
1566 2687
1567 2688
1568 2689
1569 2690
1570 - /*
1571 - * Create Api query arguments
2691 + /**
2692 + * Assemble the RESO listings query arguments from an Import Task's meta.
1572 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).
1573 2700 *
1574 - *
1575 - *
1576 - *
1577 - * */
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 ) {
1578 2709
1579 - public function mlsimport_saas_make_listing_requests_arguments( $item_id, $last_date = '', $skip = '', $top = '' ) {
1580 -
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.
1581 2713 $options = get_option( $this->plugin_name . '_admin_options' );
1582 - if ( isset( $options['mlsimport_mls_name'] ) ) {
1583 - $mls_id = intval( $options['mlsimport_mls_name'] );
1584 - } else {
2714 + $mls_id = mlsimport_task_mls_id( (int) $item_id );
2715 + if ( $mls_id <= 0 ) {
1585 2716 return '';
1586 2717 }
1587 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).
1588 2721 if ( isset( $options['mlsimport_theme_used'] ) ) {
1589 2722 $theme_id = intval( $options['mlsimport_theme_used'] );
1590 2723 } else {
1591 2724 return '';
@@ -1590,12 +2723,18 @@
1590 2723 } else {
1591 2724 return '';
1592 2725 }
1593 2726
2727 + // Base parameters every request carries.
1594 2728 $values = array();
1595 2729 $values['mls_id'] = $mls_id;
1596 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 + }
1597 2735
2736 + // Pagination (only when a page size was supplied).
1598 2737 if ( '' !== $top ) {
1599 2738 $values['top'] = $top;
1600 2739 $values['skip'] = intval( $skip );
1601 2740 }
@@ -1600,8 +2739,9 @@
1600 2739 $values['skip'] = intval( $skip );
1601 2740 }
1602 2741
1603 2742 // // add price
2743 + // Price range (only when both bounds are set).
1604 2744 $mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true );
1605 2745 $mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true );
1606 2746 if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) {
1607 2747 $values['list_price_min'] = floatval( $mlsimport_item_min_price );
@@ -1613,16 +2753,22 @@
1613 2753
1614 2754 // add county
1615 2755 $values = $this->mls_import_return_multiple_param_value( 'countyorparish', $item_id, 'county_or_parish', $values );
1616 2756
2757 + // add MLSAreaMajor
2758 + $values = $this->mls_import_saas_add_to_parms_input( 'MLSAreaMajor', $item_id, 'mls_area_major', $values );
2759 +
2760 + // add SubdivisionName
2761 + $values = $this->mls_import_saas_add_to_parms_input( 'SubdivisionName', $item_id, 'subdivision_name', $values );
2762 +
1617 2763 // add postal code
1618 2764 $values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values );
1619 2765
1620 2766 // add status
1621 2767
1622 - if ( 111 !== $mls_id ) { // edmonton check
1623 - $values = $this->mls_import_return_multiple_param_value( 'StandardStatus', $item_id, 'status', $values );
1624 - }
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 );
1625 2771
1626 2772 // add property_subtype
1627 2773 $values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values );
1628 2774
@@ -1628,17 +2774,8 @@
1628 2774
1629 2775 // add property_type
1630 2776 $values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values );
1631 2777
1632 - // rapattoni exception
1633 - if ( $mls_id > 5000 ) {
1634 - $values = $this->mls_import_saas_add_to_parms_input( 'PropertyType', $item_id, 'property_type', $values );
1635 - $temp = $values['property_type'];
1636 - $temp = str_replace( ' ', '', $temp );
1637 - $values['property_type'] = array();
1638 - $values['property_type'][] = $temp;
1639 - }
1640 -
1641 2778 // add internet_entirelisting_displayyn
1642 2779 $values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values );
1643 2780
1644 2781 // add internet_address_displayyn
@@ -1643,14 +2780,16 @@
1643 2780
1644 2781 // add internet_address_displayyn
1645 2782 $values = $this->mls_import_saas_add_to_parms_input( 'InternetAddressDisplayYN', $item_id, 'internet_address_displayyn', $values );
1646 2783
1647 - // add ListAgentKey
1648 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentKey', $item_id, 'list_agentkey', $values );
1649 - // add ListAgentKey
1650 - $values = $this->mls_import_saas_add_to_parms_input( 'ListAgentMlsId', $item_id, 'list_agentmlsid', $values );
1651 - // add ListOfficeKey
1652 - $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 );
1653 2792 // add ListOfficeMlsId
1654 2793 $values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeMlsId', $item_id, 'list_officemlsid', $values );
1655 2794
1656 2795 // add ListingId
@@ -1655,8 +2794,12 @@
1655 2794
1656 2795 // add ListingId
1657 2796 $values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values );
1658 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 +
1659 2802 //add Exclude_ListOfficeKey
1660 2803 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values );
1661 2804 // add Exclude_ListOfficeMlsId
1662 2805 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values );
@@ -1666,27 +2809,47 @@
1666 2809 //add Exclude_ListAgentKey
1667 2810 $values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentKey', $item_id, 'exclude_list_agentkey', $values );
1668 2811 // add Exclude_ListAgentMlsId
1669 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 );
1670 2815
1671 -
1672 2816
1673 - if ( '' !== $last_date ) {
1674 - $values['modification_time'] = $last_date;
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() );
1675 2827 }
1676 2828
1677 - return( $values );
2829 + $prepared = $provider->prepare_stored_request( $values, $last_date );
2830 + if ( ! $prepared['success'] ) {
2831 + return array( 'mlsimport_provider_error' => $prepared['error'] );
2832 + }
2833 +
2834 + return $prepared['arguments'];
1678 2835 }
1679 2836
1680 2837
1681 2838
1682 - /*
2839 + /**
2840 + * Copy a single scalar Import Task meta value into the arguments array.
1683 2841 *
1684 - * add input items to parameters array
2842 + * Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name.
1685 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.
1686 2849 */
1687 -
1688 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.
1689 2852 $name = strtolower( 'mlsimport_item_' . $key );
1690 2853 $value = get_post_meta( $post_id, $name, true );
1691 2854 if ( '' !== $value ) {
1692 2855 $all_values[ $new_name ] = $value;
@@ -1695,15 +2858,24 @@
1695 2858 return $all_values;
1696 2859 }
1697 2860
1698 2861
1699 - /*
2862 + /**
2863 + * Copy a multi-value (list) Import Task meta value into the arguments array.
1700 2864 *
1701 - * add list items to parameters array
1702 - *
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.
1703 2875 */
1704 -
1705 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.
1706 2878 $name_check = strtolower( 'mlsimport_item_' . $key . '_check' );
1707 2879 $name = strtolower( 'mlsimport_item_' . $key );
1708 2880
1709 2881 $value = get_post_meta( $post_id, $name, true );
@@ -1748,8 +2920,9 @@
1748 2920 }
1749 2921 }
1750 2922 }
1751 2923
2924 + // Only include the list when "select all" is off and there is a value.
1752 2925 $value_check = get_post_meta( $post_id, $name_check, true );
1753 2926
1754 2927 if ( 0 === intval($value_check) && '' !== $value ) {
1755 2928 $all_values[ $new_name ] = $value;
@@ -1754,9 +2927,9 @@
1754 2927 if ( 0 === intval($value_check) && '' !== $value ) {
1755 2928 $all_values[ $new_name ] = $value;
1756 2929 }
1757 2930
1758 - // status exception
2931 + // status exception: always send status, regardless of the check flag.
1759 2932 if ( 'status' === $new_name ) {
1760 2933 $all_values[ $new_name ] = $value;
1761 2934 }
1762 2935
@@ -1764,23 +2937,27 @@
1764 2937 }
1765 2938
1766 2939
1767 2940
1768 - /*
2941 + /**
2942 + * Build the Import Task field definition list (labels, types, enum values).
1769 2943 *
1770 - * 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.
1771 2949 *
1772 - *
1773 - *
1774 - *
1775 - *
1776 - *
1777 - * */
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 ) {
1778 2954
1779 - 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 );
1780 2958
1781 - $mlsimport_mls_metadata_mls_enums = get_option( 'mlsimport_mls_metadata_mls_enums', '' );
1782 -
2959 + // Warn the user when no metadata is available yet.
1783 2960 if ( '' === $mlsimport_mls_metadata_mls_enums ) {
1784 2961 ?>
1785 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
1786 2963 <?php
@@ -1785,8 +2962,9 @@
1785 2962 <div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div>
1786 2963 <?php
1787 2964 }
1788 2965
2966 + // Decode and reach into the enum container.
1789 2967 $metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true );
1790 2968
1791 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
1792 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
@@ -1791,8 +2969,9 @@
1791 2969 if ( isset( $metadata_api_call_full['global_array'] ) ) {
1792 2970 $metadata_api_call = $metadata_api_call_full['global_array'];
1793 2971 }
1794 2972
2973 + // Extract each enum list as a flat array of option keys (empty if absent).
1795 2974 $city_array = array();
1796 2975 if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) {
1797 2976 $city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] );
1798 2977 }
@@ -1811,41 +2990,33 @@
1811 2990 if ( isset( $metadata_api_call['PropertyEnums']['PropertySubType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertySubType'] ) ) {
1812 2991 $propertysubtype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertySubType'] );
1813 2992 }
1814 2993
1815 - $propertytype_array = array();
1816 - if ( isset( $metadata_api_call['PropertyEnums']['PropertyType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertyType'] ) ) {
1817 - $propertytype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertyType'] );
1818 - }
2994 + $propertytype_array = array();
2995 + if ( isset( $metadata_api_call['PropertyEnums']['PropertyType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertyType'] ) ) {
2996 + $propertytype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertyType'] );
2997 + }
1819 2998
2999 +
1820 3000 $standardstatus_array = array();
1821 - $standardstatus_delete_array=array();
1822 3001 if ( isset( $metadata_api_call['PropertyEnums']['StandardStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['StandardStatus'] ) ) {
1823 3002 $standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
1824 - $standardstatus_delete_array= array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] );
1825 3003 }
1826 3004
1827 3005 // if we do not have standart status
3006 + // Fall back to MlsStatus values when the MLS exposes no StandardStatus.
1828 3007 if ( empty( $standardstatus_array ) ) {
1829 3008 $standardstatus_array = $mlsstatus_array;
1830 - $standardstatus_delete_array = $mlsstatus_array;
1831 -
1832 3009 }
1833 3010
1834 - $permited_status=array('active','active under contract','coming soon','activeundercontract','comingsoon','pending');
1835 - $permited_status_lower = array_map('strtolower', $permited_status);
1836 -
1837 - // Filter out permitted statuses from array1 values
1838 - $standardstatus_delete_array = array_filter($standardstatus_delete_array, function ($value) use ($permited_status_lower) {
1839 - return !in_array(strtolower($value), $permited_status_lower);
1840 - });
1841 -
1842 3011
1843 3012
1844 3013
3014 + // Free-text "extra" inputs render empty; they hold comma-separated values.
1845 3015 $extracounty_values = '';
1846 3016 $extracity_values = '';
1847 3017
3018 + // Ordered field definitions consumed by the Import Task metabox renderer.
1848 3019 $field_import = array(
1849 3020 'City' => array(
1850 3021 'label' => esc_html__( 'Select cities', 'mlsimport' ),
1851 3022 'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ),
@@ -1870,19 +3041,33 @@
1870 3041 'values' => $county_array,
1871 3042 'show_extra_field' => true,
1872 3043 ),
1873 3044
1874 - 'extraCounty' => array(
1875 - 'label' => esc_html__( 'Add extra Counties', 'mlsimport' ),
1876 - 'description' => esc_html__( 'Add extra counties, separated by comma. They need to be written exactly like they are stored in MLS (for example all caps)', 'mlsimport' ),
1877 - 'type' => 'input',
1878 - 'multiple' => 'no',
1879 - 'values' => $extracounty_values,
1880 - ),
3045 + 'extraCounty' => array(
3046 + 'label' => esc_html__( 'Add extra Counties', 'mlsimport' ),
3047 + 'description' => esc_html__( 'Add extra counties, separated by comma. They need to be written exactly like they are stored in MLS (for example all caps)', 'mlsimport' ),
3048 + 'type' => 'input',
3049 + 'multiple' => 'no',
3050 + 'values' => $extracounty_values,
3051 + ),
1881 3052
3053 + 'MLSAreaMajor' => array(
3054 + 'label' => esc_html__( 'MLS Area Major', 'mlsimport' ),
3055 + 'description' => esc_html__( 'Filter listings by MLSAreaMajor.', 'mlsimport' ),
3056 + 'type' => 'input',
3057 + 'multiple' => 'no',
3058 + ),
3059 +
3060 + 'SubdivisionName' => array(
3061 + 'label' => esc_html__( 'Subdivision Name', 'mlsimport' ),
3062 + 'description' => esc_html__( 'Filter listings by SubDivisionName.', 'mlsimport' ),
3063 + 'type' => 'input',
3064 + 'multiple' => 'no',
3065 + ),
3066 +
1882 3067 'PostalCode' => array(
1883 3068 'label' => esc_html__( 'Select Postal Code', 'mlsimport' ),
1884 - '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' ),
1885 3070 'type' => 'input',
1886 3071 'multiple' => 'no',
1887 3072 ),
1888 3073
@@ -1906,14 +3091,14 @@
1906 3091 'type' => 'select',
1907 3092 'multiple' => 'yes',
1908 3093 'values' => $standardstatus_array,
1909 3094 ),
1910 - 'StandardStatusDelete' => array(
1911 - 'label' => esc_html__( 'Delete Statuses', 'mlsimport' ),
1912 - 'description' => __( 'If you edit the Delete Statuses after importing listings, 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' ),
1913 3098 'type' => 'select',
1914 3099 'multiple' => 'yes',
1915 - 'values' => $standardstatus_delete_array,
3100 + 'values' => $standardstatus_array,
1916 3101 ),
1917 3102
1918 3103 'InternetEntireListingDisplayYN' => array(
1919 3104 'label' => esc_html__( 'Internet Entire Listing Display ', 'mlsimport'),
@@ -1940,20 +3125,26 @@
1940 3125 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
1941 3126 'type' => 'input',
1942 3127 'multiple' => 'no',
1943 3128 ),
1944 - 'ListAgentMlsId' => array(
1945 - 'label' => esc_html__( 'ListAgentMlsId', 'mlsimport' ),
1946 - 'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ),
1947 - 'type' => 'input',
1948 - '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',
1949 3146 ),
1950 - 'ListOfficeKey' => array(
1951 - 'label' => esc_html__( 'ListOfficeKey', 'mlsimport' ),
1952 - 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport'),
1953 - 'type' => 'input',
1954 - 'multiple' => 'no',
1955 - ),
1956 3147 'ListOfficeMlsId' => array(
1957 3148 'label' => esc_html__( 'ListOfficeMlsId', 'mlsimport' ),
1958 3149 'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport' ),
1959 3150 'type' => 'input',
@@ -1964,8 +3155,14 @@
1964 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'),
1965 3156 'type' => 'input',
1966 3157 'multiple' => 'no',
1967 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 + ),
1968 3165 'Exclude_ListOfficeMlsId' => array(
1969 3166 'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ),
1970 3167 'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ),
1971 3168 'type' => 'input',
@@ -1990,8 +3187,14 @@
1990 3187 'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentKey ', 'mlsimport'),
1991 3188 'type' => 'input',
1992 3189 'multiple' => 'no',
1993 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 + ),
1994 3197
1995 3198
1996 3199 );
1997 3200 return $field_import;
@@ -2003,268 +3206,280 @@
2003 3206
2004 3207
2005 3208
2006 3209 /**
3210 + * AJAX: kick off a manual import for one Import Task.
2007 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}.
2008 3216 *
2009 - * AYsnc Test
3217 + * @return void Emits JSON.
2010 3218 */
2011 3219 public function mlsimport_move_files_per_item() {
2012 3220 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2013 - $post_id = 0;
2014 - $how_many = 0;
2015 - $max_number = 0;
2016 - if(isset( $_POST['post_id'] )){
2017 - $post_id = intval( $_POST['post_id'] );
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;
3226 +
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 );
2018 3230 }
2019 - if(isset( $_POST['how_many'] )){
2020 - $how_many = intval( $_POST['how_many'] );
2021 - }
2022 - if(isset( $_POST['post_number'] )){
2023 - $max_number = intval( $_POST['post_number'] );
2024 - }
2025 -
2026 - update_option( 'mlsimport_force_stop_' . $post_id, 'no', false );
2027 3231
2028 - $item_id_array = array(
2029 - 'item_id' => $post_id,
2030 - 'how_many' => $how_many,
2031 - 'max_number' => $max_number,
2032 - '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 + )
2033 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 + }
2034 3252
2035 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, '' );
2036 - $attachments_to_move = (array) $this->mlsimport_saas_generate_import_requests_per_item( $item_id_array );
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 );
2037 3255
2038 - update_post_meta( $post_id, 'mlsimport_attach_to_move_' . $post_id, $attachments_to_move );
3256 + $this->mlsimport_enqueue_import_worker( (string) $start['run_id'] );
2039 3257
2040 - $attachments_to_send = array(
2041 - 'args' => array(
2042 - 'attachments_to_move' => $post_id,
2043 - 'item_id_array' => $item_id_array,
2044 - ),
3258 + wp_send_json(
3259 + array(
3260 + 'success' => true,
3261 + 'run_id' => (string) $start['run_id'],
3262 + )
2045 3263 );
2046 -
2047 - mlsimport_saas_single_write_import_custom_logs( 'Preparing the import. Please hold on.' . PHP_EOL );
2048 - mlsimport_debuglogs_per_plugin( 'Preparing the import. Please hold on.' . PHP_EOL );
2049 -
2050 - update_post_meta( $post_id, 'mlsimport_spawn_status', 'started' );
2051 - as_enqueue_async_action( 'mlsimport_background_process_per_item', $attachments_to_send );
2052 - spawn_cron();
2053 -
2054 - unset( $attachments_to_send );
2055 - die();
2056 3264 }
2057 3265
2058 3266 /**
3267 + * Queue the background import worker for an accepted Import Run.
2059 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.
2060 3278 *
2061 - * Processing Cron
3279 + * @param string $run_id Accepted run identity to hand to the worker.
3280 + * @return void
2062 3281 */
2063 - public function mlsimport_background_process_per_item_cron_function( $input_arg ) {
2064 - $log = 'In cron processing function ->' . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL;
2065 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2066 - global $mlsimport;
2067 -
2068 - // Get from MLS Import it the big argument arrat
2069 - $attachments_to_move = get_post_meta( $input_arg['item_id_array']['item_id'], 'mlsimport_cron_attach_to_move_' . $input_arg['item_id_array']['item_id'], true );
2070 -
2071 - $log = 'In processing function mlsimport_cron_attach_to_move_ ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
2072 - mlsimport_saas_single_write_import_custom_logs( $log );
2073 -
2074 -
2075 - // foreach($input_arg['attachments_to_move'] as $key=>$import_link){
2076 - foreach ( $attachments_to_move as $key => $import_arguments ) {
2077 - $GLOBALS['wp_object_cache']->delete( 'mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options' );
2078 -
2079 - $log = PHP_EOL . ' Cron Parsing importing batch : ' . $key . ' = ' . wp_json_encode( $import_arguments ) . PHP_EOL;
2080 - mlsimport_saas_single_write_import_custom_logs( $log, 'cron' );
2081 -
2082 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
2083 -
2084 - $mlsimport->admin->theme_importer->mlsimportSaasCronParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key );
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 );
2085 3300 }
2086 3301
2087 - mlsimport_saas_single_write_import_custom_logs( 'CRON JOB Import Completed ' . PHP_EOL );
2088 - mlsimport_debuglogs_per_plugin( 'CRON JOB Import Completed ' . PHP_EOL );
2089 - update_post_meta( $input_arg['item_id_array']['item_id'], 'mlsimport_spawn_status', 'completed' );
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();
2090 3309 }
2091 3310
2092 3311 /**
3312 + * Return the adapter configuration error that blocks Stored mode imports.
2093 3313 *
2094 - *
2095 - * Generate import Requests per item
3314 + * @return string Empty when a supported adapter was composed.
2096 3315 */
2097 - public function mlsimport_saas_generate_import_requests_per_item( $item_id_array, $last_date = '' ) {
2098 - $import_step = 25;
3316 + public function mlsimport_stored_listing_configuration_error(): string {
3317 + return $this->stored_listing_configuration_error;
3318 + }
2099 3319
2100 - $prop_id = $item_id_array['item_id'];
2101 - $max_found = $item_id_array['max_number'];
2102 - $how_many = $item_id_array['how_many'];
2103 - if ( 0=== intval($how_many) ) {
2104 - $how_many = $max_found;
2105 - }
2106 - if ( $how_many > $max_found ) {
2107 - $how_many = $max_found;
2108 - }
2109 3320
2110 - $search_url_step = '';
2111 - $urls_array = array();
2112 3321
2113 - $skip = 0;
2114 - if ( $how_many > 10000 ) {
2115 - $how_many = 10000;
2116 - }
2117 3322
2118 - if ( $how_many < $import_step ) {
2119 - $import_step = $how_many;
2120 - }
2121 3323
2122 - while ( $skip < $how_many ) {
2123 - $search_url_step = $this->mlsimport_saas_make_listing_requests_arguments( $prop_id, $last_date, $skip, $import_step );
2124 - $skip = $skip + $import_step;
2125 - $urls_array[] = $search_url_step;
2126 - }
2127 - return $urls_array;
2128 - }
2129 3324
2130 3325
2131 3326
2132 3327
2133 3328
3329 +
3330 +
2134 3331 /**
2135 - * 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
2136 3339 */
2137 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 + }
2138 3346
2139 - $mlsimportItemId = $input_arg['item_id_array']['item_id'];
2140 - $log_prefix = 'In processing function - Item ID: ' . $mlsimportItemId . ' -> ';
2141 - mlsimport_saas_single_write_import_custom_logs( $log_prefix . wp_json_encode( $input_arg['item_id_array'] ) . PHP_EOL );
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 + }
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 + );
2142 3373
2143 - //ini_set('memory_limit', '256M');
2144 - //ini_set('max_execution_time', 0); // Unlimited execution time
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
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 );
2145 3386
2146 - // Get from MLS Import the big argument array only once
2147 - $attachments_to_move = get_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId, true );
2148 -
2149 - // Retrieve all meta data in one go to reduce database queries
2150 - $mlsimport_item_option_data = array(
2151 - 'mlsimport_item_standardstatus' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatus', true ),
2152 - 'mlsimport_item_standardstatusdelete' => get_post_meta( $mlsimportItemId, 'mlsimport_item_standardstatusdelete', true ),
2153 - 'mlsimport_item_property_user' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_user', true ),
2154 - 'mlsimport_item_agent' => get_post_meta( $mlsimportItemId, 'mlsimport_item_agent', true ),
2155 - 'mlsimport_item_property_status' => get_post_meta( $mlsimportItemId, 'mlsimport_item_property_status', true ),
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'
2156 3406 );
3407 + gc_collect_cycles();
3408 + }
2157 3409
2158 - $total_batches = count( $attachments_to_move );
2159 3410
2160 - // removed because $this
2161 - global $mlsimport;
2162 3411
2163 - $log = 'In processing function $attachments_to_move ->' . wp_json_encode( $attachments_to_move ) . PHP_EOL;
2164 - mlsimport_saas_single_write_import_custom_logs( $log );
2165 - // mlsimport_debuglogs_per_plugin($log);
2166 - print esc_html($log);
2167 3412
2168 - foreach ( $attachments_to_move as $key => $import_arguments ) {
2169 - // reconsider use
2170 - // $GLOBALS['wp_object_cache']->delete('mlsimport_force_stop_' . $input_arg['item_id_array']['item_id'], 'options');
2171 - $status = get_option( 'mlsimport_force_stop_' . $mlsimportItemId );
2172 - if ( 'no' === $status ) {
2173 - // reconsider use
2174 - // wp_cache_flush();
2175 - $mem_usage = memory_get_usage( true );
2176 - $mem_usage_show = round( $mem_usage / 1048576, 2 );
2177 3413
2178 -
2179 3414
2180 - mlsimport_saas_single_write_import_custom_logs( $log );
2181 - $log = 'Parsing import batch: ' . ( $key + 1 ) . ' of ' . $total_batches . '. Memory used: ' . $mem_usage_show . ' MB.' . PHP_EOL;
2182 3415
2183 - // Combine logs and reduce function calls
2184 - mlsimport_saas_single_write_import_custom_logs( $log );
2185 - mlsimport_debuglogs_per_plugin( $log );
2186 - print esc_html($log);
2187 3416
2188 - $api_call_array = $this->theme_importer->globalApiRequestCurlSaas( 'listings', $import_arguments, 'POST' );
2189 3417
2190 - $mlsimport->admin->theme_importer->mlsimportSaasParseSearchArrayPerItem( $api_call_array, $input_arg['item_id_array'], $key, $mlsimport_item_option_data );
2191 - } else {
2192 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2193 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2194 - mlsimport_saas_single_write_import_custom_logs( PHP_EOL . 'Parsing importing link FORCE STOP : ' );
2195 - mlsimport_debuglogs_per_plugin( 'Parsing importing link FORCE STOP : ' );
2196 - break; // Exit the loop if forced to stop
2197 - }
2198 - }
2199 -
2200 - mlsimport_saas_single_write_import_custom_logs( 'Import Completed ' . PHP_EOL );
2201 - mlsimport_debuglogs_per_plugin( 'Import Completed ' . PHP_EOL );
2202 -
2203 - update_post_meta( $mlsimportItemId, 'mlsimport_spawn_status', 'completed' );
2204 - delete_post_meta( $mlsimportItemId, 'mlsimport_attach_to_move_' . $mlsimportItemId );
2205 -
2206 - unset( $attachments_to_move );
2207 - unset( $api_call_array );
2208 - unset( $input_arg );
2209 - unset( $log );
2210 - unset( $log2 );
2211 - }
2212 -
2213 -
2214 -
2215 -
2216 -
2217 3418 /**
3419 + * AJAX: poll import status/logs for a task (drives the progress UI).
2218 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).
2219 3424 *
2220 - *
2221 - * update log function
3425 + * @return void Emits JSON then dies.
2222 3426 */
2223 3427 public function mlsimport_logger_per_item() {
2224 - 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 + }
2225 3439 $post_id=0;
2226 3440 if(isset($_POST['post_id'] )){
2227 3441 $post_id = intval( $_POST['post_id'] );
2228 3442 }
2229 3443
2230 - $status = get_post_meta( $post_id, 'mlsimport_spawn_status', true );
2231 - $path = WP_PLUGIN_DIR . '/mlsimport/logs/status_logs.log';
2232 - $logs = file_get_contents( $path );
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' );
3456 + }
2233 3457
2234 - $force_status = intval( get_post_meta( $post_id, 'mlsimport_force_stop', true ) );
2235 - $force_status = get_option( 'mlsimport_force_stop_' . $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 ) : '';
2236 3463
2237 - if ( 'no' !== $force_status ) {
2238 - echo wp_json_encode(
2239 - array(
2240 - 'is_done' => 'done',
2241 - 'status' => $status,
2242 - 'logs' => $logs,
2243 - )
2244 - );
2245 - die();
2246 - }
2247 -
2248 - if ( '' === $status || 'completed' === $status ) {
2249 - echo wp_json_encode(
2250 - array(
2251 - 'is_done' => 'done',
2252 - 'status' => $status,
2253 - 'logs' => $logs,
2254 - )
2255 - );
2256 - } else {
2257 - // return from log
2258 - echo wp_json_encode(
2259 - array(
2260 - 'is_done' => 'wip',
2261 - 'status' => $status,
2262 - 'logs' => $logs,
2263 - )
2264 - );
2265 - }
2266 - 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 + );
2267 3482 }
2268 3483
2269 3484
2270 3485
@@ -2271,53 +3486,105 @@
2271 3486
2272 3487
2273 3488
2274 3489 /**
3490 + * AJAX: request a force-stop of a running import for one task.
2275 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.
2276 3494 *
2277 - *
2278 - *
2279 - * Force Stop Import
3495 + * @return void Emits JSON success.
2280 3496 */
2281 3497 public function mlsimport_stop_import_per_item() {
3498 +
3499 +
3500 + // CSRF + read the task id.
2282 3501 check_ajax_referer( 'mlsimport_item_actions', 'security' );
2283 3502 $post_id=0;
2284 3503 if(isset($_POST['post_id'] )){
2285 - $post_id = intval( $_POST['post_id'] );
3504 + $post_id = intval( $_POST['post_id'] );
2286 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.
2287 3513 update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false );
2288 - mlsimport_saas_single_write_import_custom_logs( 'Stopeed for ' . $post_id . PHP_EOL );
2289 - mlsimport_debuglogs_per_plugin( 'Stopeed for ' . $post_id . PHP_EOL );
2290 -
2291 - die();
3514 + mlsimport_saas_single_write_import_custom_logs( 'Stopped for ' . $post_id . PHP_EOL );
3515 + mlsimport_debuglogs_per_plugin( 'Stopped for ' . $post_id . PHP_EOL );
3516 + wp_send_json_success( array( 'accepted' => (bool) $stop['accepted'] ) );
2292 3517 }
2293 3518
2294 3519
2295 3520
2296 - /*
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.
2297 3524 *
2298 - * 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.
2299 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).
2300 3538 *
2301 - *
2302 - *
2303 - **/
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 + }
2304 3547
2305 - public function mlsimport_saas_get_metadata_function() {
2306 - check_ajax_referer( 'mlsimport_saas_get_metadata', 'security' );
2307 - $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 );
2308 3550
2309 - $values = array();
2310 - $options = get_option( $this->plugin_name . '_admin_options' );
2311 - $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 + }
2312 3565
2313 - $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 );
2314 3568
2315 - 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 + }
2316 3579
2317 - update_option( 'mlsimport_mls_metadata_theme_schema', $answer['theme_schema'] );
2318 - update_option( 'mlsimport_mls_metadata_mls_data', $answer['mls_data']['mls_meta_data'] );
2319 - 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'] ) );
2320 3587 }
2321 3588
2322 3589
2323 3590
@@ -2330,17 +3597,24 @@
2330 3597
2331 3598
2332 3599
2333 3600 /**
3601 + * Append a timestamped message to the cron log file.
2334 3602 *
3603 + * Arrays are JSON-encoded; ensures the WP filesystem is initialized before
3604 + * writing (append + exclusive lock).
2335 3605 *
2336 - * write debug logs
3606 + * @param string|array $message Message to log.
3607 + * @return void
2337 3608 */
2338 3609 public function mlsimport_debuglog_cron( $message ) {
3610 + // Encode arrays for readability.
2339 3611 if ( is_array( $message ) ) {
2340 3612 $message = wp_json_encode( $message );
2341 3613 }
3614 + // Prefix with a human-readable timestamp.
2342 3615 $message = date( 'F j, Y, g:i a' ) . ' -> ' . $message;
3616 + // Ensure WP_Filesystem is available (harmless if already set up).
2343 3617 global $wp_filesystem;
2344 3618 if ( empty( $wp_filesystem ) ) {
2345 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2346 3620 WP_Filesystem();
@@ -2345,8 +3619,9 @@
2345 3619 require_once ABSPATH . '/wp-admin/includes/file.php';
2346 3620 WP_Filesystem();
2347 3621 }
2348 3622
3623 + // Append to the cron log with an exclusive lock.
2349 3624 $path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log';
2350 3625
2351 3626 file_put_contents( $path, $message, FILE_APPEND | LOCK_EX );
2352 3627 }