| @@ -1,12 +1,68 @@ | ||
| 1 | -<?php | |
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * Helper functions for the MLSImport plugin. | |
| 4 | + * | |
| 5 | + * Grab-bag of stand-alone utilities used across the admin/import layers: | |
| 6 | + * - Standalone-mode detection (theme_id 990). | |
| 7 | + * - The canonical RESO -> theme field-mapping schema (mlsimport_hardocde_theme_schema), | |
| 8 | + * a large associative array telling the importer where each RESO field lands | |
| 9 | + * (post meta, taxonomy, media, or post content/title). | |
| 10 | + * - Taxonomy discovery for a post type, allowed-HTML whitelists, SaaS "ready to go | |
| 11 | + * MLS" list fetching, recursive array sanitisation, the reconciliation cron entry | |
| 12 | + * point, the Import Tasks admin list-table columns, and a plugin data reset routine. | |
| 13 | + * | |
| 14 | + * Loaded early in the bootstrap so these helpers are available to both admin and | |
| 15 | + * public code paths. | |
| 16 | + */ | |
| 2 | 17 | if ( ! defined( 'ABSPATH' ) ) { |
| 3 | 18 | exit; // Exit if accessed directly |
| 4 | 19 | } |
| 5 | 20 | |
| 6 | 21 | |
| 22 | +/** | |
| 23 | + * Whether the site runs in standalone (theme-agnostic) mode — theme_id 990. | |
| 24 | + * | |
| 25 | + * Drives the standalone write path / front-end gating. Reads the configured | |
| 26 | + * theme from mlsimport_admin_options['mlsimport_theme_used']. | |
| 27 | + * | |
| 28 | + * @return bool | |
| 29 | + */ | |
| 30 | +function mlsimport_is_standalone_mode() { | |
| 31 | + $options = get_option( 'mlsimport_admin_options' ); | |
| 32 | + return is_array( $options ) | |
| 33 | + && isset( $options['mlsimport_theme_used'] ) | |
| 34 | + && 990 === intval( $options['mlsimport_theme_used'] ); | |
| 35 | +} | |
| 36 | + | |
| 37 | + | |
| 38 | +/** | |
| 39 | + * Hard-coded RESO -> theme field-mapping schema (fallback / reference map). | |
| 40 | + * | |
| 41 | + * Returns a large associative array keyed by RESO Data Dictionary field name | |
| 42 | + * (e.g. 'ListPrice', 'BedroomsTotal', 'City'). Each value is a descriptor array | |
| 43 | + * describing where that RESO field is written on the WordPress side: | |
| 44 | + * - 'type' => one of: | |
| 45 | + * 'meta' - stored as a post meta value ('name' = meta key), | |
| 46 | + * 'taxonomy' - assigned as a term of taxonomy 'name'; an optional | |
| 47 | + * 'insert' gives the fixed term label to add (used for | |
| 48 | + * boolean *YN flags, e.g. 'Has Garage'), | |
| 49 | + * 'media' - handled by the media/gallery importer, | |
| 50 | + * 'content' - written to the post content (PublicRemarks). | |
| 51 | + * - 'name' => the destination meta key / taxonomy slug / field on the theme side. | |
| 52 | + * - 'insert' => (taxonomy only, optional) literal term label to insert when the | |
| 53 | + * RESO boolean is truthy. | |
| 54 | + * | |
| 55 | + * The keys are ordered alphabetically by RESO field name. Section markers below | |
| 56 | + * flag the larger logical clusters (agent/office contact blocks, distance-to-*, | |
| 57 | + * tax, etc.) but most fields are simple 1:1 meta passthroughs. | |
| 58 | + * | |
| 59 | + * @return array Field-name => descriptor map. | |
| 60 | + */ | |
| 7 | 61 | function mlsimport_hardocde_theme_schema(){ |
| 62 | + // Build and return the full descriptor map in one literal array. | |
| 8 | 63 | $theme_schema= array( |
| 64 | + // ---- Above/Below-grade area, access & accessibility (A) ---- | |
| 9 | 65 | 'AboveGradeFinishedArea' => array( |
| 10 | 66 | 'type' => 'meta', |
| 11 | 67 | 'name' => 'abovegradefinishedarea', |
| 12 | 68 | ), |
| @@ -62,8 +118,9 @@ | ||
| 62 | 118 | 'ArchitecturalStyle' => array( |
| 63 | 119 | 'type' => 'meta', |
| 64 | 120 | 'name' => 'architecturalstyle', |
| 65 | 121 | ), |
| 122 | + // ---- HOA / association fees & names ---- | |
| 66 | 123 | 'AssociationAmenities' => array( |
| 67 | 124 | 'type' => 'meta', |
| 68 | 125 | 'name' => 'associationamenities', |
| 69 | 126 | ), |
| @@ -137,8 +194,9 @@ | ||
| 137 | 194 | 'type' => 'taxonomy', |
| 138 | 195 | 'name' => 'property_features', |
| 139 | 196 | 'insert' => 'Has Basement', |
| 140 | 197 | ), |
| 198 | + // ---- Bath / bedroom counts (BathroomsTotalInteger & BedroomsTotal map to theme core fields) ---- | |
| 141 | 199 | 'BathroomsFull' => array( |
| 142 | 200 | 'type' => 'meta', |
| 143 | 201 | 'name' => 'bathroomsfull', |
| 144 | 202 | ), |
| @@ -233,8 +291,9 @@ | ||
| 233 | 291 | 'BusinessType' => array( |
| 234 | 292 | 'type' => 'meta', |
| 235 | 293 | 'name' => 'businesstype', |
| 236 | 294 | ), |
| 295 | + // ---- Buyer agent contact block (name/phone/email/license variants) ---- | |
| 237 | 296 | 'BuyerAgent' => array( |
| 238 | 297 | 'type' => 'meta', |
| 239 | 298 | 'name' => 'buyeragent', |
| 240 | 299 | ), |
| @@ -353,8 +412,9 @@ | ||
| 353 | 412 | 'BuyerFinancing' => array( |
| 354 | 413 | 'type' => 'meta', |
| 355 | 414 | 'name' => 'buyerfinancing', |
| 356 | 415 | ), |
| 416 | + // ---- Buyer office / brokerage block ---- | |
| 357 | 417 | 'BuyerOffice' => array( |
| 358 | 418 | 'type' => 'meta', |
| 359 | 419 | 'name' => 'buyeroffice', |
| 360 | 420 | ), |
| @@ -434,8 +494,9 @@ | ||
| 434 | 494 | 'CarrierRoute' => array( |
| 435 | 495 | 'type' => 'meta', |
| 436 | 496 | 'name' => 'carrierroute', |
| 437 | 497 | ), |
| 498 | + // ---- Location taxonomies: City -> property_city, CityRegion -> property_area, etc. ---- | |
| 438 | 499 | 'City' => array( |
| 439 | 500 | 'type' => 'taxonomy', |
| 440 | 501 | 'name' => 'property_city', |
| 441 | 502 | ), |
| @@ -450,8 +511,9 @@ | ||
| 450 | 511 | 'ClosePrice' => array( |
| 451 | 512 | 'type' => 'meta', |
| 452 | 513 | 'name' => 'closeprice', |
| 453 | 514 | ), |
| 515 | + // ---- Co-buyer agent contact block ---- | |
| 454 | 516 | 'CoBuyerAgent' => array( |
| 455 | 517 | 'type' => 'meta', |
| 456 | 518 | 'name' => 'cobuyeragent', |
| 457 | 519 | ), |
| @@ -558,8 +620,9 @@ | ||
| 558 | 620 | 'CoBuyerAgentVoiceMailExt' => array( |
| 559 | 621 | 'type' => 'meta', |
| 560 | 622 | 'name' => 'cobuyeragentvoicemailext', |
| 561 | 623 | ), |
| 624 | + // ---- Co-buyer office / brokerage block ---- | |
| 562 | 625 | 'CoBuyerOffice' => array( |
| 563 | 626 | 'type' => 'meta', |
| 564 | 627 | 'name' => 'cobuyeroffice', |
| 565 | 628 | ), |
| @@ -602,8 +665,9 @@ | ||
| 602 | 665 | 'CoBuyerOfficeURL' => array( |
| 603 | 666 | 'type' => 'meta', |
| 604 | 667 | 'name' => 'cobuyerofficeurl', |
| 605 | 668 | ), |
| 669 | + // ---- Co-listing agent contact block ---- | |
| 606 | 670 | 'CoListAgent' => array( |
| 607 | 671 | 'type' => 'meta', |
| 608 | 672 | 'name' => 'colistagent', |
| 609 | 673 | ), |
| @@ -710,8 +774,9 @@ | ||
| 710 | 774 | 'CoListAgentVoiceMailExt' => array( |
| 711 | 775 | 'type' => 'meta', |
| 712 | 776 | 'name' => 'colistagentvoicemailext', |
| 713 | 777 | ), |
| 778 | + // ---- Co-listing office / brokerage block ---- | |
| 714 | 779 | 'CoListOffice' => array( |
| 715 | 780 | 'type' => 'meta', |
| 716 | 781 | 'name' => 'colistoffice', |
| 717 | 782 | ), |
| @@ -893,8 +958,9 @@ | ||
| 893 | 958 | 'Disclosures' => array( |
| 894 | 959 | 'type' => 'meta', |
| 895 | 960 | 'name' => 'disclosures', |
| 896 | 961 | ), |
| 962 | + // ---- DistanceTo* family: comments/numeric/units triplets for each amenity ---- | |
| 897 | 963 | 'DistanceToBusComments' => array( |
| 898 | 964 | 'type' => 'meta', |
| 899 | 965 | 'name' => 'distancetobuscomments', |
| 900 | 966 | ), |
| @@ -1459,8 +1525,9 @@ | ||
| 1459 | 1525 | 'LicensesExpense' => array( |
| 1460 | 1526 | 'type' => 'meta', |
| 1461 | 1527 | 'name' => 'licensesexpense', |
| 1462 | 1528 | ), |
| 1529 | + // ---- Listing agent contact block ---- | |
| 1463 | 1530 | 'ListAgent' => array( |
| 1464 | 1531 | 'type' => 'meta', |
| 1465 | 1532 | 'name' => 'listagent', |
| 1466 | 1533 | ), |
| @@ -1603,8 +1670,9 @@ | ||
| 1603 | 1670 | 'ListingURLDescription' => array( |
| 1604 | 1671 | 'type' => 'meta', |
| 1605 | 1672 | 'name' => 'listingurldescription', |
| 1606 | 1673 | ), |
| 1674 | + // ---- Listing office / brokerage block ---- | |
| 1607 | 1675 | 'ListOffice' => array( |
| 1608 | 1676 | 'type' => 'meta', |
| 1609 | 1677 | 'name' => 'listoffice', |
| 1610 | 1678 | ), |
| @@ -1647,8 +1715,9 @@ | ||
| 1647 | 1715 | 'ListOfficeURL' => array( |
| 1648 | 1716 | 'type' => 'meta', |
| 1649 | 1717 | 'name' => 'listofficeurl', |
| 1650 | 1718 | ), |
| 1719 | + // ---- Core theme fields: ListPrice/LivingArea/LotSize/Lat-Long map to theme meta keys ---- | |
| 1651 | 1720 | 'ListPrice' => array( |
| 1652 | 1721 | 'type' => 'meta', |
| 1653 | 1722 | 'name' => 'property_price', |
| 1654 | 1723 | ), |
| @@ -1767,8 +1836,9 @@ | ||
| 1767 | 1836 | 'MapURL' => array( |
| 1768 | 1837 | 'type' => 'meta', |
| 1769 | 1838 | 'name' => 'mapurl', |
| 1770 | 1839 | ), |
| 1840 | + // ---- Media: routed to the gallery/attachment importer rather than a meta key ---- | |
| 1771 | 1841 | 'Media' => array( |
| 1772 | 1842 | 'type' => 'media', |
| 1773 | 1843 | 'name' => 'media', |
| 1774 | 1844 | ), |
| @@ -2142,8 +2212,9 @@ | ||
| 2142 | 2212 | 'PropertyType' => array( |
| 2143 | 2213 | 'type' => 'taxonomy', |
| 2144 | 2214 | 'name' => 'property_action_category', |
| 2145 | 2215 | ), |
| 2216 | + // ---- PublicRemarks -> the post content body (the public listing description) ---- | |
| 2146 | 2217 | 'PublicRemarks' => array( |
| 2147 | 2218 | 'type' => 'content', |
| 2148 | 2219 | 'name' => 'content', |
| 2149 | 2220 | ), |
| @@ -2427,8 +2498,9 @@ | ||
| 2427 | 2498 | 'SyndicationRemarks' => array( |
| 2428 | 2499 | 'type' => 'meta', |
| 2429 | 2500 | 'name' => 'syndicationremarks', |
| 2430 | 2501 | ), |
| 2502 | + // ---- Tax / assessment block ---- | |
| 2431 | 2503 | 'TaxAnnualAmount' => array( |
| 2432 | 2504 | 'type' => 'meta', |
| 2433 | 2505 | 'name' => 'taxannualamount', |
| 2434 | 2506 | ), |
| @@ -2621,8 +2693,9 @@ | ||
| 2621 | 2693 | 'WorkmansCompensationExpense' => array( |
| 2622 | 2694 | 'type' => 'meta', |
| 2623 | 2695 | 'name' => 'workmanscompensationexpense', |
| 2624 | 2696 | ), |
| 2697 | + // ---- Year built / establishment & zoning (tail of the map) ---- | |
| 2625 | 2698 | 'YearBuilt' => array( |
| 2626 | 2699 | 'type' => 'meta', |
| 2627 | 2700 | 'name' => 'yearbuilt', |
| 2628 | 2701 | ), |
| @@ -2654,12 +2727,19 @@ | ||
| 2654 | 2727 | 'type' => 'meta', |
| 2655 | 2728 | 'name' => 'zoningdescription', |
| 2656 | 2729 | ), |
| 2657 | 2730 | ); |
| 2731 | + // Hand the completed map back to the caller. | |
| 2658 | 2732 | return $theme_schema; |
| 2659 | 2733 | } |
| 2660 | 2734 | |
| 2661 | 2735 | |
| 2736 | +/** | |
| 2737 | + * Build a slug => label map of the taxonomies registered for a post type. | |
| 2738 | + * | |
| 2739 | + * @param string $post_type Post type name to inspect. | |
| 2740 | + * @return array Associative array of taxonomy slug => human-readable label. | |
| 2741 | + */ | |
| 2662 | 2742 | function mlsimport_get_custom_post_type_taxonomies($post_type) { |
| 2663 | 2743 | // Get the taxonomies associated with the custom post type |
| 2664 | 2744 | $taxonomies = get_object_taxonomies($post_type , 'objects'); |
| 2665 | 2745 | |
| @@ -2667,8 +2747,9 @@ | ||
| 2667 | 2747 | $taxonomy_array = array(); |
| 2668 | 2748 | |
| 2669 | 2749 | // Loop through the taxonomies and fill the array |
| 2670 | 2750 | foreach ($taxonomies as $taxonomy_slug => $taxonomy) { |
| 2751 | + // Key by slug, store the taxonomy's display label. | |
| 2671 | 2752 | $taxonomy_array[$taxonomy_slug] = $taxonomy->label; |
| 2672 | 2753 | } |
| 2673 | 2754 | |
| 2674 | 2755 | return $taxonomy_array; |
| @@ -2681,8 +2762,18 @@ | ||
| 2681 | 2762 | * |
| 2682 | 2763 | * |
| 2683 | 2764 | */ |
| 2684 | 2765 | |
| 2766 | +/** | |
| 2767 | + * Whitelist of HTML tags/attributes allowed in MLSImport-rendered content. | |
| 2768 | + * | |
| 2769 | + * Intended for use with wp_kses() when echoing plugin-generated markup: keeps a | |
| 2770 | + * small set of formatting tags plus the form controls (select/option/input/ | |
| 2771 | + * fieldset/label) the admin UI emits. (The preceding block comment is a stale | |
| 2772 | + * copy/paste and does not describe this function.) | |
| 2773 | + * | |
| 2774 | + * @return array Tag => allowed-attributes map in wp_kses() format. | |
| 2775 | + */ | |
| 2685 | 2776 | function mlsimport_allowed_html_tags_content() { |
| 2686 | 2777 | // Define the allowable HTML tags and their attributes |
| 2687 | 2778 | $allowed_tags = array( |
| 2688 | 2779 | 'a' => array( |
| @@ -2756,23 +2847,46 @@ | ||
| 2756 | 2847 | * Request list of ready to go MLS |
| 2757 | 2848 | * |
| 2758 | 2849 | * |
| 2759 | 2850 | */ |
| 2851 | +/** | |
| 2852 | + * Fetch the SaaS list of "ready to go" MLS systems, formatted for an autocomplete. | |
| 2853 | + * | |
| 2854 | + * Result is cached in a 24h transient. On a fresh fetch the raw MLS list is turned | |
| 2855 | + * into an array of {label, value} pairs (with a "My MLS is not on this list" entry | |
| 2856 | + * prepended) and JSON-encoded; on API failure a single-element error array is returned. | |
| 2857 | + * | |
| 2858 | + * @return string|array JSON string of autofill pairs on success, or an array | |
| 2859 | + * carrying an error message under key '0' on failure. | |
| 2860 | + */ | |
| 2760 | 2861 | function mlsimport_saas_request_list() { |
| 2761 | 2862 | |
| 2863 | + // Serve from the cached transient when present. | |
| 2762 | 2864 | $mls_data = get_transient( 'mlsimport_ready_to_go_mlsimport_data' ); |
| 2763 | 2865 | |
| 2866 | + // Cache miss: hit the SaaS API for the current MLS list. | |
| 2764 | 2867 | if ( false === $mls_data ) { |
| 2765 | 2868 | $theme_Start = new ThemeImport(); |
| 2766 | 2869 | $values = array(); |
| 2767 | 2870 | |
| 2871 | + // GET the MLS catalogue from the SaaS endpoint. | |
| 2768 | 2872 | $answer = $theme_Start::globalApiRequestSaas( 'mls', $values, 'GET' ); |
| 2769 | 2873 | |
| 2874 | + // Success: reshape the list into autocomplete {label,value} pairs. | |
| 2770 | 2875 | if ( isset( $answer['success'] ) && true === $answer['success'] ) { |
| 2771 | 2876 | $mls_data = $answer['mls_list']; |
| 2877 | + | |
| 2878 | + // Fresh catalogue in hand: refresh registered connections' display | |
| 2879 | + // names so an MLS renamed upstream shows its new name everywhere | |
| 2880 | + // (Connections table, import task picker) after a cache clear. | |
| 2881 | + if ( is_array( $mls_data ) && class_exists( 'Mlsimport_Connections' ) ) { | |
| 2882 | + Mlsimport_Connections::sync_names( $mls_data ); | |
| 2883 | + } | |
| 2884 | + // Prepend the "not listed" opt-out choice. | |
| 2772 | 2885 | $mls_data['0'] = esc_html__( 'My MLS is not on this list', 'mlsimport' ); |
| 2773 | 2886 | |
| 2774 | 2887 | $autofill_array = array(); |
| 2888 | + // Convert each key => label into a label/value pair. | |
| 2775 | 2889 | foreach ( $mls_data as $key => $value ) { |
| 2776 | 2890 | $temp_array = array( |
| 2777 | 2891 | 'label' => $value, |
| 2778 | 2892 | 'value' => $key, |
| @@ -2779,16 +2893,21 @@ | ||
| 2779 | 2893 | ); |
| 2780 | 2894 | $autofill_array[] = $temp_array; |
| 2781 | 2895 | } |
| 2782 | 2896 | |
| 2897 | + // Encode the pairs and cache for 24 hours. | |
| 2783 | 2898 | $mls_data = wp_json_encode( $autofill_array ); |
| 2784 | 2899 | |
| 2785 | 2900 | set_transient( 'mlsimport_ready_to_go_mlsimport_data', $mls_data, 60 * 60 * 24 ); |
| 2786 | - } else { | |
| 2787 | - $mls_data = array(); | |
| 2788 | - $mls_data['0'] = esc_html__( 'We could not connect to MLSimport Api', 'mlsimport' ); | |
| 2789 | - } | |
| 2790 | - } | |
| 2901 | + } else { | |
| 2902 | + // Failure: return a single-entry array carrying the error message. | |
| 2903 | + $mls_data = array(); | |
| 2904 | + $error_message = isset( $answer['error_message'] ) && ! empty( $answer['error_message'] ) | |
| 2905 | + ? $answer['error_message'] | |
| 2906 | + : esc_html__( 'We could not connect to MLSimport Api', 'mlsimport' ); | |
| 2907 | + $mls_data['0'] = esc_html( $error_message ); | |
| 2908 | + } | |
| 2909 | + } | |
| 2791 | 2910 | |
| 2792 | 2911 | return $mls_data; |
| 2793 | 2912 | } |
| 2794 | 2913 | |
| @@ -2797,11 +2916,22 @@ | ||
| 2797 | 2916 | * sanitize multidimensional array |
| 2798 | 2917 | * |
| 2799 | 2918 | * |
| 2800 | 2919 | * */ |
| 2920 | +/** | |
| 2921 | + * Recursively sanitise a value (scalar or nested array) for safe storage. | |
| 2922 | + * | |
| 2923 | + * Walks arrays depth-first, applying sanitize_text_field( wp_unslash() ) to every | |
| 2924 | + * leaf; a scalar input is sanitised directly. | |
| 2925 | + * | |
| 2926 | + * @param mixed $data Array or scalar to sanitise. | |
| 2927 | + * @return mixed Sanitised value of the same shape as the input. | |
| 2928 | + */ | |
| 2801 | 2929 | function mlsimport_sanitize_multi_dimensional_array($data){ |
| 2930 | + // Arrays: recurse into each element. | |
| 2802 | 2931 | if ( is_array( $data ) ) { |
| 2803 | 2932 | foreach ( $data as $key => $value ) { |
| 2933 | + // Nested array -> recurse; otherwise sanitise the leaf value. | |
| 2804 | 2934 | if ( is_array( $value ) ) { |
| 2805 | 2935 | $data[ $key ] = mlsimport_sanitize_multi_dimensional_array( $value ); |
| 2806 | 2936 | } else { |
| 2807 | 2937 | |
| @@ -2808,8 +2938,9 @@ | ||
| 2808 | 2938 | $data[ $key ] = sanitize_text_field( wp_unslash( $value )); |
| 2809 | 2939 | } |
| 2810 | 2940 | } |
| 2811 | 2941 | } else { |
| 2942 | + // Scalar input: sanitise directly. | |
| 2812 | 2943 | $data = sanitize_text_field( wp_unslash( $data) ); |
| 2813 | 2944 | } |
| 2814 | 2945 | |
| 2815 | 2946 | return $data; |
| @@ -2824,17 +2955,50 @@ | ||
| 2824 | 2955 | * |
| 2825 | 2956 | * |
| 2826 | 2957 | * */ |
| 2827 | 2958 | |
| 2959 | +/** | |
| 2960 | + * Cron entry point for the daily SaaS reconciliation pass. | |
| 2961 | + * | |
| 2962 | + * Bails out early unless at least one non-trashed import task has a title, then | |
| 2963 | + * hands off to the per-connection runner (#279) when an MLS name is configured: | |
| 2964 | + * one sub-run of the deep reconciliation module per registered connection, | |
| 2965 | + * or the single legacy unscoped run while no connections are registered. | |
| 2966 | + * | |
| 2967 | + * The same function handles the deduplicated one-hour retry hook — a retry | |
| 2968 | + * re-runs all connections, and completed ones converge to no-op keeps. Trigger | |
| 2969 | + * code intentionally owns no snapshot, status, batching, or deletion decisions. | |
| 2970 | + * | |
| 2971 | + * @return array<int, array<string, int|string>>|null Outcome per connection, or null when ineligible. | |
| 2972 | + */ | |
| 2828 | 2973 | function mlsimport_saas_reconciliation_event_function() { |
| 2829 | 2974 | |
| 2830 | - global $mlsimport; | |
| 2975 | + // Pull the API token (side effect: ensures a fresh token) and plugin options. | |
| 2976 | + global $mlsimport, $wpdb; | |
| 2831 | 2977 | $token = $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient(); |
| 2832 | 2978 | $options = get_option( 'mlsimport_admin_options' ); |
| 2833 | 2979 | |
| 2980 | + // Only run reconciliation if at least one import task has a title. | |
| 2981 | + $has_titled_task = $wpdb->get_var( | |
| 2982 | + "SELECT ID FROM {$wpdb->posts} | |
| 2983 | + WHERE post_type = 'mlsimport_item' | |
| 2984 | + AND post_status != 'trash' | |
| 2985 | + AND post_title != '' | |
| 2986 | + LIMIT 1" | |
| 2987 | + ); | |
| 2988 | + // No titled task -> nothing to reconcile, abort. | |
| 2989 | + if ( ! $has_titled_task ) { | |
| 2990 | + return null; | |
| 2991 | + } | |
| 2992 | + | |
| 2993 | + // Only reconcile when an MLS name is configured. The runner sequences one | |
| 2994 | + // scoped sub-run per registered connection (legacy unscoped run when the | |
| 2995 | + // registry is empty) and records/logs every outcome itself. | |
| 2834 | 2996 | if ( isset( $options['mlsimport_mls_name'] ) && '' !== $options['mlsimport_mls_name'] ) { |
| 2835 | - $mlsimport->admin->mlsimport_saas_start_doing_reconciliation(); | |
| 2997 | + return mlsimport_reconciliation_run_connections(); | |
| 2836 | 2998 | } |
| 2999 | + | |
| 3000 | + return null; | |
| 2837 | 3001 | } |
| 2838 | 3002 | |
| 2839 | 3003 | /* |
| 2840 | 3004 | * Admin extra columns for MlsImport Items |
| @@ -2844,22 +3008,39 @@ | ||
| 2844 | 3008 | * |
| 2845 | 3009 | * |
| 2846 | 3010 | * */ |
| 2847 | 3011 | |
| 3012 | +// Register the custom admin list-table columns for the Import Tasks post type. | |
| 2848 | 3013 | add_filter( 'manage_edit-mlsimport_item_columns', 'mlsimport_items_columns_admin' ); |
| 2849 | 3014 | |
| 3015 | +// Guard against redeclaration when the filter/file is loaded more than once. | |
| 2850 | 3016 | if ( ! function_exists( 'mlsimport_items_columns_admin' ) ) : |
| 2851 | 3017 | |
| 3018 | + /** | |
| 3019 | + * Add MLSImport-specific columns to the Import Tasks admin list table. | |
| 3020 | + * | |
| 3021 | + * Drops the default comments column and appends Import Parameters, Last action | |
| 3022 | + * and Auto Update Enabled columns. | |
| 3023 | + * | |
| 3024 | + * @param array $columns Existing column id => label map. | |
| 3025 | + * @return array Modified column map. | |
| 3026 | + */ | |
| 2852 | 3027 | function mlsimport_items_columns_admin( $columns ) { |
| 3028 | + // Keep a copy of two columns starting at offset 2 (to re-append later). | |
| 2853 | 3029 | $slice = array_slice( $columns, 2, 2 ); |
| 3030 | + // Remove the comments column from both the full set and the slice. | |
| 2854 | 3031 | unset( $columns['comments'] ); |
| 2855 | 3032 | unset( $slice['comments'] ); |
| 3033 | + // Trim the original column set down to the first two entries. | |
| 2856 | 3034 | $splice = array_splice( $columns, 2 ); |
| 2857 | 3035 | |
| 3036 | + // Append the plugin's own columns. | |
| 2858 | 3037 | $columns['mlsimport_items_params'] = esc_html__( 'Import Parameters', 'mlsimport' ); |
| 3038 | + $columns['mlsimport_task_health'] = esc_html__( 'Status', 'mlsimport' ); | |
| 2859 | 3039 | $columns['mlsimport_last_action'] = esc_html__( 'Last action', 'mlsimport' ); |
| 2860 | 3040 | $columns['mlsimport_autoupdates'] = esc_html__( 'Auto Update Enabled', 'mlsimport' ); |
| 2861 | 3041 | |
| 3042 | + // Return the plugin columns followed by the preserved slice (reversed). | |
| 2862 | 3043 | return array_merge( $columns, array_reverse( $slice ) ); |
| 2863 | 3044 | } |
| 2864 | 3045 | |
| 2865 | 3046 | endif; // end wpestate_my_columns |
| @@ -2874,16 +3055,27 @@ | ||
| 2874 | 3055 | * |
| 2875 | 3056 | * |
| 2876 | 3057 | * |
| 2877 | 3058 | * */ |
| 3059 | +/** | |
| 3060 | + * Flatten an import-parameter value into a comma-separated display string. | |
| 3061 | + * | |
| 3062 | + * Arrays are joined with commas (trailing comma trimmed); scalars pass through. | |
| 3063 | + * | |
| 3064 | + * @param mixed $value Array of values or a single scalar. | |
| 3065 | + * @return string Comma-separated (or scalar) display string. | |
| 3066 | + */ | |
| 2878 | 3067 | function mlsimport_populate_columns_params_display_value( $value ) { |
| 2879 | 3068 | $display_value = ''; |
| 3069 | + // Array: concatenate each element with a comma separator. | |
| 2880 | 3070 | if ( is_array( $value ) ) { |
| 2881 | 3071 | foreach ( $value as $key_item => $item_name ) : |
| 2882 | 3072 | $display_value .= $item_name . ','; |
| 2883 | 3073 | endforeach; |
| 3074 | + // Drop the trailing comma left by the loop. | |
| 2884 | 3075 | $display_value = rtrim( $display_value, ',' ); |
| 2885 | 3076 | } else { |
| 3077 | + // Scalar: use as-is. | |
| 2886 | 3078 | $display_value = $value; |
| 2887 | 3079 | } |
| 2888 | 3080 | |
| 2889 | 3081 | return $display_value; |
| @@ -2896,20 +3088,34 @@ | ||
| 2896 | 3088 | * |
| 2897 | 3089 | * |
| 2898 | 3090 | * */ |
| 2899 | 3091 | |
| 3092 | +/** | |
| 3093 | + * Echo the configured import parameters for one Import Task into the admin column. | |
| 3094 | + * | |
| 3095 | + * Iterates the MLS field definitions and, for each non-hidden field, prints the | |
| 3096 | + * task's stored value. Fields NOT in the $select_all_none list show "ALL" when | |
| 3097 | + * their per-field checkbox meta is set; the rest always show the raw stored value. | |
| 3098 | + * | |
| 3099 | + * @param int $postID Import Task post ID. | |
| 3100 | + * @return void Output is echoed directly. | |
| 3101 | + */ | |
| 2900 | 3102 | function mlsimport_populate_columns_params_display( $postID ) { |
| 3103 | + // Field definitions come from the admin class, scoped to the TASK's own | |
| 3104 | + // connection (#277) so each row's parameters render against its MLS. | |
| 2901 | 3105 | global $mlsimport; |
| 2902 | - $field_import = $mlsimport->admin->mlsimport_saas_return_mls_fields(); | |
| 3106 | + $field_import = $mlsimport->admin->mlsimport_saas_return_mls_fields( mlsimport_task_mls_id( (int) $postID ) ); | |
| 2903 | 3107 | |
| 3108 | + // Fields whose stored value is always shown verbatim (never collapsed to "ALL"). | |
| 2904 | 3109 | $select_all_none = array( |
| 2905 | 3110 | 'InternetAddressDisplayYN', |
| 2906 | 3111 | 'InternetEntireListingDisplayYN', |
| 2907 | 3112 | 'PostalCode', |
| 2908 | - 'ListAgentKey', | |
| 2909 | - 'ListAgentMlsId', | |
| 2910 | - 'ListOfficeKey', | |
| 2911 | - 'ListOfficeMlsId', | |
| 3113 | + 'ListAgentKey', | |
| 3114 | + 'ListAgentMlsId', | |
| 3115 | + 'BuyerAgentMlsId', | |
| 3116 | + 'ListOfficeKey', | |
| 3117 | + 'ListOfficeMlsId', | |
| 2912 | 3118 | 'ListingID', |
| 2913 | 3119 | 'StandardStatus', |
| 2914 | 3120 | 'extraCity', |
| 2915 | 3121 | 'extraCounty', |
| @@ -2916,25 +3122,35 @@ | ||
| 2916 | 3122 | 'Exclude_ListOfficeKey', |
| 2917 | 3123 | 'Exclude_ListOfficeMlsId', |
| 2918 | 3124 | 'Exclude_ListAgentKey', |
| 2919 | 3125 | 'Exclude_ListAgentMlsId', |
| 3126 | + 'CustomParameters', | |
| 2920 | 3127 | 'MLSAreaMajor', |
| 2921 | 3128 | 'SubdivisionName', |
| 2922 | 3129 | |
| 2923 | 3130 | ); |
| 3131 | + // Walk every defined MLS field for this task. | |
| 2924 | 3132 | foreach ( $field_import as $key => $field ) : |
| 3133 | + // Skip fields flagged hidden. | |
| 3134 | + if ( ! empty( $field['hidden'] ) ) { | |
| 3135 | + continue; | |
| 3136 | + } | |
| 2925 | 3137 | $display_value = ''; |
| 3138 | + // Derive the per-field meta keys (value + its "check" companion). | |
| 2926 | 3139 | $name_check = strtolower( 'mlsimport_item_' . $key . '_check' ); |
| 2927 | 3140 | $name = strtolower( 'mlsimport_item_' . $key ); |
| 2928 | 3141 | |
| 3142 | + // Read the stored value and its checkbox flag. | |
| 2929 | 3143 | $value = get_post_meta( $postID, $name, true ); |
| 2930 | 3144 | $value_check = get_post_meta( $postID, $name_check, true ); |
| 2931 | 3145 | |
| 3146 | + // Normalise the checkbox flag to 0/1. | |
| 2932 | 3147 | $is_checkbox_admin = 0; |
| 2933 | 3148 | if ( 1 === intval($value_check) ) { |
| 2934 | 3149 | $is_checkbox_admin = 1; |
| 2935 | 3150 | } |
| 2936 | 3151 | |
| 3152 | + // For collapsible fields a set checkbox means "ALL"; otherwise show the value. | |
| 2937 | 3153 | if ( ! in_array( $key, $select_all_none ) ) { |
| 2938 | 3154 | if ( 1 === intval($is_checkbox_admin) ) { |
| 2939 | 3155 | $display_value = esc_html__( 'ALL', 'mlsimport' ); |
| 2940 | 3156 | } else { |
| @@ -2940,14 +3156,17 @@ | ||
| 2940 | 3156 | } else { |
| 2941 | 3157 | $display_value = mlsimport_populate_columns_params_display_value( $value ); |
| 2942 | 3158 | } |
| 2943 | 3159 | } else { |
| 3160 | + // Always-verbatim fields: print the stored value. | |
| 2944 | 3161 | $display_value = mlsimport_populate_columns_params_display_value( $value ); |
| 2945 | 3162 | } |
| 2946 | 3163 | |
| 3164 | + // Render "<Label> : <value>" only when there is something to show. | |
| 2947 | 3165 | if ( '' !== $display_value ) { ?> |
| 2948 | 3166 | <strong> |
| 2949 | 3167 | <?php |
| 3168 | + // Field label with the leading "Select " prefix stripped. | |
| 2950 | 3169 | print esc_html( ucfirst( str_replace( 'Select ', '', $field['label'] ) ) ); |
| 2951 | 3170 | ?> |
| 2952 | 3171 | :</strong> |
| 2953 | 3172 | <?php |
| @@ -2958,16 +3177,30 @@ | ||
| 2958 | 3177 | } |
| 2959 | 3178 | |
| 2960 | 3179 | |
| 2961 | 3180 | |
| 3181 | +// Render cell contents for the custom Import Tasks columns. | |
| 2962 | 3182 | add_action( 'manage_posts_custom_column', 'mlsimport_populate_columns' ); |
| 3183 | +// Guard against redeclaration. | |
| 2963 | 3184 | if ( ! function_exists( 'mlsimport_populate_columns' ) ) : |
| 2964 | 3185 | |
| 3186 | + /** | |
| 3187 | + * Output the value for each custom Import Tasks admin column. | |
| 3188 | + * | |
| 3189 | + * Handles the three plugin columns: import parameters (price range + field | |
| 3190 | + * params), last action date, and whether auto-update (cron) is enabled. | |
| 3191 | + * | |
| 3192 | + * @param string $column Column id being rendered. | |
| 3193 | + * @return void Output is echoed directly. | |
| 3194 | + */ | |
| 2965 | 3195 | function mlsimport_populate_columns( $column ) { |
| 2966 | 3196 | |
| 3197 | + // Current row's post is available via the global. | |
| 2967 | 3198 | global $post; |
| 2968 | 3199 | |
| 3200 | + // Import Parameters column: price range then the field parameter list. | |
| 2969 | 3201 | if ( 'mlsimport_items_params' === $column ) { |
| 3202 | + // Read the configured min/max price filters as floats. | |
| 2970 | 3203 | $mlsimport_item_min_price = floatval( get_post_meta( $post->ID, 'mlsimport_item_min_price', true ) ); |
| 2971 | 3204 | $mlsimport_item_max_price = floatval( get_post_meta( $post->ID, 'mlsimport_item_max_price', true ) ); |
| 2972 | 3205 | ?> |
| 2973 | 3206 | |
| @@ -2985,20 +3218,54 @@ | ||
| 2985 | 3218 | |
| 2986 | 3219 | <?php echo esc_html($mlsimport_item_max_price); ?><br> |
| 2987 | 3220 | <?php |
| 2988 | 3221 | |
| 3222 | + // Then the per-field import parameters. | |
| 2989 | 3223 | mlsimport_populate_columns_params_display( $post->ID ); |
| 3224 | + } elseif ( 'mlsimport_task_health' === $column ) { | |
| 3225 | + // Status column (GitHub issue #200): surface stuck, failed, and | |
| 3226 | + // sync-overdue tasks directly in the list. All inputs are already | |
| 3227 | + // recorded — the run status meta (state, progress, heartbeat, error), | |
| 3228 | + // the sync watermark, and the auto-sync flag. The badge decision | |
| 3229 | + // itself lives in the pure, unit-tested mlsimport_task_health(). | |
| 3230 | + $health_status = get_post_meta( $post->ID, 'mlsimport_import_run_status', true ); | |
| 3231 | + $health = mlsimport_task_health( | |
| 3232 | + is_array( $health_status ) ? $health_status : array(), | |
| 3233 | + (string) get_post_meta( $post->ID, 'mlsimport_last_date', true ), | |
| 3234 | + 1 === (int) get_post_meta( $post->ID, 'mlsimport_item_stat_cron', true ), | |
| 3235 | + time(), | |
| 3236 | + // Watermarks are stored with wp_date() in site-local time, so | |
| 3237 | + // the overdue cutoff must be built the same way to compare. | |
| 3238 | + wp_date( 'Y-m-d\TH:i', time() - MLSIMPORT_TASK_HEALTH_OVERDUE_AFTER ), | |
| 3239 | + // The hourly runner's own eligibility rule (GitHub issue #330): | |
| 3240 | + // a task it will never pick up must not read as merely overdue. | |
| 3241 | + mlsimport_cron_task_is_eligible( | |
| 3242 | + (int) get_post_meta( $post->ID, 'mlsimport_initial_import_completed', true ), | |
| 3243 | + (string) get_post_meta( $post->ID, 'mlsimport_spawn_status', true ) | |
| 3244 | + ) | |
| 3245 | + ); | |
| 3246 | + ?> | |
| 3247 | + <span class="mlsimport-task-health mlsimport-task-health--<?php echo esc_attr( $health['level'] ); ?>"> | |
| 3248 | + <?php echo esc_html( $health['label'] ); ?> | |
| 3249 | + </span> | |
| 3250 | + <p class="mlsimport-task-health__message"><?php echo esc_html( $health['message'] ); ?></p> | |
| 3251 | + <?php | |
| 2990 | 3252 | } elseif ( 'mlsimport_last_action' === $column ) { |
| 3253 | + // Last action column: date of the most recent import activity. | |
| 2991 | 3254 | $last_date = get_post_meta( $post->ID, 'mlsimport_last_date', true ); |
| 2992 | 3255 | if ( '' !== $last_date ) { |
| 3256 | + // Have a date: show it with an explanatory note. | |
| 2993 | 3257 | print esc_html($last_date) . ' </br>'; |
| 2994 | 3258 | esc_html_e( 'On this date we found new or edited listings.', 'mlsimport' ); |
| 2995 | 3259 | } else { |
| 3260 | + // No date recorded (sync likely disabled). | |
| 2996 | 3261 | esc_html_e( 'Not available - sync option may be off.', 'mlsimport' ); |
| 2997 | 3262 | } |
| 2998 | 3263 | } elseif ( 'mlsimport_autoupdates' === $column ) { |
| 3264 | + // Auto Update column: reflects the cron-enabled meta flag. | |
| 2999 | 3265 | $mlsimport_item_stat_cron = esc_html( get_post_meta( $post->ID, 'mlsimport_item_stat_cron', true ) ); |
| 3000 | 3266 | |
| 3267 | + // Positive flag -> "yes", otherwise "no". | |
| 3001 | 3268 | if ( intval( $mlsimport_item_stat_cron ) > 0 ) { |
| 3002 | 3269 | ?> |
| 3003 | 3270 | yes |
| 3004 | 3271 | <?php |
| @@ -3012,8 +3279,17 @@ | ||
| 3012 | 3279 | endif; |
| 3013 | 3280 | |
| 3014 | 3281 | |
| 3015 | 3282 | |
| 3283 | +/** | |
| 3284 | + * Wipe all MLSImport-created options and transients from the database. | |
| 3285 | + * | |
| 3286 | + * Deletes every option whose name begins with "mlsimport_" (both single-site and | |
| 3287 | + * network variants) and every matching transient (site and normal). Does NOT touch | |
| 3288 | + * imported posts/terms — options and transients only. | |
| 3289 | + * | |
| 3290 | + * @return bool Always true. | |
| 3291 | + */ | |
| 3016 | 3292 | function mlsimport_reset_plugin_data() { |
| 3017 | 3293 | |
| 3018 | 3294 | |
| 3019 | 3295 | global $wpdb; |
| @@ -3018,9 +3294,11 @@ | ||
| 3018 | 3294 | |
| 3019 | 3295 | global $wpdb; |
| 3020 | 3296 | |
| 3021 | 3297 | // Remove all options stored by MLSImport |
| 3298 | + // Collect every option name prefixed with "mlsimport_". | |
| 3022 | 3299 | $option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $wpdb->esc_like( 'mlsimport_' ) . '%' ) ); |
| 3300 | + // Delete each as both a normal and a network option. | |
| 3023 | 3301 | foreach ( $option_names as $option_name ) { |
| 3024 | 3302 | delete_option( $option_name ); |
| 3025 | 3303 | delete_site_option( $option_name ); |
| 3026 | 3304 | } |
| @@ -3025,12 +3303,15 @@ | ||
| 3025 | 3303 | delete_site_option( $option_name ); |
| 3026 | 3304 | } |
| 3027 | 3305 | |
| 3028 | 3306 | // Remove transients created by MLSImport |
| 3307 | + // Match both normal and site transient row-name prefixes. | |
| 3029 | 3308 | $transient_patterns = array( '_transient_mlsimport_%', '_site_transient_mlsimport_%' ); |
| 3030 | 3309 | foreach ( $transient_patterns as $pattern ) { |
| 3310 | + // Find the raw option rows backing these transients. | |
| 3031 | 3311 | $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $pattern ) ); |
| 3032 | 3312 | foreach ( $names as $name ) { |
| 3313 | + // Strip the storage prefix and delete via the matching transient API. | |
| 3033 | 3314 | if ( strpos( $name, '_site_transient_' ) === 0 ) { |
| 3034 | 3315 | $transient = substr( $name, strlen( '_site_transient_' ) ); |
| 3035 | 3316 | delete_site_transient( $transient ); |
| 3036 | 3317 | } else { |
| @@ -3040,5 +3321,5 @@ | ||
| 3040 | 3321 | } |
| 3041 | 3322 | } |
| 3042 | 3323 | |
| 3043 | 3324 | return true; |
| 3044 | -} | |
| 3325 | +} | |