| 1 |
<?php |
| 2 |
if ( ! defined( 'ABSPATH' ) ) { |
| 3 |
exit; // Exit if accessed directly |
| 4 |
} |
| 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 |
*/ |
| 33 |
|
| 34 |
|
| 35 |
/** |
| 36 |
* The admin-specific functionality of the plugin. |
| 37 |
* |
| 38 |
* @link http://mlsimport.com/ |
| 39 |
* @since 1.0.0 |
| 40 |
* |
| 41 |
* @package Mlsimport |
| 42 |
* @subpackage Mlsimport/admin |
| 43 |
*/ |
| 44 |
|
| 45 |
|
| 46 |
/** |
| 47 |
* The admin-specific functionality of the plugin. |
| 48 |
* |
| 49 |
* Defines the plugin name, version, and two examples hooks for how to |
| 50 |
* enqueue the admin-specific stylesheet and JavaScript. |
| 51 |
* |
| 52 |
* @package Mlsimport |
| 53 |
* @subpackage Mlsimport/admin |
| 54 |
* @author MlsImport <office@mlsimport.com> |
| 55 |
*/ |
| 56 |
class Mlsimport_Admin { |
| 57 |
|
| 58 |
/** |
| 59 |
* The ID of this plugin. |
| 60 |
* |
| 61 |
* @since 1.0.0 |
| 62 |
* @access private |
| 63 |
* @var string $plugin_name The ID of this plugin. |
| 64 |
*/ |
| 65 |
private $plugin_name; |
| 66 |
|
| 67 |
/** |
| 68 |
* The version of this plugin. |
| 69 |
* |
| 70 |
* @since 1.0.0 |
| 71 |
* @access private |
| 72 |
* @var string $version The current version of this plugin. |
| 73 |
*/ |
| 74 |
private $version; |
| 75 |
// Back-reference to the core Mlsimport instance (set externally). |
| 76 |
public $main; |
| 77 |
// ThemeImport API client instance (OAuth + all SaaS API calls). |
| 78 |
public $theme_importer; |
| 79 |
// Active theme adapter object (e.g. ResidenceClass); stdClass when no theme. |
| 80 |
public $env_data; |
| 81 |
// Active MLS provider adapter object; stdClass when none configured. |
| 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). |
| 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). |
| 90 |
public $field_import; |
| 91 |
// Map of supported theme_id => human name (990 standalone, 991-994 themes). |
| 92 |
public $themes; |
| 93 |
/** |
| 94 |
* Initialize the class and set its properties. |
| 95 |
* |
| 96 |
* @since 1.0.0 |
| 97 |
* @param string $plugin_name The name of this plugin. |
| 98 |
* @param string $version The version of this plugin. |
| 99 |
*/ |
| 100 |
public function __construct( $plugin_name, $version ) { |
| 101 |
|
| 102 |
// Store the plugin slug (used as the option-key prefix) and version. |
| 103 |
$this->plugin_name = $plugin_name; |
| 104 |
$this->version = $version; |
| 105 |
|
| 106 |
// RESO fields that are enum/lookup-driven and shown on the Import Task form. |
| 107 |
$this->field_import = array( |
| 108 |
'City', |
| 109 |
'CountyOrParish', |
| 110 |
'MlsStatus', |
| 111 |
'PropertySubType', |
| 112 |
'PropertyType', |
| 113 |
'StandardStatus', |
| 114 |
'InternetEntireListingDisplayYN', |
| 115 |
'InternetAddressDisplayYN', |
| 116 |
); |
| 117 |
|
| 118 |
// theme_id => administrator-facing name. Adapter classes are selected by |
| 119 |
// Mlsimport_Stored_Listing_Adapter_Factory, never derived from these labels. |
| 120 |
$this->themes = array( |
| 121 |
990 => 'Standalone', |
| 122 |
991 => 'WpResidence', |
| 123 |
992 => 'Houzez', |
| 124 |
993 => 'RealHomes', |
| 125 |
994 => 'Wpestate', |
| 126 |
); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Return the one shared Import Task execution module for this request. |
| 131 |
* |
| 132 |
* Manual actions, setup, and hourly cron use this method instead of creating |
| 133 |
* separate runners. Lazy creation also keeps ordinary admin page requests |
| 134 |
* from allocating import objects when no import work is requested. |
| 135 |
* |
| 136 |
* @return Mlsimport_Import_Task_Execution Shared execution module. |
| 137 |
*/ |
| 138 |
public function mlsimport_import_task_execution(): Mlsimport_Import_Task_Execution { |
| 139 |
if ( ! $this->import_task_execution instanceof Mlsimport_Import_Task_Execution ) { |
| 140 |
$environment = new Mlsimport_Import_Task_Execution_WordPress_Environment( $this ); |
| 141 |
$this->import_task_execution = new Mlsimport_Import_Task_Execution( $environment ); |
| 142 |
} |
| 143 |
|
| 144 |
return $this->import_task_execution; |
| 145 |
} |
| 146 |
/** |
| 147 |
* Wire up the theme and MLS provider adapter objects for this request. |
| 148 |
* |
| 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. |
| 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. |
| 165 |
* @since 1.0.0 |
| 166 |
*/ |
| 167 |
public function admin_setup( $plugin_name, $mls_enviroment, $theme_enviroment ) { |
| 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. |
| 171 |
$options = get_option( $this->plugin_name . '_admin_options' ); |
| 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(); |
| 187 |
} |
| 188 |
|
| 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 |
} |
| 200 |
|
| 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' ); |
| 220 |
} |
| 221 |
} |
| 222 |
|
| 223 |
/** |
| 224 |
* Whether the current request renders the settings page's Connections tab. |
| 225 |
* |
| 226 |
* The single gate shared by the Connections styles and scripts enqueues. |
| 227 |
* Since the tab consolidation the Connections tab is also the page |
| 228 |
* DEFAULT (no ?tab=) and the retired display_options alias, so the check |
| 229 |
* must go through the tab resolver — a raw $_GET['tab'] comparison would |
| 230 |
* miss both of those URL forms. |
| 231 |
* |
| 232 |
* @return bool True when the Connections tab is being rendered. |
| 233 |
*/ |
| 234 |
private function mlsimport_is_connections_tab_screen(): bool { |
| 235 |
return isset( $_GET['page'] ) && 'mlsimport_plugin_options' === $_GET['page'] |
| 236 |
&& 'connections' === mlsimport_settings_active_tab( isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : '' ); |
| 237 |
} |
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
|
| 242 |
/** |
| 243 |
* Register the JavaScript for the admin area. |
| 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. |
| 249 |
* |
| 250 |
* @param string $hook_suffix Current admin page hook suffix. |
| 251 |
* @since 1.0.0 |
| 252 |
*/ |
| 253 |
public function enqueue_scripts($hook_suffix) { |
| 254 |
// jQuery UI autocomplete backs the MLS-name search box. |
| 255 |
wp_enqueue_script( 'jquery-ui-autocomplete' ); |
| 256 |
// Pull the cached MLS list (used later for the autocomplete bootstrap). |
| 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. |
| 289 |
wp_enqueue_script( 'mlsimport-admin', plugin_dir_url( __FILE__ ) . 'js/mlsimport-admin.js', array( 'jquery' ), $this->version, true ); |
| 290 |
wp_localize_script( |
| 291 |
'mlsimport-admin', |
| 292 |
'mlsimport_vars', |
| 293 |
array( |
| 294 |
'ajax_url' => admin_url( 'admin-ajax.php' ), |
| 295 |
'provider_families' => $provider_browser_config, |
| 296 |
) |
| 297 |
); |
| 298 |
|
| 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' && |
| 325 |
isset($_GET['tab']) && $_GET['tab'] === 'field_options') { |
| 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 ); |
| 328 |
if ( 'yes' !== $mlsimport_mls_metadata_populated ) { |
| 329 |
$inline_script = 'jQuery(document).ready(function($){ mlsimport_saas_get_metadata(); });'; |
| 330 |
wp_add_inline_script('mlsimport-admin', $inline_script); |
| 331 |
} |
| 332 |
} |
| 333 |
|
| 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(); });'; |
| 349 |
wp_add_inline_script('mlsimport-admin', $inline_script); |
| 350 |
} |
| 351 |
} |
| 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 |
|
| 471 |
} |
| 472 |
|
| 473 |
|
| 474 |
|
| 475 |
|
| 476 |
|
| 477 |
/** |
| 478 |
* Register the administration menu for this plugin into the WordPress Dashboard menu. |
| 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. |
| 483 |
* |
| 484 |
* @since 1.0.0 |
| 485 |
*/ |
| 486 |
public function add_plugin_admin_menu() { |
| 487 |
// Top-level settings menu (capability: administrator). |
| 488 |
add_menu_page( |
| 489 |
esc_html__( 'MLS Import Settings', 'mlsimport'), |
| 490 |
esc_html__( 'MLS Import Settings', 'mlsimport' ), |
| 491 |
'administrator', |
| 492 |
'mlsimport_plugin_options', |
| 493 |
array( $this, 'display_plugin_setup_page' ), |
| 494 |
MLSIMPORT_PLUGIN_URL . '/img/mlsimport_menu.png', |
| 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 |
| 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 |
} |
| 537 |
} |
| 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 |
} |
| 552 |
|
| 553 |
/** |
| 554 |
* Enqueue the compiled Standalone Design React bundle and its WP component |
| 555 |
* styles. Dependencies + cache-busting version come from the build's |
| 556 |
* generated index.asset.php. |
| 557 |
* |
| 558 |
* @return void |
| 559 |
*/ |
| 560 |
private function enqueue_standalone_settings_app() { |
| 561 |
// The build emits index.asset.php with dependencies + a content hash; |
| 562 |
// bail quietly if the app was never built. |
| 563 |
$asset_file = MLSIMPORT_PLUGIN_PATH . 'admin/settings-app/build/index.asset.php'; |
| 564 |
if ( ! file_exists( $asset_file ) ) { |
| 565 |
return; |
| 566 |
} |
| 567 |
$asset = require $asset_file; |
| 568 |
|
| 569 |
// The MLS-logo control opens the native WordPress media modal (wp.media). |
| 570 |
wp_enqueue_media(); |
| 571 |
|
| 572 |
// wp-color-picker (Iris) powers the native colour control in the React app; |
| 573 |
// it pulls in jQuery + iris, so the window.jQuery global is available. |
| 574 |
wp_enqueue_script( |
| 575 |
'mlsimport-standalone-settings', |
| 576 |
MLSIMPORT_PLUGIN_URL . 'admin/settings-app/build/index.js', |
| 577 |
array_merge( $asset['dependencies'], array( 'wp-color-picker' ) ), |
| 578 |
$asset['version'], |
| 579 |
true |
| 580 |
); |
| 581 |
// Enable JS translation loading for the app's strings. |
| 582 |
wp_set_script_translations( 'mlsimport-standalone-settings', 'mlsimport' ); |
| 583 |
|
| 584 |
// The field tree the React app renders from — tabs/sub-tabs/fields generated |
| 585 |
// from the ONE registry (mlsimport_standalone_settings_app_config). The app |
| 586 |
// reads window.mlsimportFields instead of a hard-coded list, so a field added |
| 587 |
// to the registry appears here (and, via the schema, in the Customizer) with |
| 588 |
// no JS change. |
| 589 |
if ( function_exists( 'mlsimport_standalone_settings_app_config' ) ) { |
| 590 |
wp_add_inline_script( |
| 591 |
'mlsimport-standalone-settings', |
| 592 |
'window.mlsimportFields = ' . wp_json_encode( mlsimport_standalone_settings_app_config() ) . ';', |
| 593 |
'before' |
| 594 |
); |
| 595 |
} |
| 596 |
|
| 597 |
// Feed the "Arrange Sections" control its catalog (slug + label) from the |
| 598 |
// property section registry, so the list matches what the front end renders. |
| 599 |
if ( function_exists( 'mlsimport_standalone_section_catalog' ) ) { |
| 600 |
$catalog = array(); |
| 601 |
foreach ( mlsimport_standalone_section_catalog() as $slug => $label ) { |
| 602 |
$catalog[] = array( 'slug' => $slug, 'label' => $label ); |
| 603 |
} |
| 604 |
wp_add_inline_script( |
| 605 |
'mlsimport-standalone-settings', |
| 606 |
'window.mlsimportSections = ' . wp_json_encode( $catalog ) . ';', |
| 607 |
'before' |
| 608 |
); |
| 609 |
} |
| 610 |
|
| 611 |
// The Overview "Arrange Fields" control reads the Overview tile catalog — the |
| 612 |
// stat tiles the Overview section can draw (Updated, MLS #, Bedrooms, …). |
| 613 |
if ( function_exists( 'mlsimport_standalone_overview_fields_catalog' ) ) { |
| 614 |
$overview_fields = array(); |
| 615 |
foreach ( mlsimport_standalone_overview_fields_catalog() as $slug => $label ) { |
| 616 |
$overview_fields[] = array( 'slug' => $slug, 'label' => $label ); |
| 617 |
} |
| 618 |
wp_add_inline_script( |
| 619 |
'mlsimport-standalone-settings', |
| 620 |
'window.mlsimportOverviewFields = ' . wp_json_encode( $overview_fields ) . ';', |
| 621 |
'before' |
| 622 |
); |
| 623 |
} |
| 624 |
|
| 625 |
// The agent "Arrange Sections" control reads its own catalog (the agent page's |
| 626 |
// reorderable content-column sections), kept separate from the property catalog. |
| 627 |
if ( function_exists( 'mlsimport_standalone_agent_section_catalog' ) ) { |
| 628 |
$agent_catalog = array(); |
| 629 |
foreach ( mlsimport_standalone_agent_section_catalog() as $slug => $label ) { |
| 630 |
$agent_catalog[] = array( 'slug' => $slug, 'label' => $label ); |
| 631 |
} |
| 632 |
wp_add_inline_script( |
| 633 |
'mlsimport-standalone-settings', |
| 634 |
'window.mlsimportAgentSections = ' . wp_json_encode( $agent_catalog ) . ';', |
| 635 |
'before' |
| 636 |
); |
| 637 |
} |
| 638 |
|
| 639 |
// The archive "Taxonomy filters" control reads its own catalog (the search |
| 640 |
// form's toggleable filter fields), so the on/off toggle list matches what |
| 641 |
// the taxonomy/CPT archive search bar can render. |
| 642 |
if ( function_exists( 'mlsimport_standalone_archive_filters_catalog' ) ) { |
| 643 |
$archive_filters = array(); |
| 644 |
foreach ( mlsimport_standalone_archive_filters_catalog() as $slug => $label ) { |
| 645 |
$archive_filters[] = array( 'slug' => $slug, 'label' => $label ); |
| 646 |
} |
| 647 |
wp_add_inline_script( |
| 648 |
'mlsimport-standalone-settings', |
| 649 |
'window.mlsimportArchiveFilters = ' . wp_json_encode( $archive_filters ) . ';', |
| 650 |
'before' |
| 651 |
); |
| 652 |
} |
| 653 |
|
| 654 |
// The saved MLS logo's preview URL, so the media control can show the |
| 655 |
// current image before the user opens the picker. |
| 656 |
if ( function_exists( 'mlsimport_standalone_mls_logo_url' ) ) { |
| 657 |
wp_add_inline_script( |
| 658 |
'mlsimport-standalone-settings', |
| 659 |
'window.mlsimportLogoUrl = ' . wp_json_encode( mlsimport_standalone_mls_logo_url() ) . ';', |
| 660 |
'before' |
| 661 |
); |
| 662 |
} |
| 663 |
|
| 664 |
// Component + color-picker styles the React controls rely on, then the |
| 665 |
// app's own stylesheet. Cache-bust by file mtime so edits to the CSS are |
| 666 |
// picked up immediately — the plugin version (MLSIMPORT_VERSION) doesn't |
| 667 |
// change between design tweaks, so keying the ?ver on it left browsers |
| 668 |
// serving a stale cached copy under the same URL. |
| 669 |
$standalone_css_path = MLSIMPORT_PLUGIN_PATH . 'admin/css/mlsimport-standalone-settings.css'; |
| 670 |
$standalone_css_ver = file_exists( $standalone_css_path ) |
| 671 |
? (string) filemtime( $standalone_css_path ) |
| 672 |
: ( defined( 'MLSIMPORT_VERSION' ) ? MLSIMPORT_VERSION : false ); |
| 673 |
wp_enqueue_style( 'wp-components' ); |
| 674 |
wp_enqueue_style( 'wp-color-picker' ); |
| 675 |
wp_enqueue_style( |
| 676 |
'mlsimport-standalone-settings', |
| 677 |
MLSIMPORT_PLUGIN_URL . 'admin/css/mlsimport-standalone-settings.css', |
| 678 |
array( 'wp-components' ), |
| 679 |
$standalone_css_ver |
| 680 |
); |
| 681 |
} |
| 682 |
|
| 683 |
/** |
| 684 |
* Renders the Import History admin page. |
| 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. |
| 702 |
* |
| 703 |
* @param array $links Existing plugin action links. |
| 704 |
* @return array Links with the Settings link prepended. |
| 705 |
* @since 1.0.0 |
| 706 |
*/ |
| 707 |
public function add_action_links( $links ) { |
| 708 |
// Build the Settings link and place it before the default action links. |
| 709 |
$settings_link = array( |
| 710 |
'<a href="' . admin_url( 'admin.php?page=mlsimport_plugin_options' ) . '">' . esc_html__( 'Settings', 'mlsimport') . '</a>', |
| 711 |
); |
| 712 |
return array_merge( $settings_link, $links ); |
| 713 |
} |
| 714 |
|
| 715 |
|
| 716 |
|
| 717 |
|
| 718 |
|
| 719 |
|
| 720 |
|
| 721 |
|
| 722 |
/** |
| 723 |
* Render the main settings page for this plugin. |
| 724 |
* |
| 725 |
* Loads the admin-display partial (whose filename is prefixed with the slug). |
| 726 |
* |
| 727 |
* @since 1.0.0 |
| 728 |
*/ |
| 729 |
public function display_plugin_setup_page() { |
| 730 |
// Delegates the whole page to the slug-prefixed admin-display partial. |
| 731 |
include_once 'partials/' . $this->plugin_name . '-admin-display.php'; |
| 732 |
} |
| 733 |
|
| 734 |
|
| 735 |
|
| 736 |
|
| 737 |
|
| 738 |
|
| 739 |
/** |
| 740 |
* Sanitize/whitelist the main plugin options on save (register_setting callback). |
| 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. |
| 745 |
* |
| 746 |
* @param array $input Raw submitted options. |
| 747 |
* @return array Whitelisted, escaped options. |
| 748 |
* @since 1.0.0 |
| 749 |
*/ |
| 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 |
: ''; |
| 758 |
|
| 759 |
// Whitelist of accepted option keys (value = label/help metadata, unused |
| 760 |
// beyond documentation here); anything not listed is dropped on save. |
| 761 |
$valid = array(); |
| 762 |
$settings_list = array( |
| 763 |
'auth_username' => array( |
| 764 |
'name' => esc_html__( 'Api auth_username ', 'mlsimport' ), |
| 765 |
'details' => 'to be added', |
| 766 |
), |
| 767 |
'auth_password' => array( |
| 768 |
'name' => esc_html__( 'Api auth_password', 'mlsimport' ), |
| 769 |
'details' => 'to be added', |
| 770 |
), |
| 771 |
'client_id' => array( |
| 772 |
'name' => esc_html__( 'Api client_id', 'mlsimport' ), |
| 773 |
'details' => 'to be added', |
| 774 |
), |
| 775 |
'client_secret' => array( |
| 776 |
'name' => esc_html__( 'client_secret', 'mlsimport' ), |
| 777 |
'details' => 'to be added', |
| 778 |
), |
| 779 |
'redirect_uri' => array( |
| 780 |
'name' => esc_html__( 'redirect_uri', 'mlsimport' ), |
| 781 |
'details' => 'to be added', |
| 782 |
), |
| 783 |
'title_format' => array( |
| 784 |
'name' => esc_html__( 'title_format', 'mlsimport' ), |
| 785 |
'details' => 'to be added', |
| 786 |
), |
| 787 |
'force_rand' => array( |
| 788 |
'name' => esc_html__( 'title_format', 'mlsimport' ), |
| 789 |
'details' => 'to be added', |
| 790 |
), |
| 791 |
'mlsimport_username' => array( |
| 792 |
'name' => esc_html__( 'MLSImport.com Username or email', 'mlsimport' ), |
| 793 |
'details' => 'to be added', |
| 794 |
), |
| 795 |
'mlsimport_password' => array( |
| 796 |
'name' => esc_html__( 'MLSImport.com Password', 'mlsimport' ), |
| 797 |
'details' => 'to be added', |
| 798 |
), |
| 799 |
'mlsimport_mls_name' => array( |
| 800 |
'name' => esc_html__( 'MLSImport Name', 'mlsimport' ), |
| 801 |
'details' => 'to be added', |
| 802 |
), |
| 803 |
'mlsimport_mls_token' => array( |
| 804 |
'name' => esc_html__( 'MLSImport Token', 'mlsimport' ), |
| 805 |
'details' => 'to be added', |
| 806 |
), |
| 807 |
|
| 808 |
'mlsimport_tresle_client_id' => array( |
| 809 |
'name' => esc_html__( 'MLSImport Tresle Client id', 'mlsimport' ), |
| 810 |
'details' => 'to be added', |
| 811 |
), |
| 812 |
|
| 813 |
'mlsimport_tresle_client_secret' => array( |
| 814 |
'name' => esc_html__( 'MLSImport Client Secret', 'mlsimport' ), |
| 815 |
'details' => 'to be added', |
| 816 |
), |
| 817 |
|
| 818 |
'mlsimport_connectmls_username' => array( |
| 819 |
'name' => esc_html__( 'MLSImport ConnectMLS Username', 'mlsimport' ), |
| 820 |
'details' => 'to be added', |
| 821 |
), |
| 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 |
|
| 833 |
'mlsimport_rapattoni_client_secret' => array( |
| 834 |
'name' => esc_html__( 'MLSImport Rapattoni Secret', 'mlsimport' ), |
| 835 |
'details' => 'to be added', |
| 836 |
), |
| 837 |
|
| 838 |
'mlsimport_rapattoni_username' => array( |
| 839 |
'name' => esc_html__( 'MLSImport Rapattoni Username', 'mlsimport' ), |
| 840 |
'details' => 'to be added', |
| 841 |
), |
| 842 |
|
| 843 |
'mlsimport_rapattoni_password' => array( |
| 844 |
'name' => esc_html__( 'MLSImport Rapattoni Password', 'mlsimport' ), |
| 845 |
'details' => 'to be added', |
| 846 |
), |
| 847 |
|
| 848 |
'mlsimport_paragon_client_id' => array( |
| 849 |
'name' => esc_html__( 'MLSImport Paragon Client id','mlsimport' ), |
| 850 |
'details' => 'to be added', |
| 851 |
), |
| 852 |
|
| 853 |
'mlsimport_paragon_client_secret' => array( |
| 854 |
'name' => esc_html__( 'MLSImport Paragon Secret', 'mlsimport' ), |
| 855 |
'details' => 'to be added', |
| 856 |
), |
| 857 |
'mlsimport_realtorca_client_id' => array( |
| 858 |
'name' => esc_html__( 'MLSImport Realtor.ca Client id','mlsimport' ), |
| 859 |
'details' => 'to be added', |
| 860 |
), |
| 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 |
|
| 876 |
'mlsimport_theme_used' => array( |
| 877 |
'name' => esc_html__( 'Your Wordpress Theme', 'mlsimport' ), |
| 878 |
'details' => 'to be added', |
| 879 |
), |
| 880 |
'mlsimport_mls_name_front' => array( |
| 881 |
'name' => '', |
| 882 |
'details' => 'to be added', |
| 883 |
), |
| 884 |
'mlsimport-disable-logs' => array( |
| 885 |
'name' => '', |
| 886 |
'details' => 'to be added', |
| 887 |
), |
| 888 |
); |
| 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. |
| 894 |
foreach ( $settings_list as $key => $setting ) { |
| 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 |
} |
| 902 |
} |
| 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. |
| 915 |
delete_option( 'mlsimport_connection_test' ); |
| 916 |
mlsimport_delete_connection_option( 'mlsimport_mls_metadata_populated', (int) $new_mls_id ); |
| 917 |
|
| 918 |
// Reset cached encoding and drop cached token/schema transients. |
| 919 |
update_option( 'mlsimport_encoding_array', '' ); |
| 920 |
delete_transient( 'mlsimport_token_request' ); |
| 921 |
delete_transient( 'mlsimport_schema' ); |
| 922 |
delete_transient( 'mlsimport_plugin_data_schema' ); |
| 923 |
|
| 924 |
delete_transient( 'mlsimport_saas_token' ); |
| 925 |
return $valid; |
| 926 |
} |
| 927 |
|
| 928 |
|
| 929 |
|
| 930 |
|
| 931 |
|
| 932 |
/** |
| 933 |
* Validate the MLS-sync option group on save (register_setting callback). |
| 934 |
* |
| 935 |
* Copies a fixed whitelist of sync/import parameter keys straight through. |
| 936 |
* |
| 937 |
* @param array $input Raw submitted sync settings. |
| 938 |
* @return array Whitelisted sync settings. |
| 939 |
* @since 1.0.0 |
| 940 |
*/ |
| 941 |
public function validate_admin_mls_sync( $input ) { |
| 942 |
$valid = array(); |
| 943 |
|
| 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', |
| 947 |
'StandardStatus_delete', 'StandardStatus_delete_check', 'InternetEntireListingDisplayYN', 'InternetAddressDisplayYN' ); |
| 948 |
// Pass each whitelisted key through unchanged. |
| 949 |
foreach ( $field_import as $key ) { |
| 950 |
$valid[ $key ] = $input[ $key ]; |
| 951 |
} |
| 952 |
|
| 953 |
return $valid; |
| 954 |
} |
| 955 |
|
| 956 |
|
| 957 |
|
| 958 |
/** |
| 959 |
* Register all plugin option groups with the Settings API and bind each to |
| 960 |
* its validation callback. Hooked on admin_init. |
| 961 |
*/ |
| 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. |
| 965 |
register_setting( $this->plugin_name . '_admin_options', $this->plugin_name . '_admin_options', array( $this, 'validate_admin_options' ) ); |
| 966 |
register_setting( $this->plugin_name . '_admin_mls_sync', $this->plugin_name . '_admin_mls_sync', array( $this, 'validate_admin_mls_sync' ) ); |
| 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. |
| 969 |
} |
| 970 |
|
| 971 |
/** |
| 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. |
| 974 |
*/ |
| 975 |
public function update_option_mlsimport_admin_fields_select() { |
| 976 |
|
| 977 |
// Delegate to the theme adapter to sync its custom fields. |
| 978 |
$this->env_data->enviroment_custom_fields( $this->plugin_name ); |
| 979 |
} |
| 980 |
|
| 981 |
|
| 982 |
/** |
| 983 |
* Register the "Hidden Fields" metabox on the theme's property post type. |
| 984 |
* |
| 985 |
* Only added when the theme adapter exposes get_property_post_type(). |
| 986 |
*/ |
| 987 |
public function mlsimport_meta_options() { |
| 988 |
// Add the metabox to whatever post type the active theme uses for listings. |
| 989 |
if ( method_exists( $this->env_data, 'get_property_post_type' ) ) { |
| 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' ); |
| 991 |
} |
| 992 |
} |
| 993 |
|
| 994 |
/** |
| 995 |
* Render the "Hidden Fields" metabox for a single property post. |
| 996 |
* |
| 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. |
| 1000 |
*/ |
| 1001 |
public function mlsimport_hidden_fields() { |
| 1002 |
global $post; |
| 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). |
| 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; |
| 1047 |
if ( isset( $options['mls-fields-label'][ $key ] ) && '' !== $options['mls-fields-label'][ $key ] ) { |
| 1048 |
$display_label = $options['mls-fields-label'][ $key ]; |
| 1049 |
} |
| 1050 |
|
| 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 ); |
| 1057 |
} else { |
| 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 ); |
| 1062 |
} |
| 1063 |
?> |
| 1064 |
|
| 1065 |
<strong><?php echo esc_html($display_label);?>:</strong> |
| 1066 |
<?php echo esc_html( $field_value ); ?> </br> |
| 1067 |
<?php |
| 1068 |
} |
| 1069 |
} |
| 1070 |
?> |
| 1071 |
|
| 1072 |
<h2 style="font-weight:bold;padding-left:0px;">Mls Import History</h2> |
| 1073 |
<?php |
| 1074 |
// Property change history (only populated when history logging is enabled). |
| 1075 |
$meta = get_post_meta( $post->ID, 'mlsimport_property_history', true ); |
| 1076 |
if ( '' === trim( $meta ) ) { ?> |
| 1077 |
<strong>Property history is blank - you can enable it in Settings/ Tools page </strong> |
| 1078 |
<?php |
| 1079 |
} else { |
| 1080 |
print wp_kses_post($meta); |
| 1081 |
} |
| 1082 |
} |
| 1083 |
|
| 1084 |
|
| 1085 |
|
| 1086 |
|
| 1087 |
/** |
| 1088 |
* AJAX (Tools page): clear all MLSImport caches/transients and the |
| 1089 |
* metadata-populated flag, forcing the next request to re-fetch everything. |
| 1090 |
*/ |
| 1091 |
function mlsimport_delete_cache() { |
| 1092 |
|
| 1093 |
// CSRF: Tools-page nonce. |
| 1094 |
check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 1095 |
|
| 1096 |
// Drop every cached token/metadata/schema transient. |
| 1097 |
delete_transient( 'mlsimport_token_request' ); |
| 1098 |
delete_transient( 'mlsimport_metadata_api_call_data_service_property' ); |
| 1099 |
delete_transient( 'mls_import_meta_enums' ); |
| 1100 |
delete_transient( 'mls_import_meta' ); |
| 1101 |
delete_transient( 'mlsimport_plugin_data_schema' ); |
| 1102 |
delete_transient( 'mlsimport_ready_to_go_mlsimport_data' ); |
| 1103 |
delete_transient( 'mlsimport_saas_token' ); |
| 1104 |
|
| 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 ); |
| 1161 |
} |
| 1162 |
|
| 1163 |
/** |
| 1164 |
* AJAX (Tools page): delete imported properties matching selected taxonomy |
| 1165 |
* terms, in batches of 20. Admin-only. Reports progress so the client can |
| 1166 |
* loop until done; refreshes term counts once the last batch completes. |
| 1167 |
* |
| 1168 |
* @return void Emits a JSON success payload {deleted,remaining,total,done}. |
| 1169 |
*/ |
| 1170 |
function mlsimport_delete_properties() { |
| 1171 |
global $mlsimport; |
| 1172 |
|
| 1173 |
// CSRF + capability. |
| 1174 |
check_ajax_referer( 'mlsimport_tool_actions', 'security' ); |
| 1175 |
|
| 1176 |
if ( ! current_user_can( 'administrator' ) ) { |
| 1177 |
wp_send_json_error( 'Unauthorized' ); |
| 1178 |
} |
| 1179 |
|
| 1180 |
// Selected taxonomy and its chosen term slugs. |
| 1181 |
$taxonomy = sanitize_text_field( wp_unslash( $_POST['mlsimport_delete_category'] ) ); |
| 1182 |
$terms = array(); |
| 1183 |
|
| 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 ) ); |
| 1188 |
} |
| 1189 |
} |
| 1190 |
|
| 1191 |
// Require a taxonomy. |
| 1192 |
if ( '' === $taxonomy ) { |
| 1193 |
wp_send_json_error( esc_html__( 'Please select a taxonomy', 'mlsimport' ) ); |
| 1194 |
} |
| 1195 |
|
| 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 |
} |
| 1200 |
|
| 1201 |
// Query one page of property IDs matching the term selection. |
| 1202 |
$post_type = $mlsimport->admin->env_data->get_property_post_type(); |
| 1203 |
|
| 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, |
| 1211 |
'field' => 'slug', |
| 1212 |
'terms' => $terms, |
| 1213 |
), |
| 1214 |
), |
| 1215 |
'fields' => 'ids', |
| 1216 |
); |
| 1217 |
|
| 1218 |
$prop_selection = new WP_Query( $args ); |
| 1219 |
$deleted = 0; |
| 1220 |
|
| 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 |
} |
| 1226 |
|
| 1227 |
// Compute how many still match after this batch; done when none remain. |
| 1228 |
$remaining = $prop_selection->found_posts - $deleted; |
| 1229 |
$done = ( $remaining <= 0 ); |
| 1230 |
|
| 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 ); |
| 1239 |
} |
| 1240 |
} |
| 1241 |
} |
| 1242 |
|
| 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 |
} |
| 1251 |
|
| 1252 |
|
| 1253 |
|
| 1254 |
|
| 1255 |
|
| 1256 |
|
| 1257 |
|
| 1258 |
|
| 1259 |
/** |
| 1260 |
* Convert a PHP shorthand byte value (e.g. "256M", "1G", "-1") to bytes. |
| 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. |
| 1294 |
* |
| 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. |
| 1300 |
*/ |
| 1301 |
public function mlsimport_saas_setting_up() { |
| 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> |
| 1343 |
<?php |
| 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 ) { |
| 1350 |
?> |
| 1351 |
<div class="mlsimport_warning long_warning"> |
| 1352 |
<?php |
| 1353 |
printf( |
| 1354 |
/* translators: %s: current max_execution_time value. */ |
| 1355 |
wp_kses( |
| 1356 |
__( 'Your <strong>max_execution_time</strong> setting in php is set to <strong>%s</strong>. Importing hundreds of listings requires extra time. Please set max_execution_time to <strong>0 (unlimited)</strong>. If that is not possible, set it to a minimum of <strong>600 (10 minutes)</strong>.', 'mlsimport' ), |
| 1357 |
array( 'strong' => array() ) |
| 1358 |
), |
| 1359 |
esc_html( $max_time ) |
| 1360 |
); |
| 1361 |
?> |
| 1362 |
</div> |
| 1363 |
|
| 1364 |
<?php |
| 1365 |
} |
| 1366 |
} |
| 1367 |
|
| 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 |
*/ |
| 1378 |
public function mlsimport_saas_check_mls_connection() { |
| 1379 |
|
| 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 ); |
| 1389 |
|
| 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 |
); |
| 1398 |
} |
| 1399 |
|
| 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 |
); |
| 1408 |
} |
| 1409 |
$values = $payload_result['payload']; |
| 1410 |
|
| 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 ); |
| 1422 |
} |
| 1423 |
|
| 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 ); |
| 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 |
} |
| 1436 |
|
| 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' ); |
| 1450 |
} |
| 1451 |
|
| 1452 |
// Mirror the outcome into this connection's registry record (#277): |
| 1453 |
// the cron gate reads record status for every non-current connection, |
| 1454 |
// so the record must stay truthful, not only the global flag above. |
| 1455 |
mlsimport_connection_record_test_result( (int) $mls_id, $mlsimport_tested_ok ); |
| 1456 |
|
| 1457 |
return $answer; |
| 1458 |
} |
| 1459 |
|
| 1460 |
/** |
| 1461 |
* AJAX handler for the plugin-deactivation exit survey. |
| 1462 |
* |
| 1463 |
* Thin wrapper: it verifies the nonce and capability, sanitizes input, |
| 1464 |
* delegates the real work to mlsimport_exit_survey_record(), and POSTs |
| 1465 |
* the result to the SaaS API. The POST is fire-and-forget — a failed or |
| 1466 |
* not-yet-deployed endpoint must never stop the admin from deactivating. |
| 1467 |
*/ |
| 1468 |
public function mlsimport_exit_survey_submit() { |
| 1469 |
check_ajax_referer( 'mlsimport_exit_survey', 'security' ); |
| 1470 |
if ( ! current_user_can( 'administrator' ) ) { |
| 1471 |
wp_send_json_error( 'Unauthorized' ); |
| 1472 |
} |
| 1473 |
|
| 1474 |
$input = array( |
| 1475 |
'reason' => sanitize_text_field( wp_unslash( $_POST['reason'] ?? '' ) ), |
| 1476 |
'details' => sanitize_textarea_field( wp_unslash( $_POST['details'] ?? '' ) ), |
| 1477 |
); |
| 1478 |
|
| 1479 |
// Only record a recognized reason; an unknown value is dropped |
| 1480 |
// silently rather than blocking the user or storing junk. |
| 1481 |
if ( $this->mlsimport_exit_survey_is_valid_reason( $input['reason'] ) ) { |
| 1482 |
$payload = $this->mlsimport_exit_survey_record( $input ); |
| 1483 |
try { |
| 1484 |
ThemeImport::globalApiRequestSaas( 'user-activity', $payload, 'POST' ); |
| 1485 |
} catch ( \Throwable $e ) { |
| 1486 |
// Swallow: deactivation proceeds regardless of transport failure. |
| 1487 |
} |
| 1488 |
} |
| 1489 |
|
| 1490 |
wp_send_json_success(); |
| 1491 |
} |
| 1492 |
|
| 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 |
} |
| 1501 |
|
| 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 |
} |
| 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 |
} |
| 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 |
} |
| 1543 |
|
| 1544 |
$count = (int) get_option( 'mlsimport_deactivation_count', 0 ) + 1; |
| 1545 |
update_option( 'mlsimport_deactivation_count', $count ); |
| 1546 |
|
| 1547 |
$reason = (string) ( $input['reason'] ?? '' ); |
| 1548 |
$options = $this->get_exit_survey_options(); |
| 1549 |
|
| 1550 |
return array( |
| 1551 |
'event_type' => 'exit_survey', |
| 1552 |
'reason' => $reason, |
| 1553 |
'reason_label' => $options[ $reason ] ?? '', |
| 1554 |
'details' => (string) ( $input['details'] ?? '' ), |
| 1555 |
'account' => (string) ( $opts['mlsimport_username'] ?? '' ), |
| 1556 |
'install_uuid' => $opts['mlsimport_install_uuid'], |
| 1557 |
'deactivation_count' => $count, |
| 1558 |
'environment' => wp_get_environment_type(), |
| 1559 |
'site_url' => home_url(), |
| 1560 |
'admin_email' => (string) get_option( 'admin_email' ), |
| 1561 |
'timestamp' => time(), |
| 1562 |
); |
| 1563 |
} |
| 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 |
} |
| 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 |
} |
| 1602 |
|
| 1603 |
|
| 1604 |
|
| 1605 |
|
| 1606 |
|
| 1607 |
|
| 1608 |
/** |
| 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. |
| 1611 |
* |
| 1612 |
* @since 4.0.1 |
| 1613 |
* @return string|array The token string, or the raw answer/'' on failure. |
| 1614 |
*/ |
| 1615 |
public function mlsimport_saas_get_mls_api_token_from_transient() { |
| 1616 |
|
| 1617 |
// Prefer the cached token. |
| 1618 |
$token = get_transient( 'mlsimport_saas_token' ); |
| 1619 |
|
| 1620 |
// Cache miss/empty: request a new token and cache it on success. |
| 1621 |
if ( false === $token || '' === $token ) { |
| 1622 |
$token_json_answer = $this->mlsimport_saas_get_mls_api_token(); |
| 1623 |
|
| 1624 |
if ( isset( $token_json_answer['success'] ) && true === $token_json_answer['success'] ) { |
| 1625 |
$token = $token_json_answer['token']; |
| 1626 |
|
| 1627 |
// 3500s < the token's 1h life, leaving headroom before expiry. |
| 1628 |
set_transient( 'mlsimport_saas_token', $token, 3500 ); |
| 1629 |
} |
| 1630 |
} |
| 1631 |
|
| 1632 |
return $token; |
| 1633 |
} |
| 1634 |
|
| 1635 |
|
| 1636 |
/** |
| 1637 |
* Request a fresh SaaS API token using the stored account username/password. |
| 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 |
* |
| 1643 |
* @since 4.0.1 |
| 1644 |
* @return array|string The 'token' API response, or '' when unconfigured. |
| 1645 |
*/ |
| 1646 |
protected function mlsimport_saas_get_mls_api_token() { |
| 1647 |
$values = array(); |
| 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', '' ); |
| 1652 |
|
| 1653 |
|
| 1654 |
$username = ''; |
| 1655 |
if ( isset( $options['mlsimport_username'] ) ) { |
| 1656 |
$username = sanitize_text_field( trim( $options['mlsimport_username'] ) ); |
| 1657 |
} |
| 1658 |
|
| 1659 |
$password = ''; |
| 1660 |
if ( isset( $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'] ); |
| 1664 |
} |
| 1665 |
$mls_name = ''; |
| 1666 |
if ( isset( $options['mlsimport_mls_name'] ) ) { |
| 1667 |
$mls_name = sanitize_text_field( trim( $options['mlsimport_mls_name'] ) ); |
| 1668 |
} |
| 1669 |
|
| 1670 |
$mls_token = ''; |
| 1671 |
if ( isset( $options['mlsimport_mls_token'] ) ) { |
| 1672 |
$mls_token = sanitize_text_field( trim( $options['mlsimport_mls_token'] ) ); |
| 1673 |
} |
| 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. |
| 1700 |
$values['username'] = $username; |
| 1701 |
$values['password'] = $password; |
| 1702 |
|
| 1703 |
// No account credentials -> nothing to request. |
| 1704 |
if ( '' === $username || '' === $password ) { |
| 1705 |
return ''; |
| 1706 |
} |
| 1707 |
|
| 1708 |
// POST to the SaaS 'token' endpoint and return its response. |
| 1709 |
$theme_Start = new ThemeImport(); |
| 1710 |
$answer = $theme_Start::globalApiRequestSaas( 'token', $values, 'POST' ); |
| 1711 |
|
| 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 ); |
| 1715 |
|
| 1716 |
return $answer; |
| 1717 |
} |
| 1718 |
|
| 1719 |
|
| 1720 |
|
| 1721 |
|
| 1722 |
|
| 1723 |
|
| 1724 |
/** |
| 1725 |
* Register the "Set Import data" metabox on the mlsimport_item post type. |
| 1726 |
* |
| 1727 |
* @since 3.0.1 |
| 1728 |
*/ |
| 1729 |
public function mlsimport_item_product_metaboxes() { |
| 1730 |
// The metabox renders the import-parameter form for an Import Task. |
| 1731 |
add_meta_box( 'mlsimport_item_metaboxes-sectionid', __( 'Set Import data', 'mlsimport' ), array( $this, 'mlsimport_saas_display_meta_options' ), 'mlsimport_item', 'normal', 'default' ); |
| 1732 |
} |
| 1733 |
|
| 1734 |
|
| 1735 |
|
| 1736 |
/** |
| 1737 |
* Save the Import Task metabox fields to post meta (save_post callback). |
| 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. |
| 1743 |
* |
| 1744 |
* @param int $post_id Post being saved. |
| 1745 |
* @param WP_Post $post Post object. |
| 1746 |
* @since 3.0.1 |
| 1747 |
*/ |
| 1748 |
public function mlsimport_item_product_save_metaboxes( $post_id, $post ) { |
| 1749 |
|
| 1750 |
// Guard against non-post contexts. |
| 1751 |
if ( ! is_object( $post ) || ! isset( $post->post_type ) ) { |
| 1752 |
return; |
| 1753 |
} |
| 1754 |
|
| 1755 |
// Only handle Import Task posts. |
| 1756 |
if ( 'mlsimport_item' !== $post->post_type ) { |
| 1757 |
return; |
| 1758 |
} |
| 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. |
| 1785 |
$allowed_keys = array( |
| 1786 |
'mlsimport_item_how_many', |
| 1787 |
'mlsimport_item_title_format', |
| 1788 |
'mlsimport_item_agent', |
| 1789 |
'mlsimport_item_use_mls_agent', |
| 1790 |
'mlsimport_item_property_status', |
| 1791 |
'mlsimport_item_property_user', |
| 1792 |
'mlsimport_item_min_price', |
| 1793 |
'mlsimport_item_max_price', |
| 1794 |
'mlsimport_item_city_check', |
| 1795 |
'mlsimport_item_city', |
| 1796 |
'mlsimport_item_city[]', |
| 1797 |
'mlsimport_item_countyorparish_check', |
| 1798 |
'mlsimport_item_countyorparish', |
| 1799 |
'mlsimport_item_mlsstatus_check', |
| 1800 |
'mlsimport_item_mlsstatus', |
| 1801 |
'mlsimport_item_propertysubtype_check', |
| 1802 |
'mlsimport_item_propertysubtype', |
| 1803 |
'mlsimport_item_propertytype_check', |
| 1804 |
'mlsimport_item_propertytype', |
| 1805 |
'mlsimport_item_standardstatus_check', |
| 1806 |
'mlsimport_item_standardstatus', |
| 1807 |
'mlsimport_item_standardstatusprotect_check', |
| 1808 |
'mlsimport_item_standardstatusprotect', |
| 1809 |
|
| 1810 |
'mlsimport_item_internetentirelistingdisplayyn', |
| 1811 |
'mlsimport_item_internetaddressdisplayyn', |
| 1812 |
'mlsimport_item_stat_cron', |
| 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 |
); |
| 1831 |
|
| 1832 |
|
| 1833 |
|
| 1834 |
|
| 1835 |
// Store each posted key (recursively sanitized; key sanitized too). |
| 1836 |
foreach ( $allowed_keys as $key => $key_value ) { |
| 1837 |
if( isset($_POST[$key_value]) ){ |
| 1838 |
$postmeta = mlsimport_sanitize_multi_dimensional_array ( $_POST[$key_value] ) ; |
| 1839 |
update_post_meta( $post_id, sanitize_key( $key_value ), $postmeta ); |
| 1840 |
} |
| 1841 |
|
| 1842 |
} |
| 1843 |
|
| 1844 |
// Keys that must be reset to '' when omitted from the POST (cleared). |
| 1845 |
$blank_keys = array( |
| 1846 |
'mlsimport_item_use_mls_agent', |
| 1847 |
'mlsimport_item_standardstatus', |
| 1848 |
'mlsimport_item_standardstatusprotect', |
| 1849 |
'mlsimport_item_city', |
| 1850 |
'mlsimport_item_countyorparish', |
| 1851 |
'mlsimport_item_propertysubtype', |
| 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', |
| 1859 |
|
| 1860 |
); |
| 1861 |
|
| 1862 |
// Reset any whitelisted-blank key that was not submitted this save. |
| 1863 |
foreach ( $blank_keys as $key ) { |
| 1864 |
if ( ! isset( $_POST[ $key ] ) ) { |
| 1865 |
update_post_meta( $post_id, $key, '' ); |
| 1866 |
} |
| 1867 |
} |
| 1868 |
|
| 1869 |
|
| 1870 |
} |
| 1871 |
|
| 1872 |
|
| 1873 |
/** |
| 1874 |
* Render the Import Task metabox content. |
| 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 |
* |
| 1886 |
* @param WP_Post $post The post object. |
| 1887 |
*/ |
| 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; |
| 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(); |
| 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 |
} |
| 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 |
} |
| 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 |
|
| 1964 |
/** |
| 1965 |
* Generate Meta Options HTML |
| 1966 |
* |
| 1967 |
* @param int $postId The post ID. |
| 1968 |
* @param int|null $foundItems The number of found items, or null when the |
| 1969 |
* count request failed and no count is known. |
| 1970 |
* @param string $lastDate The last date checked. |
| 1971 |
* @param string $mlsimportItemHowMany How many items to import. |
| 1972 |
* @param string $mlsimportItemStatCron The status of the cron job. |
| 1973 |
* @param int $mlsimportMlsId The MLS import ID. |
| 1974 |
* @param array $fieldImport The fields to import. |
| 1975 |
* @return string The generated HTML. |
| 1976 |
*/ |
| 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. |
| 1981 |
ob_start(); |
| 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 |
|
| 2009 |
?> |
| 2010 |
<div class="mlsimport_item_search_url" style="display:none;"><?php echo esc_html__('Last date/time we check :', 'mlsimport') . ' ' . esc_html($lastDate); ?></div> |
| 2011 |
<ul> |
| 2012 |
<li>1. Set the import parameters.</li> |
| 2013 |
<li>2. Hit Publish or Update, otherwise import will not work correctly.</li> |
| 2014 |
<li>3. Click the Start Import button. Most MLS limit the import number to 1000. If you need to import more create additional import items.</li> |
| 2015 |
<li>4. Press the Update button after you make any change in the import settings.</li> |
| 2016 |
</ul> |
| 2017 |
|
| 2018 |
<?php if (is_numeric($foundItems) && $foundItems >= 500): ?> |
| 2019 |
<div class="mlsimport_notification"> |
| 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'); ?> |
| 2021 |
</div> |
| 2022 |
<?php endif; ?> |
| 2023 |
|
| 2024 |
<div class="mlsimport_import_no"> |
| 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; ?> |
| 2032 |
</div> |
| 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 |
|
| 2070 |
<fieldset class="mlsimport-fieldset"> |
| 2071 |
<label class="mlsimport-label" for="mlsimport_item_how_many"> |
| 2072 |
<?php esc_html_e('How Many to import. Use 0 if you want to import all listings found.', 'mlsimport'); ?> |
| 2073 |
</label> |
| 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); ?>"/> |
| 2076 |
</fieldset> |
| 2077 |
|
| 2078 |
<fieldset class="mlsimport-fieldset mlsimport_auto_switch"> |
| 2079 |
<?php esc_html_e('Enable Auto Update every hour?', 'mlsimport'); ?> |
| 2080 |
<label class="mlsimport_switch"> |
| 2081 |
<input type="hidden" value="0" name="mlsimport_item_stat_cron"> |
| 2082 |
<input type="checkbox" class="mlsimport-import-checkbox" value="1" name="mlsimport_item_stat_cron"<?php if (intval($mlsimportItemStatCron) !== 0) echo esc_html(' checked'); ?>> |
| 2083 |
<span class="slider round"></span> |
| 2084 |
</label> |
| 2085 |
</fieldset> |
| 2086 |
|
| 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; ?> |
| 2099 |
|
| 2100 |
<input type="hidden" id="mlsimport_item_actions" value="<?php echo esc_attr(wp_create_nonce("mlsimport_item_actions")); ?>"/> |
| 2101 |
<div class="mlsimport_param_wrapper"><h2><?php esc_html_e('Import Parameters', 'mlsimport'); ?></h2> |
| 2102 |
|
| 2103 |
<?php |
| 2104 |
$mlsimportItemTitleFormat = esc_html(get_post_meta($postId, 'mlsimport_item_title_format', true)); |
| 2105 |
?> |
| 2106 |
|
| 2107 |
<fieldset class="mlsimport-fieldset"> |
| 2108 |
<label class="mlsimport-label" for="mlsimport_item_title_format"> |
| 2109 |
<?php esc_html_e('Title Format', 'mlsimport'); ?> |
| 2110 |
</label> |
| 2111 |
|
| 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> |
| 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}'); ?>"/> |
| 2116 |
</fieldset> |
| 2117 |
|
| 2118 |
<?php |
| 2119 |
$mlsimportItemAgent = esc_html(get_post_meta($postId, 'mlsimport_item_agent', true)); |
| 2120 |
?> |
| 2121 |
|
| 2122 |
<fieldset class="mlsimport-fieldset"> |
| 2123 |
<label class="mlsimport-label" for="mlsimport_item_agent"> |
| 2124 |
<?php esc_html_e('Select Agent', 'mlsimport'); ?> |
| 2125 |
</label> |
| 2126 |
<select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_agent" id="mlsimport_item_agent"> |
| 2127 |
<?php |
| 2128 |
$permitedTags = mlsimport_allowed_html_tags_content(); |
| 2129 |
$selectAgent =$this->theme_importer->mlsimportSaasThemeImportSelectAgent($mlsimportItemAgent); |
| 2130 |
print wp_kses($selectAgent, $permitedTags); |
| 2131 |
?> |
| 2132 |
</select> |
| 2133 |
</fieldset> |
| 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 |
|
| 2150 |
<?php |
| 2151 |
$mlsimportItemPropertyStatus = esc_html(get_post_meta($postId, 'mlsimport_item_property_status', true)); |
| 2152 |
if ('' === $mlsimportItemPropertyStatus) { |
| 2153 |
$mlsimportItemPropertyStatus = 'publish'; |
| 2154 |
} |
| 2155 |
$statusArray = array('publish', 'draft'); |
| 2156 |
?> |
| 2157 |
<fieldset class="mlsimport-fieldset"> |
| 2158 |
<label class="mlsimport-label" for="mlsimport_item_property_status"> |
| 2159 |
<?php esc_html_e('Select Property Status on import', 'mlsimport'); ?> |
| 2160 |
</label> |
| 2161 |
<select class="mlsimport-select mlsimport-2025-select" name="mlsimport_item_property_status" id="mlsimport_item_property_status"> |
| 2162 |
<?php foreach ($statusArray as $value): ?> |
| 2163 |
<option value="<?php echo esc_attr($value); ?>" <?php if ($value === $mlsimportItemPropertyStatus) echo esc_html('selected'); ?>> |
| 2164 |
<?php echo esc_html($value); ?> |
| 2165 |
</option> |
| 2166 |
<?php endforeach; ?> |
| 2167 |
</select> |
| 2168 |
</fieldset> |
| 2169 |
|
| 2170 |
<?php |
| 2171 |
$mlsimportItemPropertyUser = esc_html(get_post_meta($postId, 'mlsimport_item_property_user', true)); |
| 2172 |
?> |
| 2173 |
<fieldset class="mlsimport-fieldset"> |
| 2174 |
<label class="mlsimport-label" for="mlsimport_item_property_user"> |
| 2175 |
<?php esc_html_e('User', 'mlsimport'); ?> |
| 2176 |
</label> |
| 2177 |
<select class="mlsimport-select mlsimport-2025-select" id="mlsimport_item_property_user" name="mlsimport_item_property_user"> |
| 2178 |
<?php |
| 2179 |
$selectUser = $this->theme_importer->mlsimportSaasThemeImportSelectUser($mlsimportItemPropertyUser); |
| 2180 |
print wp_kses($selectUser, $permitedTags); |
| 2181 |
?> |
| 2182 |
</select> |
| 2183 |
</fieldset> |
| 2184 |
|
| 2185 |
<?php |
| 2186 |
$mlsimportItemMinPrice = floatval(get_post_meta($postId, 'mlsimport_item_min_price', true)); |
| 2187 |
$mlsimportItemMaxPrice = floatval(get_post_meta($postId, 'mlsimport_item_max_price', true)); |
| 2188 |
if (0 === intval($mlsimportItemMaxPrice)) { |
| 2189 |
$mlsimportItemMaxPrice = 10000000; |
| 2190 |
} |
| 2191 |
?> |
| 2192 |
<fieldset class="mlsimport-fieldset"> |
| 2193 |
<label class="mlsimport-label"> |
| 2194 |
<?php esc_html_e('Price Between', 'mlsimport'); ?> |
| 2195 |
</label> |
| 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); ?>"> |
| 2198 |
</fieldset> |
| 2199 |
|
| 2200 |
<?php |
| 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 ); |
| 2214 |
|
| 2215 |
// Render one fieldset per import parameter. |
| 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. |
| 2222 |
$nameCheck = strtolower('mlsimport_item_' . $key . '_check'); |
| 2223 |
$name = strtolower('mlsimport_item_' . $key); |
| 2224 |
|
| 2225 |
// Current saved value + select-all flag for this field. |
| 2226 |
$value = get_post_meta($postId, $name, true); |
| 2227 |
$valueCheck = get_post_meta($postId, $nameCheck, true); |
| 2228 |
// extraCity/extraCounty render as a toggle button, not a plain label. |
| 2229 |
$extraClass = ''; |
| 2230 |
if ('extraCity' === $key || 'extraCounty' === $key) { |
| 2231 |
$extraClass = ' mlsimport_hidden_field_button button mlsimport_button'; |
| 2232 |
} |
| 2233 |
?> |
| 2234 |
<fieldset class="mlsimport-fieldset"> |
| 2235 |
<label class="mlsimport-label <?php echo esc_attr($extraClass); ?>" for="<?php echo esc_attr($name); ?>"> |
| 2236 |
<?php echo esc_html($field['label']); ?> |
| 2237 |
</label> |
| 2238 |
<?php if ('extraCity' === $key || 'extraCounty' === $key): ?> |
| 2239 |
<div class="mlsimport-input-wrapper" style="display:none"> |
| 2240 |
<?php endif; ?> |
| 2241 |
<p class="mlsimport-exp"><?php echo wp_kses_post($this->mlsimport_notes_for_mls($mlsimportMlsId, $name, $field['description'])); ?> |
| 2242 |
<?php |
| 2243 |
// Whether the "select all" checkbox is currently on. |
| 2244 |
$isCheckboxAdmin = 0; |
| 2245 |
if (1 === intval($valueCheck)) { |
| 2246 |
$isCheckboxAdmin = 1; |
| 2247 |
} |
| 2248 |
|
| 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 |
]; |
| 2272 |
|
| 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) { |
| 2278 |
$selectAllNone[] = 'PropertyType'; |
| 2279 |
} |
| 2280 |
|
| 2281 |
if (!in_array($key, $selectAllNone)): ?> |
| 2282 |
<?php |
| 2283 |
esc_html_e('- Or Select All ', 'mlsimport'); |
| 2284 |
|
| 2285 |
?> |
| 2286 |
<input type="hidden" name="<?php echo esc_attr($nameCheck); ?>" value="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)); ?>/> |
| 2288 |
<?php endif; ?> |
| 2289 |
</p> |
| 2290 |
|
| 2291 |
<?php |
| 2292 |
$permittedStatus = ['active', 'active under contract', 'coming soon', 'activeundercontract', 'comingsoon', 'pending']; |
| 2293 |
|
| 2294 |
if ($field['type'] === 'select'): ?> |
| 2295 |
<?php |
| 2296 |
// Multi-select fields need the multiple attr + [] name. |
| 2297 |
$multiple = ''; |
| 2298 |
if ('yes' === $field['multiple']) { |
| 2299 |
$multiple = 'multiple'; |
| 2300 |
$name .= '[]'; |
| 2301 |
} |
| 2302 |
|
| 2303 |
// Default StandardStatus to Active when nothing saved. |
| 2304 |
if ('StandardStatus' === $key && '' === $value) { |
| 2305 |
$value = ['Active']; |
| 2306 |
} |
| 2307 |
|
| 2308 |
|
| 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 |
|
| 2318 |
// Additional conditions can be placed here. |
| 2319 |
?> |
| 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): ?> |
| 2326 |
|
| 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); |
| 2334 |
|
| 2335 |
// Label = the enum's mapped name (identity for |
| 2336 |
// name=>name MLSs, city name for code=>name |
| 2337 |
// providers like Centris). Value stays the key. |
| 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 |
} |
| 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 |
|
| 2370 |
<?php elseif ($field['type'] === 'input'): ?> |
| 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); ?>"> |
| 2372 |
<?php endif; ?> |
| 2373 |
<?php if ('extraCity' === $key || 'extraCounty' === $key): ?> |
| 2374 |
</div> |
| 2375 |
<?php endif; ?> |
| 2376 |
</fieldset> |
| 2377 |
<?php endforeach; ?> |
| 2378 |
|
| 2379 |
</div> |
| 2380 |
<?php |
| 2381 |
// Return the buffered form markup. |
| 2382 |
return ob_get_clean(); |
| 2383 |
} |
| 2384 |
|
| 2385 |
|
| 2386 |
|
| 2387 |
|
| 2388 |
|
| 2389 |
|
| 2390 |
// Placeholder hook target for injecting additional Import Task fields (no-op). |
| 2391 |
public function mlsimport_add_extra_fields() { |
| 2392 |
} |
| 2393 |
|
| 2394 |
/** |
| 2395 |
* Per-field help text override, keyed by MLS + meta field. |
| 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. |
| 2399 |
* |
| 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 |
| 2404 |
*/ |
| 2405 |
function mlsimport_notes_for_mls( $mlsimport_mls_id, $name, $description ) { |
| 2406 |
// 111 - Rae Edmonton |
| 2407 |
|
| 2408 |
if ( 111 === intval($mlsimport_mls_id) && 'mlsimport_item_standardstatus' === $name ) { |
| 2409 |
return esc_html__( 'Your MLS does not use this field - all listings are considered Active.', 'mlsimport' ); |
| 2410 |
} else { |
| 2411 |
return $description; |
| 2412 |
} |
| 2413 |
} |
| 2414 |
|
| 2415 |
|
| 2416 |
/** |
| 2417 |
* Return the "last checked" timestamp for an Import Task, seeding it if unset. |
| 2418 |
* |
| 2419 |
* @param int $item_id Import Task post id. |
| 2420 |
* @return string A 'Y-m-d\TH:i' timestamp. |
| 2421 |
*/ |
| 2422 |
public function mlsimport_saas_get_last_date( $item_id ) { |
| 2423 |
// Stored watermark used as the modification-time filter for syncs. |
| 2424 |
$last_date = get_post_meta( $item_id, 'mlsimport_last_date', true ); |
| 2425 |
|
| 2426 |
// First run: initialize it. |
| 2427 |
if ( '' === $last_date ) { |
| 2428 |
$last_date = $this->mlsimport_saas_update_last_date( $item_id ); |
| 2429 |
} |
| 2430 |
return $last_date; |
| 2431 |
} |
| 2432 |
|
| 2433 |
|
| 2434 |
/** |
| 2435 |
* Set the Import Task's "last checked" watermark to 2 hours ago and store it. |
| 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. |
| 2439 |
* |
| 2440 |
* @param int $item_id Import Task post id. |
| 2441 |
* @return string The stored 'Y-m-d\TH:i' timestamp. |
| 2442 |
*/ |
| 2443 |
public function mlsimport_saas_update_last_date( $item_id ) { |
| 2444 |
|
| 2445 |
// Current site time minus 2 hours, formatted as an ISO-ish local stamp. |
| 2446 |
$unix_time = current_time( 'timestamp', 0 ) - ( 2 * 60 * 60 ); |
| 2447 |
print $last_date_to_save = date( 'Y-m-d\TH:i', $unix_time ); |
| 2448 |
update_post_meta( $item_id, 'mlsimport_last_date', $last_date_to_save ); |
| 2449 |
|
| 2450 |
return $last_date_to_save; |
| 2451 |
} |
| 2452 |
|
| 2453 |
|
| 2454 |
|
| 2455 |
|
| 2456 |
|
| 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; |
| 2479 |
} |
| 2480 |
mlsimport_alert_resolve( $incident ); |
| 2481 |
|
| 2482 |
$start = $this->mlsimport_import_task_execution()->start( |
| 2483 |
array( |
| 2484 |
'task_id' => $item_id, |
| 2485 |
'source' => 'automatic', |
| 2486 |
) |
| 2487 |
); |
| 2488 |
// Hourly work is already in the background. If another import owns the |
| 2489 |
// site-wide slot, this task simply waits for the next normal hourly run. |
| 2490 |
if ( true !== ( $start['accepted'] ?? false ) ) { |
| 2491 |
return 0; |
| 2492 |
} |
| 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' ) ); |
| 2499 |
|
| 2500 |
// Same rules as the manual worker: a large hourly sync must not be |
| 2501 |
// killed by the web/cron request time limit mid-run, and term counts |
| 2502 |
// are recomputed once after the run instead of per assignment. |
| 2503 |
if ( function_exists( 'set_time_limit' ) ) { |
| 2504 |
@set_time_limit( 0 ); // phpcs:ignore |
| 2505 |
} |
| 2506 |
wp_defer_term_counting( true ); |
| 2507 |
$result = $this->mlsimport_import_task_execution()->execute( (string) $start['run_id'] ); |
| 2508 |
wp_defer_term_counting( false ); |
| 2509 |
// A 'running' result is a chunk hand-off (issue #330): the cron request |
| 2510 |
// spent its 45-second budget on this task and a background worker now |
| 2511 |
// carries the run to the end, keeping the site-wide slot. The hourly |
| 2512 |
// loop moves on; tasks behind this one are refused by that slot and |
| 2513 |
// get their turn on the next run, ordered by last attempt. |
| 2514 |
mlsimport_saas_single_write_import_custom_logs( |
| 2515 |
'running' === (string) $result['state'] |
| 2516 |
? 'Automatic import for task ' . $item_id . ' handed off at ' . (int) ( $result['saved'] + $result['failed'] ) . ' listings; background worker queued.' . PHP_EOL |
| 2517 |
: 'Automatic import for task ' . $item_id . ' finished with state ' . (string) $result['state'] . '.' . PHP_EOL, |
| 2518 |
'cron' |
| 2519 |
); |
| 2520 |
gc_collect_cycles(); |
| 2521 |
|
| 2522 |
return (int) $result['found']; |
| 2523 |
} |
| 2524 |
|
| 2525 |
|
| 2526 |
|
| 2527 |
|
| 2528 |
|
| 2529 |
|
| 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 |
} |
| 2547 |
|
| 2548 |
/** |
| 2549 |
* Fetch the reconciliation feed (all current ListingKeys) from the SaaS API. |
| 2550 |
* |
| 2551 |
* A positive mls_id scopes the request to one connection (#279): |
| 2552 |
* GET reconciliation?mls_id=X, whose response must echo the mls_id back |
| 2553 |
* before the caller may use it (the #276 echo guard). With 0 (default) |
| 2554 |
* the request stays the legacy unscoped account snapshot. |
| 2555 |
* |
| 2556 |
* @param int $mls_id Connection to scope the snapshot to; 0 = unscoped. |
| 2557 |
* @return array The API response, expected to carry an 'all_data' key. |
| 2558 |
*/ |
| 2559 |
public function mlsimport_saas_get_mls_reconciliation_data( $mls_id = 0 ) { |
| 2560 |
|
| 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' ); |
| 2564 |
return $answer; |
| 2565 |
} |
| 2566 |
|
| 2567 |
/** |
| 2568 |
* Return all published posts' values for a given meta key, with their post ids. |
| 2569 |
* |
| 2570 |
* @param string $key Meta key to fetch. |
| 2571 |
* @return array Rows of {meta_value, ID}. |
| 2572 |
*/ |
| 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 |
} |
| 2590 |
|
| 2591 |
|
| 2592 |
|
| 2593 |
/** |
| 2594 |
* Run a single listings request for an Import Task and return the API result. |
| 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. |
| 2600 |
* |
| 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 ); |
| 2611 |
|
| 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']; |
| 2616 |
return array( |
| 2617 |
'success' => false, |
| 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' ), |
| 2620 |
); |
| 2621 |
} |
| 2622 |
|
| 2623 |
// Guard against an over-long query string (too many parameters selected). |
| 2624 |
$potential_leght = strlen( wp_json_encode( $arguments ) ); |
| 2625 |
if ( $potential_leght > 1750 ) { |
| 2626 |
return array( |
| 2627 |
'success' => false, |
| 2628 |
'potential_leght' => $potential_leght, |
| 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' ), |
| 2630 |
); |
| 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 |
} |
| 2643 |
|
| 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. |
| 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 |
|
| 2683 |
return ( $answer ); |
| 2684 |
} |
| 2685 |
|
| 2686 |
|
| 2687 |
|
| 2688 |
|
| 2689 |
|
| 2690 |
|
| 2691 |
/** |
| 2692 |
* Assemble the RESO listings query arguments from an Import Task's meta. |
| 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). |
| 2700 |
* |
| 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 ) { |
| 2709 |
|
| 2710 |
// MLS id is mandatory — resolved through the TASK's own connection |
| 2711 |
// binding (#277), never the global selection. Unstamped legacy tasks |
| 2712 |
// fall back to the current connection inside the resolver. |
| 2713 |
$options = get_option( $this->plugin_name . '_admin_options' ); |
| 2714 |
$mls_id = mlsimport_task_mls_id( (int) $item_id ); |
| 2715 |
if ( $mls_id <= 0 ) { |
| 2716 |
return ''; |
| 2717 |
} |
| 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). |
| 2721 |
if ( isset( $options['mlsimport_theme_used'] ) ) { |
| 2722 |
$theme_id = intval( $options['mlsimport_theme_used'] ); |
| 2723 |
} else { |
| 2724 |
return ''; |
| 2725 |
} |
| 2726 |
|
| 2727 |
// Base parameters every request carries. |
| 2728 |
$values = array(); |
| 2729 |
$values['mls_id'] = $mls_id; |
| 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 |
} |
| 2735 |
|
| 2736 |
// Pagination (only when a page size was supplied). |
| 2737 |
if ( '' !== $top ) { |
| 2738 |
$values['top'] = $top; |
| 2739 |
$values['skip'] = intval( $skip ); |
| 2740 |
} |
| 2741 |
|
| 2742 |
// // add price |
| 2743 |
// Price range (only when both bounds are set). |
| 2744 |
$mlsimport_item_min_price = get_post_meta( $item_id, 'mlsimport_item_min_price', true ); |
| 2745 |
$mlsimport_item_max_price = get_post_meta( $item_id, 'mlsimport_item_max_price', true ); |
| 2746 |
if ( '' !== $mlsimport_item_min_price && '' !== $mlsimport_item_max_price ) { |
| 2747 |
$values['list_price_min'] = floatval( $mlsimport_item_min_price ); |
| 2748 |
$values['list_price_max'] = floatval( $mlsimport_item_max_price ); |
| 2749 |
} |
| 2750 |
|
| 2751 |
// add city |
| 2752 |
$values = $this->mls_import_return_multiple_param_value( 'city', $item_id, 'city', $values ); |
| 2753 |
|
| 2754 |
// add county |
| 2755 |
$values = $this->mls_import_return_multiple_param_value( 'countyorparish', $item_id, 'county_or_parish', $values ); |
| 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 |
|
| 2763 |
// add postal code |
| 2764 |
$values = $this->mls_import_saas_add_to_parms_input( 'PostalCode', $item_id, 'postal_code', $values ); |
| 2765 |
|
| 2766 |
// add status |
| 2767 |
|
| 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 ); |
| 2771 |
|
| 2772 |
// add property_subtype |
| 2773 |
$values = $this->mls_import_return_multiple_param_value( 'PropertySubType', $item_id, 'property_subtype', $values ); |
| 2774 |
|
| 2775 |
// add property_type |
| 2776 |
$values = $this->mls_import_return_multiple_param_value( 'PropertyType', $item_id, 'property_type', $values ); |
| 2777 |
|
| 2778 |
// add internet_entirelisting_displayyn |
| 2779 |
$values = $this->mls_import_saas_add_to_parms_input( 'InternetEntireListingDisplayYN', $item_id, 'internet_entirelisting_displayyn', $values ); |
| 2780 |
|
| 2781 |
// add internet_address_displayyn |
| 2782 |
$values = $this->mls_import_saas_add_to_parms_input( 'InternetAddressDisplayYN', $item_id, 'internet_address_displayyn', $values ); |
| 2783 |
|
| 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 ); |
| 2792 |
// add ListOfficeMlsId |
| 2793 |
$values = $this->mls_import_saas_add_to_parms_input( 'ListOfficeMlsId', $item_id, 'list_officemlsid', $values ); |
| 2794 |
|
| 2795 |
// add ListingId |
| 2796 |
$values = $this->mls_import_saas_add_to_parms_input( 'ListingId', $item_id, 'listingid', $values ); |
| 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 |
|
| 2802 |
//add Exclude_ListOfficeKey |
| 2803 |
$values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeKey', $item_id, 'exclude_list_officekey', $values ); |
| 2804 |
// add Exclude_ListOfficeMlsId |
| 2805 |
$values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListOfficeMlsId', $item_id, 'exclude_list_officemlsid', $values ); |
| 2806 |
|
| 2807 |
|
| 2808 |
|
| 2809 |
//add Exclude_ListAgentKey |
| 2810 |
$values = $this->mls_import_saas_add_to_parms_input( 'Exclude_ListAgentKey', $item_id, 'exclude_list_agentkey', $values ); |
| 2811 |
// add Exclude_ListAgentMlsId |
| 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 ); |
| 2815 |
|
| 2816 |
|
| 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() ); |
| 2827 |
} |
| 2828 |
|
| 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']; |
| 2835 |
} |
| 2836 |
|
| 2837 |
|
| 2838 |
|
| 2839 |
/** |
| 2840 |
* Copy a single scalar Import Task meta value into the arguments array. |
| 2841 |
* |
| 2842 |
* Reads mlsimport_item_<key> and, when non-empty, stores it under $new_name. |
| 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. |
| 2849 |
*/ |
| 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. |
| 2852 |
$name = strtolower( 'mlsimport_item_' . $key ); |
| 2853 |
$value = get_post_meta( $post_id, $name, true ); |
| 2854 |
if ( '' !== $value ) { |
| 2855 |
$all_values[ $new_name ] = $value; |
| 2856 |
} |
| 2857 |
|
| 2858 |
return $all_values; |
| 2859 |
} |
| 2860 |
|
| 2861 |
|
| 2862 |
/** |
| 2863 |
* Copy a multi-value (list) Import Task meta value into the arguments array. |
| 2864 |
* |
| 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. |
| 2875 |
*/ |
| 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. |
| 2878 |
$name_check = strtolower( 'mlsimport_item_' . $key . '_check' ); |
| 2879 |
$name = strtolower( 'mlsimport_item_' . $key ); |
| 2880 |
|
| 2881 |
$value = get_post_meta( $post_id, $name, true ); |
| 2882 |
|
| 2883 |
// add extra county - should be moved into function if pass tests |
| 2884 |
if ( 'countyorparish' === $key ) { |
| 2885 |
$extracounty_values = get_post_meta( $post_id, 'mlsimport_item_extracounty', true ); |
| 2886 |
|
| 2887 |
if ( '' !== $extracounty_values ) { |
| 2888 |
$extracounty_array = explode( ',', $extracounty_values ); |
| 2889 |
|
| 2890 |
if ( ! is_array( $value ) ) { |
| 2891 |
if ( '' === $value ) { |
| 2892 |
$value = array(); |
| 2893 |
} else { |
| 2894 |
$value = array( $value ); |
| 2895 |
} |
| 2896 |
} |
| 2897 |
|
| 2898 |
foreach ( $extracounty_array as $extra ) { |
| 2899 |
$value[] = $extra; |
| 2900 |
} |
| 2901 |
} |
| 2902 |
} |
| 2903 |
|
| 2904 |
// add extra city - should be moved into function if pass tests |
| 2905 |
if ( 'city' === $key ) { |
| 2906 |
$extracity_values = get_post_meta( $post_id, 'mlsimport_item_extracity', true ); |
| 2907 |
if ( '' !== $extracity_values ) { |
| 2908 |
$extracity_array = explode( ',', $extracity_values ); |
| 2909 |
|
| 2910 |
if ( ! is_array( $value ) ) { |
| 2911 |
if ('' === $value ) { |
| 2912 |
$value = array(); |
| 2913 |
} else { |
| 2914 |
$value = array( $value ); |
| 2915 |
} |
| 2916 |
} |
| 2917 |
|
| 2918 |
foreach ( $extracity_array as $extra ) { |
| 2919 |
$value[] = $extra; |
| 2920 |
} |
| 2921 |
} |
| 2922 |
} |
| 2923 |
|
| 2924 |
// Only include the list when "select all" is off and there is a value. |
| 2925 |
$value_check = get_post_meta( $post_id, $name_check, true ); |
| 2926 |
|
| 2927 |
if ( 0 === intval($value_check) && '' !== $value ) { |
| 2928 |
$all_values[ $new_name ] = $value; |
| 2929 |
} |
| 2930 |
|
| 2931 |
// status exception: always send status, regardless of the check flag. |
| 2932 |
if ( 'status' === $new_name ) { |
| 2933 |
$all_values[ $new_name ] = $value; |
| 2934 |
} |
| 2935 |
|
| 2936 |
return $all_values; |
| 2937 |
} |
| 2938 |
|
| 2939 |
|
| 2940 |
|
| 2941 |
/** |
| 2942 |
* Build the Import Task field definition list (labels, types, enum values). |
| 2943 |
* |
| 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. |
| 2949 |
* |
| 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 ) { |
| 2954 |
|
| 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 ); |
| 2958 |
|
| 2959 |
// Warn the user when no metadata is available yet. |
| 2960 |
if ( '' === $mlsimport_mls_metadata_mls_enums ) { |
| 2961 |
?> |
| 2962 |
<div class="mlsimport_warning long_warning">Please select the import fields(from MLS Import Settings) before starting a MLS import process.</div> |
| 2963 |
<?php |
| 2964 |
} |
| 2965 |
|
| 2966 |
// Decode and reach into the enum container. |
| 2967 |
$metadata_api_call_full = json_decode( $mlsimport_mls_metadata_mls_enums, true ); |
| 2968 |
|
| 2969 |
if ( isset( $metadata_api_call_full['global_array'] ) ) { |
| 2970 |
$metadata_api_call = $metadata_api_call_full['global_array']; |
| 2971 |
} |
| 2972 |
|
| 2973 |
// Extract each enum list as a flat array of option keys (empty if absent). |
| 2974 |
$city_array = array(); |
| 2975 |
if ( isset( $metadata_api_call['PropertyEnums']['City'] ) && is_array( $metadata_api_call['PropertyEnums']['City'] ) ) { |
| 2976 |
$city_array = array_keys( $metadata_api_call['PropertyEnums']['City'] ); |
| 2977 |
} |
| 2978 |
|
| 2979 |
$county_array = array(); |
| 2980 |
if ( isset( $metadata_api_call['PropertyEnums']['CountyOrParish'] ) && is_array( $metadata_api_call['PropertyEnums']['CountyOrParish'] ) ) { |
| 2981 |
$county_array = array_keys( $metadata_api_call['PropertyEnums']['CountyOrParish'] ); |
| 2982 |
} |
| 2983 |
|
| 2984 |
$mlsstatus_array = array(); |
| 2985 |
if ( isset( $metadata_api_call['PropertyEnums']['MlsStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['MlsStatus'] ) ) { |
| 2986 |
$mlsstatus_array = array_keys( $metadata_api_call['PropertyEnums']['MlsStatus'] ); |
| 2987 |
} |
| 2988 |
|
| 2989 |
$propertysubtype_array = array(); |
| 2990 |
if ( isset( $metadata_api_call['PropertyEnums']['PropertySubType'] ) && is_array( $metadata_api_call['PropertyEnums']['PropertySubType'] ) ) { |
| 2991 |
$propertysubtype_array = array_keys( $metadata_api_call['PropertyEnums']['PropertySubType'] ); |
| 2992 |
} |
| 2993 |
|
| 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 |
} |
| 2998 |
|
| 2999 |
|
| 3000 |
$standardstatus_array = array(); |
| 3001 |
if ( isset( $metadata_api_call['PropertyEnums']['StandardStatus'] ) && is_array( $metadata_api_call['PropertyEnums']['StandardStatus'] ) ) { |
| 3002 |
$standardstatus_array = array_keys( $metadata_api_call['PropertyEnums']['StandardStatus'] ); |
| 3003 |
} |
| 3004 |
|
| 3005 |
// if we do not have standart status |
| 3006 |
// Fall back to MlsStatus values when the MLS exposes no StandardStatus. |
| 3007 |
if ( empty( $standardstatus_array ) ) { |
| 3008 |
$standardstatus_array = $mlsstatus_array; |
| 3009 |
} |
| 3010 |
|
| 3011 |
|
| 3012 |
|
| 3013 |
|
| 3014 |
// Free-text "extra" inputs render empty; they hold comma-separated values. |
| 3015 |
$extracounty_values = ''; |
| 3016 |
$extracity_values = ''; |
| 3017 |
|
| 3018 |
// Ordered field definitions consumed by the Import Task metabox renderer. |
| 3019 |
$field_import = array( |
| 3020 |
'City' => array( |
| 3021 |
'label' => esc_html__( 'Select cities', 'mlsimport' ), |
| 3022 |
'description' => esc_html__( 'Select the cities from where we will import data.', 'mlsimport' ), |
| 3023 |
'type' => 'select', |
| 3024 |
'multiple' => 'yes', |
| 3025 |
'values' => $city_array, |
| 3026 |
), |
| 3027 |
|
| 3028 |
'extraCity' => array( |
| 3029 |
'label' => esc_html__( 'Add extra Cities', 'mlsimport' ), |
| 3030 |
'description' => esc_html__( 'Add extra cities, separated by comma. They need to be written exactly like they are stored in MLS (for example all caps)', 'mlsimport' ), |
| 3031 |
'type' => 'input', |
| 3032 |
'multiple' => 'no', |
| 3033 |
'values' => $extracity_values, |
| 3034 |
), |
| 3035 |
|
| 3036 |
'CountyOrParish' => array( |
| 3037 |
'label' => esc_html__( 'Select Counties', 'mlsimport' ), |
| 3038 |
'description' => esc_html__( 'Select the counties from where we will import data.', 'mlsimport' ), |
| 3039 |
'type' => 'select', |
| 3040 |
'multiple' => 'yes', |
| 3041 |
'values' => $county_array, |
| 3042 |
'show_extra_field' => true, |
| 3043 |
), |
| 3044 |
|
| 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 |
), |
| 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 |
|
| 3067 |
'PostalCode' => array( |
| 3068 |
'label' => esc_html__( 'Select Postal Code', 'mlsimport' ), |
| 3069 |
'description' => esc_html__( 'Enter one or more postal codes to import listings from, separated by commas (e.g. 12345, 23456).', 'mlsimport' ), |
| 3070 |
'type' => 'input', |
| 3071 |
'multiple' => 'no', |
| 3072 |
), |
| 3073 |
|
| 3074 |
'PropertySubType' => array( |
| 3075 |
'label' => esc_html__( 'Select Property Category', 'mlsimport' ), |
| 3076 |
'description' => esc_html__( 'Property Category', 'mlsimport' ), |
| 3077 |
'type' => 'select', |
| 3078 |
'multiple' => 'yes', |
| 3079 |
'values' => $propertysubtype_array, |
| 3080 |
), |
| 3081 |
'PropertyType' => array( |
| 3082 |
'label' => esc_html__( 'Select Property Action Category', 'mlsimport' ), |
| 3083 |
'description' => esc_html__( 'Property Action Category', 'mlsimport' ), |
| 3084 |
'type' => 'select', |
| 3085 |
'multiple' => 'yes', |
| 3086 |
'values' => $propertytype_array, |
| 3087 |
), |
| 3088 |
'StandardStatus' => array( |
| 3089 |
'label' => esc_html__( 'Select Status', 'mlsimport' ), |
| 3090 |
'description' => __( 'The list is auto-populated with MLS available statuses. To select multiple statuses, use Ctrl (Windows) or Command (Mac).', 'mlsimport' ), |
| 3091 |
'type' => 'select', |
| 3092 |
'multiple' => 'yes', |
| 3093 |
'values' => $standardstatus_array, |
| 3094 |
), |
| 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' ), |
| 3098 |
'type' => 'select', |
| 3099 |
'multiple' => 'yes', |
| 3100 |
'values' => $standardstatus_array, |
| 3101 |
), |
| 3102 |
|
| 3103 |
'InternetEntireListingDisplayYN' => array( |
| 3104 |
'label' => esc_html__( 'Internet Entire Listing Display ', 'mlsimport'), |
| 3105 |
'description' => esc_html__( 'A yes/no field that states the seller has allowed the listing to be displayed on Internet sites.', 'mlsimport' ), |
| 3106 |
'type' => 'select', |
| 3107 |
'multiple' => 'no', |
| 3108 |
'values' => array( |
| 3109 |
'yes', |
| 3110 |
'no', |
| 3111 |
), |
| 3112 |
), |
| 3113 |
'InternetAddressDisplayYN' => array( |
| 3114 |
'label' => esc_html__( 'Internet Address display', 'mlsimport' ), |
| 3115 |
'description' => esc_html__( 'A yes/no field that states the seller has allowed the listing address to be displayed on Internet sites.', 'mlsimport' ), |
| 3116 |
'type' => 'select', |
| 3117 |
'multiple' => 'no', |
| 3118 |
'values' => array( |
| 3119 |
'yes', |
| 3120 |
'no', |
| 3121 |
), |
| 3122 |
), |
| 3123 |
'ListAgentKey' => array( |
| 3124 |
'label' => esc_html__( 'ListAgentKey', 'mlsimport' ), |
| 3125 |
'description' => esc_html__( 'Import listings from a specific Agent (contact your MLS for this information)', 'mlsimport' ), |
| 3126 |
'type' => 'input', |
| 3127 |
'multiple' => 'no', |
| 3128 |
), |
| 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', |
| 3146 |
), |
| 3147 |
'ListOfficeMlsId' => array( |
| 3148 |
'label' => esc_html__( 'ListOfficeMlsId', 'mlsimport' ), |
| 3149 |
'description' => esc_html__( 'Import listings from a specific Office (contact your MLS for this information)', 'mlsimport' ), |
| 3150 |
'type' => 'input', |
| 3151 |
'multiple' => 'no', |
| 3152 |
), |
| 3153 |
'ListingId' => array( |
| 3154 |
'label' => esc_html__( 'ListingId', 'mlsimport' ), |
| 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'), |
| 3156 |
'type' => 'input', |
| 3157 |
'multiple' => 'no', |
| 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 |
), |
| 3165 |
'Exclude_ListOfficeMlsId' => array( |
| 3166 |
'label' => esc_html__( 'Exclude listings with ListOfficeMlsId', 'mlsimport' ), |
| 3167 |
'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeMlsId.', 'mlsimport' ), |
| 3168 |
'type' => 'input', |
| 3169 |
'multiple' => 'no', |
| 3170 |
), |
| 3171 |
'Exclude_ListOfficeKey' => array( |
| 3172 |
'label' => esc_html__( 'Exclude listings with ListOfficeKey', 'mlsimport' ), |
| 3173 |
'description' => esc_html__( 'Exclude listings that belong to one or more ListOfficeKey', 'mlsimport'), |
| 3174 |
'type' => 'input', |
| 3175 |
'multiple' => 'no', |
| 3176 |
), |
| 3177 |
|
| 3178 |
|
| 3179 |
'Exclude_ListAgentMlsId' => array( |
| 3180 |
'label' => esc_html__( 'Exclude listings with ListAgentMlsId', 'mlsimport' ), |
| 3181 |
'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentMlsId.', 'mlsimport' ), |
| 3182 |
'type' => 'input', |
| 3183 |
'multiple' => 'no', |
| 3184 |
), |
| 3185 |
'Exclude_ListAgentKey' => array( |
| 3186 |
'label' => esc_html__( 'Exclude listings with ListAgentKey ', 'mlsimport' ), |
| 3187 |
'description' => esc_html__( 'Exclude listings that belong to one or more ListAgentKey ', 'mlsimport'), |
| 3188 |
'type' => 'input', |
| 3189 |
'multiple' => 'no', |
| 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 |
), |
| 3197 |
|
| 3198 |
|
| 3199 |
); |
| 3200 |
return $field_import; |
| 3201 |
} |
| 3202 |
|
| 3203 |
|
| 3204 |
|
| 3205 |
|
| 3206 |
|
| 3207 |
|
| 3208 |
|
| 3209 |
/** |
| 3210 |
* AJAX: kick off a manual import for one Import Task. |
| 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}. |
| 3216 |
* |
| 3217 |
* @return void Emits JSON. |
| 3218 |
*/ |
| 3219 |
public function mlsimport_move_files_per_item() { |
| 3220 |
check_ajax_referer( 'mlsimport_item_actions', 'security' ); |
| 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 ); |
| 3230 |
} |
| 3231 |
|
| 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 |
) |
| 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 |
} |
| 3252 |
|
| 3253 |
mlsimport_saas_single_write_import_custom_logs( 'Manual import queued for task ' . $post_id . '.' . PHP_EOL, 'manual' ); |
| 3254 |
mlsimport_debuglogs_per_plugin( 'Manual import queued for task ' . $post_id . '.' . PHP_EOL ); |
| 3255 |
|
| 3256 |
$this->mlsimport_enqueue_import_worker( (string) $start['run_id'] ); |
| 3257 |
|
| 3258 |
wp_send_json( |
| 3259 |
array( |
| 3260 |
'success' => true, |
| 3261 |
'run_id' => (string) $start['run_id'], |
| 3262 |
) |
| 3263 |
); |
| 3264 |
} |
| 3265 |
|
| 3266 |
/** |
| 3267 |
* Queue the background import worker for an accepted Import Run. |
| 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. |
| 3278 |
* |
| 3279 |
* @param string $run_id Accepted run identity to hand to the worker. |
| 3280 |
* @return void |
| 3281 |
*/ |
| 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 ); |
| 3300 |
} |
| 3301 |
|
| 3302 |
// Only the small run identity crosses the HTTP/background boundary. The |
| 3303 |
// runner reads the request and progress from WordPress when it wakes. |
| 3304 |
as_enqueue_async_action( |
| 3305 |
'mlsimport_background_process_per_item', |
| 3306 |
array( 'args' => array( 'run_id' => $run_id ) ) |
| 3307 |
); |
| 3308 |
spawn_cron(); |
| 3309 |
} |
| 3310 |
|
| 3311 |
/** |
| 3312 |
* Return the adapter configuration error that blocks Stored mode imports. |
| 3313 |
* |
| 3314 |
* @return string Empty when a supported adapter was composed. |
| 3315 |
*/ |
| 3316 |
public function mlsimport_stored_listing_configuration_error(): string { |
| 3317 |
return $this->stored_listing_configuration_error; |
| 3318 |
} |
| 3319 |
|
| 3320 |
|
| 3321 |
|
| 3322 |
|
| 3323 |
|
| 3324 |
|
| 3325 |
|
| 3326 |
|
| 3327 |
|
| 3328 |
|
| 3329 |
|
| 3330 |
|
| 3331 |
/** |
| 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 |
| 3339 |
*/ |
| 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 |
} |
| 3346 |
|
| 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 |
); |
| 3373 |
|
| 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 ); |
| 3386 |
|
| 3387 |
$result = $this->mlsimport_import_task_execution()->execute( $run_id ); |
| 3388 |
// Recount the deferred term totals now that this worker is done. A |
| 3389 |
// hand-off recounts per chunk, which keeps counts correct even if a |
| 3390 |
// later chunk in the chain dies. |
| 3391 |
wp_defer_term_counting( false ); |
| 3392 |
// A 'running' result is a chunk hand-off (issue #199): this worker |
| 3393 |
// spent its time budget and already queued the follow-up worker. Every |
| 3394 |
// exit line carries timing, totals, and memory so one run's log is a |
| 3395 |
// complete health trace of the whole worker chain. |
| 3396 |
$worker_trace = ' Elapsed ' . round( microtime( true ) - $worker_started_at, 1 ) . 's,' |
| 3397 |
. ' saved ' . (int) $result['saved'] . ', failed ' . (int) $result['failed'] . ',' |
| 3398 |
. ' memory ' . round( memory_get_usage( true ) / 1048576 ) . 'MB,' |
| 3399 |
. ' peak ' . round( memory_get_peak_usage( true ) / 1048576 ) . 'MB.' |
| 3400 |
. ( '' !== (string) $result['error'] ? ' Error: ' . (string) $result['error'] : '' ); |
| 3401 |
mlsimport_saas_single_write_import_custom_logs( |
| 3402 |
'running' === (string) $result['state'] |
| 3403 |
? 'Import chunk handed off at ' . (int) ( $result['saved'] + $result['failed'] ) . ' listings; next worker queued.' . $worker_trace . PHP_EOL |
| 3404 |
: 'Import worker finished with state ' . (string) $result['state'] . '.' . $worker_trace . PHP_EOL, |
| 3405 |
'manual' |
| 3406 |
); |
| 3407 |
gc_collect_cycles(); |
| 3408 |
} |
| 3409 |
|
| 3410 |
|
| 3411 |
|
| 3412 |
|
| 3413 |
|
| 3414 |
|
| 3415 |
|
| 3416 |
|
| 3417 |
|
| 3418 |
/** |
| 3419 |
* AJAX: poll import status/logs for a task (drives the progress UI). |
| 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). |
| 3424 |
* |
| 3425 |
* @return void Emits JSON then dies. |
| 3426 |
*/ |
| 3427 |
public function mlsimport_logger_per_item() { |
| 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 |
} |
| 3439 |
$post_id=0; |
| 3440 |
if(isset($_POST['post_id'] )){ |
| 3441 |
$post_id = intval( $_POST['post_id'] ); |
| 3442 |
} |
| 3443 |
|
| 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 |
} |
| 3457 |
|
| 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 ) : ''; |
| 3463 |
|
| 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 |
); |
| 3482 |
} |
| 3483 |
|
| 3484 |
|
| 3485 |
|
| 3486 |
|
| 3487 |
|
| 3488 |
|
| 3489 |
/** |
| 3490 |
* AJAX: request a force-stop of a running import for one task. |
| 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. |
| 3494 |
* |
| 3495 |
* @return void Emits JSON success. |
| 3496 |
*/ |
| 3497 |
public function mlsimport_stop_import_per_item() { |
| 3498 |
|
| 3499 |
|
| 3500 |
// CSRF + read the task id. |
| 3501 |
check_ajax_referer( 'mlsimport_item_actions', 'security' ); |
| 3502 |
$post_id=0; |
| 3503 |
if(isset($_POST['post_id'] )){ |
| 3504 |
$post_id = intval( $_POST['post_id'] ); |
| 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. |
| 3513 |
update_option( 'mlsimport_force_stop_' . $post_id, 'yes', false ); |
| 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'] ) ); |
| 3517 |
} |
| 3518 |
|
| 3519 |
|
| 3520 |
|
| 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. |
| 3524 |
* |
| 3525 |
* Thin wrapper since #281: nonce + capability here, the actual gather / |
| 3526 |
* persist / reconcile sequence lives in the shared connection-scoped core |
| 3527 |
* mlsimport_gather_connection_metadata() (includes/mlsimport-metadata- |
| 3528 |
* gather.php). An optional posted mls_id scopes the gather to one |
| 3529 |
* registered connection (per-connection field mapping UI); without one the |
| 3530 |
* request resolves to the CURRENT connection — exactly the historic |
| 3531 |
* behavior of this handler, including response shapes and status codes. |
| 3532 |
* |
| 3533 |
* A NON-current scope re-runs that connection's record-scoped credential |
| 3534 |
* test first: the SaaS 'GET clients' returns the metadata of the MLS the |
| 3535 |
* account record last held, so without the PATCH the scoped connection |
| 3536 |
* would be seeded with another MLS's metadata (the step-1 caveat in |
| 3537 |
* mlsimport-metadata-gather.php). |
| 3538 |
* |
| 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 |
} |
| 3547 |
|
| 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 ); |
| 3550 |
|
| 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 |
} |
| 3565 |
|
| 3566 |
// One shared gather core, scoped to the resolved connection. |
| 3567 |
$gather = mlsimport_gather_connection_metadata( $mls_id ); |
| 3568 |
|
| 3569 |
// A response without the metadata shape changed nothing — retryable 502. |
| 3570 |
if ( 'request_failed' === $gather['code'] ) { |
| 3571 |
wp_send_json_error( |
| 3572 |
array( |
| 3573 |
'message' => $gather['message'], |
| 3574 |
'detail' => $gather['detail'], |
| 3575 |
), |
| 3576 |
502 |
| 3577 |
); |
| 3578 |
} |
| 3579 |
|
| 3580 |
// Reconcile failure returns the raw Field Configuration result (500), |
| 3581 |
// matching the historic response body for this case. |
| 3582 |
if ( ! $gather['success'] ) { |
| 3583 |
wp_send_json_error( $gather['result'], 500 ); |
| 3584 |
} |
| 3585 |
|
| 3586 |
wp_send_json_success( array( 'revision' => $gather['revision'] ) ); |
| 3587 |
} |
| 3588 |
|
| 3589 |
|
| 3590 |
|
| 3591 |
|
| 3592 |
|
| 3593 |
|
| 3594 |
|
| 3595 |
|
| 3596 |
|
| 3597 |
|
| 3598 |
|
| 3599 |
|
| 3600 |
/** |
| 3601 |
* Append a timestamped message to the cron log file. |
| 3602 |
* |
| 3603 |
* Arrays are JSON-encoded; ensures the WP filesystem is initialized before |
| 3604 |
* writing (append + exclusive lock). |
| 3605 |
* |
| 3606 |
* @param string|array $message Message to log. |
| 3607 |
* @return void |
| 3608 |
*/ |
| 3609 |
public function mlsimport_debuglog_cron( $message ) { |
| 3610 |
// Encode arrays for readability. |
| 3611 |
if ( is_array( $message ) ) { |
| 3612 |
$message = wp_json_encode( $message ); |
| 3613 |
} |
| 3614 |
// Prefix with a human-readable timestamp. |
| 3615 |
$message = date( 'F j, Y, g:i a' ) . ' -> ' . $message; |
| 3616 |
// Ensure WP_Filesystem is available (harmless if already set up). |
| 3617 |
global $wp_filesystem; |
| 3618 |
if ( empty( $wp_filesystem ) ) { |
| 3619 |
require_once ABSPATH . '/wp-admin/includes/file.php'; |
| 3620 |
WP_Filesystem(); |
| 3621 |
} |
| 3622 |
|
| 3623 |
// Append to the cron log with an exclusive lock. |
| 3624 |
$path = WP_PLUGIN_DIR . '/mlsimport/logs/cron_logs.log'; |
| 3625 |
|
| 3626 |
file_put_contents( $path, $message, FILE_APPEND | LOCK_EX ); |
| 3627 |
} |
| 3628 |
|
| 3629 |
} |
| 3630 |
|