Plugin Directory

Changeset 3475951


Ignore:
Timestamp:
03/05/2026 09:37:16 PM (2 days ago)
Author:
shift8
Message:

Geographic filtering, additional testing and stability improvements

Location:
shift8-real-estate-listings-for-treb/trunk
Files:
9 edited

Legend:

Unmodified
Added
Removed
  • shift8-real-estate-listings-for-treb/trunk/admin/class-shift8-treb-admin.php

    r3452434 r3475951  
    4040        add_action('wp_ajax_shift8_treb_manual_sync', array($this, 'ajax_manual_sync'));
    4141        add_action('wp_ajax_shift8_treb_reset_sync', array($this, 'ajax_reset_sync'));
     42        add_action('wp_ajax_shift8_treb_get_cities', array($this, 'ajax_get_cities'));
    4243    }
    4344
     
    131132            'google_maps_api_key' => '',
    132133            'listing_status_filter' => 'Active',
    133             'city_filter' => 'Toronto',
     134            'city_filter' => '',
    134135            'property_type_filter' => '',
    135136            'min_price' => '',
    136137            'max_price' => '',
     138            'geographic_filter_type' => '',
     139            'postal_code_prefixes' => '',
    137140            'listing_template' => ''
    138141        ));
     
    141144        include SHIFT8_TREB_PLUGIN_DIR . 'admin/partials/settings-page.php';
    142145    }
    143 
    144     /**
    145      * Register settings
    146      *
    147      * Registers plugin settings with WordPress settings API for proper
    148      * validation and sanitization.
    149      *
    150      * @since 1.0.0
    151      */
    152     public function register_settings() {
    153         register_setting(
    154             'shift8_treb_settings',
    155             'shift8_treb_settings',
    156             array(
    157                 'sanitize_callback' => array($this, 'sanitize_settings'),
    158                 'default' => array(
    159                     'bearer_token' => '',
    160                     'sync_frequency' => 'daily',
    161                     'max_listings_per_query' => 100,
    162                     'debug_enabled' => '0',
    163                     'google_maps_api_key' => '',
    164                     'listing_status_filter' => 'Active',
    165                     'city_filter' => 'Toronto',
    166                     'property_type_filter' => '',
    167                     'min_price' => '',
    168                     'max_price' => ''
    169                 )
    170             )
    171         );
    172     }
    173 
    174     /**
    175      * Sanitize settings
    176      *
    177      * Validates and sanitizes all plugin settings before saving.
    178      *
    179      * @since 1.0.0
    180      * @param array $input Raw input data
    181      * @return array Sanitized settings
    182      */
    183     public function sanitize_settings($input) {
    184         $sanitized = array();
    185        
    186         // Sanitize Bearer Token
    187         if (isset($input['bearer_token'])) {
    188             $token = trim($input['bearer_token']);
    189             if (!empty($token)) {
    190                 $sanitized['bearer_token'] = shift8_treb_encrypt_data($token);
    191             } else {
    192                 // Keep existing token if new one is empty
    193                 $existing_settings = get_option('shift8_treb_settings', array());
    194                 $sanitized['bearer_token'] = isset($existing_settings['bearer_token']) ? $existing_settings['bearer_token'] : '';
    195             }
    196         }
    197        
    198         // Sanitize sync frequency with extended options
    199         $allowed_frequencies = array('hourly', 'every_8_hours', 'every_12_hours', 'daily', 'weekly', 'bi_weekly', 'monthly');
    200         if (isset($input['sync_frequency']) && in_array($input['sync_frequency'], $allowed_frequencies, true)) {
    201             $sanitized['sync_frequency'] = $input['sync_frequency'];
    202         } else {
    203             $sanitized['sync_frequency'] = 'daily';
    204         }
    205        
    206         // Sanitize max listings per query
    207         if (isset($input['max_listings_per_query'])) {
    208             $max_listings = absint($input['max_listings_per_query']);
    209             $sanitized['max_listings_per_query'] = max(1, min(1000, $max_listings)); // Between 1 and 1000
    210         }
    211        
    212         // Sanitize debug setting
    213         $sanitized['debug_enabled'] = isset($input['debug_enabled']) ? '1' : '0';
    214        
    215         // Sanitize Google Maps API key
    216         if (isset($input['google_maps_api_key'])) {
    217             $sanitized['google_maps_api_key'] = sanitize_text_field(trim($input['google_maps_api_key']));
    218         }
    219        
    220         // Sanitize listing status filter (based on RESO StandardStatus)
    221         $allowed_statuses = array('Active', 'Pending', 'ActiveUnderContract', 'Sold', 'Expired', 'Withdrawn', 'All');
    222         if (isset($input['listing_status_filter']) && in_array($input['listing_status_filter'], $allowed_statuses, true)) {
    223             $sanitized['listing_status_filter'] = $input['listing_status_filter'];
    224         } else {
    225             $sanitized['listing_status_filter'] = 'Active';
    226         }
    227        
    228         // Sanitize city filter
    229         if (isset($input['city_filter'])) {
    230             $sanitized['city_filter'] = sanitize_text_field(trim($input['city_filter']));
    231         }
    232        
    233         // Sanitize property type filter (based on RESO PropertyType)
    234         if (isset($input['property_type_filter'])) {
    235             $sanitized['property_type_filter'] = sanitize_text_field(trim($input['property_type_filter']));
    236         }
    237        
    238         // Sanitize price filters
    239         if (isset($input['min_price'])) {
    240             $sanitized['min_price'] = $input['min_price'] !== '' ? absint($input['min_price']) : '';
    241         }
    242        
    243         if (isset($input['max_price'])) {
    244             $sanitized['max_price'] = $input['max_price'] !== '' ? absint($input['max_price']) : '';
    245         }
    246        
    247         shift8_treb_log('Settings saved', array(
    248             'bearer_token_set' => !empty($sanitized['bearer_token']),
    249             'sync_frequency' => $sanitized['sync_frequency'],
    250             'debug_enabled' => $sanitized['debug_enabled'],
    251             'city_filter' => $sanitized['city_filter'],
    252             'max_listings' => $sanitized['max_listings_per_query']
    253         ));
    254        
    255         // Reschedule cron if frequency changed
    256         $existing_settings = get_option('shift8_treb_settings', array());
    257         if (isset($existing_settings['sync_frequency']) && $existing_settings['sync_frequency'] !== $sanitized['sync_frequency']) {
    258             wp_clear_scheduled_hook('shift8_treb_sync_listings');
    259             wp_schedule_event(time(), $sanitized['sync_frequency'], 'shift8_treb_sync_listings');
    260         }
    261        
    262         return $sanitized;
    263     }
    264 
    265146
    266147    /**
     
    286167        );
    287168
     169        // jQuery UI Autocomplete ships with WordPress core
     170        wp_enqueue_script('jquery-ui-autocomplete');
     171
    288172        // Add plugin-specific scripts
    289173        wp_enqueue_script(
    290174            'shift8-treb-admin',
    291175            SHIFT8_TREB_PLUGIN_URL . 'admin/js/admin.js',
    292             array('jquery'),
     176            array('jquery', 'jquery-ui-autocomplete'),
    293177            SHIFT8_TREB_VERSION,
    294178            true
     
    437321
    438322    /**
     323     * AJAX handler for fetching city lookup values
     324     *
     325     * Returns cached city list from AMPRE Lookup API, refreshing if stale.
     326     * Accepts optional ?refresh=1 to force cache refresh.
     327     *
     328     * @since 1.7.0
     329     */
     330    public function ajax_get_cities() {
     331        if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'shift8_treb_nonce') || !current_user_can('manage_options')) {
     332            wp_send_json_error(array('message' => esc_html__('Security check failed.', 'shift8-real-estate-listings-for-treb')));
     333        }
     334
     335        $force_refresh = !empty($_POST['refresh']);
     336        $transient_key = 'shift8_treb_city_lookups';
     337
     338        if (!$force_refresh) {
     339            $cached = get_transient($transient_key);
     340            if (false !== $cached && is_array($cached)) {
     341                wp_send_json_success(array('cities' => $cached, 'source' => 'cache'));
     342            }
     343        }
     344
     345        try {
     346            $settings = get_option('shift8_treb_settings', array());
     347
     348            if (empty($settings['bearer_token'])) {
     349                wp_send_json_error(array('message' => esc_html__('Bearer token not configured. Save your API credentials first.', 'shift8-real-estate-listings-for-treb')));
     350            }
     351
     352            $bearer_token = shift8_treb_decrypt_data($settings['bearer_token']);
     353            $settings['bearer_token'] = $bearer_token;
     354
     355            require_once SHIFT8_TREB_PLUGIN_DIR . 'includes/class-shift8-treb-ampre-service.php';
     356            $service = new Shift8_TREB_AMPRE_Service($settings);
     357            $cities = $service->get_city_lookups();
     358
     359            if (is_wp_error($cities)) {
     360                wp_send_json_error(array('message' => esc_html($cities->get_error_message())));
     361            }
     362
     363            set_transient($transient_key, $cities, 30 * 86400);
     364
     365            wp_send_json_success(array('cities' => $cities, 'source' => 'api', 'count' => count($cities)));
     366
     367        } catch (Exception $e) {
     368            wp_send_json_error(array('message' => esc_html($e->getMessage())));
     369        }
     370    }
     371
     372    /**
    439373     * AJAX handler for resetting sync mode
    440374     *
  • shift8-real-estate-listings-for-treb/trunk/admin/css/admin.css

    r3385519 r3475951  
    429429    flex: 1;
    430430}
     431
     432/* jQuery UI Autocomplete overrides for WP Admin */
     433.ui-autocomplete {
     434    max-height: 250px;
     435    overflow-y: auto;
     436    overflow-x: hidden;
     437    z-index: 100100;
     438    background: #fff;
     439    border: 1px solid #ccd0d4;
     440    box-shadow: 0 3px 6px rgba(0, 0, 0, 0.15);
     441}
     442
     443.ui-autocomplete .ui-menu-item-wrapper {
     444    padding: 6px 12px;
     445    font-size: 13px;
     446    cursor: pointer;
     447}
     448
     449.ui-autocomplete .ui-state-active {
     450    background: #0073aa;
     451    color: #fff;
     452    border: none;
     453    margin: 0;
     454}
  • shift8-real-estate-listings-for-treb/trunk/admin/js/admin.js

    r3385519 r3475951  
    190190        }
    191191
     192        // Validate geographic filter fields based on selected type
     193        var geoType = $('#geographic_filter_type').val();
     194        if (geoType === 'postal_prefix') {
     195            var postalVal = $('#postal_code_prefixes').val().trim();
     196            if (!postalVal) {
     197                errors.push('Postal code prefix filter is selected but no prefixes specified.');
     198                isValid = false;
     199            } else {
     200                var invalidPrefixes = validatePostalPrefixes(postalVal);
     201                if (invalidPrefixes.length > 0) {
     202                    errors.push('Invalid postal code prefix(es): ' + invalidPrefixes.join(', ') + '. Format must be letter-digit-letter (e.g., M5V).');
     203                    isValid = false;
     204                }
     205            }
     206        } else if (geoType === 'city') {
     207            var cityVal = $('#city_filter').val().trim();
     208            if (!cityVal) {
     209                errors.push('City filter is selected but no city specified.');
     210                isValid = false;
     211            } else if (cityListCache && cityListCache.length > 0) {
     212                var enteredCities = splitValues(cityVal).filter(function(c) { return c.trim(); });
     213                var lowered = cityListCache.map(function(c) { return c.toLowerCase(); });
     214                var unknown = [];
     215                for (var i = 0; i < enteredCities.length; i++) {
     216                    if (lowered.indexOf(enteredCities[i].trim().toLowerCase()) === -1) {
     217                        unknown.push(enteredCities[i].trim());
     218                    }
     219                }
     220                if (unknown.length > 0) {
     221                    errors.push('Unrecognised city name(s): ' + unknown.join(', ') + '. Use the autocomplete suggestions or click the refresh button.');
     222                    isValid = false;
     223                }
     224            }
     225        }
     226
    192227        // Show errors if any
    193228        if (!isValid) {
     
    196231
    197232        return isValid;
     233    }
     234
     235    /**
     236     * Validate postal code prefixes and return array of invalid ones
     237     */
     238    function validatePostalPrefixes(value) {
     239        if (!value) return [];
     240        var fsaPattern = /^[A-Za-z]\d[A-Za-z]$/;
     241        var prefixes = value.split(',');
     242        var invalid = [];
     243        for (var i = 0; i < prefixes.length; i++) {
     244            var p = prefixes[i].trim();
     245            if (p && !fsaPattern.test(p)) {
     246                invalid.push(p);
     247            }
     248        }
     249        return invalid;
     250    }
     251
     252    /**
     253     * Toggle geographic filter fields based on selected type
     254     */
     255    function initGeographicFilterToggle() {
     256        var $select = $('#geographic_filter_type');
     257        if (!$select.length) return;
     258
     259        function toggleRows() {
     260            var type = $select.val();
     261            $('#postal_prefix_row').toggle(type === 'postal_prefix');
     262            $('#city_filter_row').toggle(type === 'city');
     263            if (type === 'city') {
     264                loadCityAutocomplete(false);
     265            }
     266        }
     267
     268        $select.on('change', toggleRows);
     269        toggleRows();
     270    }
     271
     272    var cityListCache = null;
     273
     274    /**
     275     * Load city list and initialise jQuery UI Autocomplete (multi-value)
     276     */
     277    function loadCityAutocomplete(forceRefresh) {
     278        var $field = $('#city_filter');
     279        var $status = $('#city-cache-status');
     280        if (!$field.length) return;
     281
     282        var data = {
     283            action: 'shift8_treb_get_cities',
     284            nonce: shift8TREB.nonce
     285        };
     286        if (forceRefresh) {
     287            data.refresh = 1;
     288        }
     289
     290        $status.text('Loading cities...').css('color', '#0073aa').show();
     291
     292        $.post(shift8TREB.ajaxurl, data, function(response) {
     293            if (response.success && response.data.cities) {
     294                cityListCache = response.data.cities;
     295                var src = response.data.source === 'cache' ? 'cached' : 'API';
     296                $status.text(cityListCache.length + ' cities loaded (' + src + ')').css('color', '#00a32a');
     297                initCityAutocomplete($field, cityListCache);
     298            } else {
     299                var msg = response.data && response.data.message ? response.data.message : 'Could not load cities';
     300                $status.text(msg).css('color', '#d63638');
     301            }
     302        }).fail(function() {
     303            $status.text('Network error loading cities').css('color', '#d63638');
     304        });
     305    }
     306
     307    function splitValues(val) {
     308        return val.split(/,\s*/);
     309    }
     310
     311    function extractLast(term) {
     312        return splitValues(term).pop();
     313    }
     314
     315    function initCityAutocomplete($field, cities) {
     316        if ($field.data('ui-autocomplete')) {
     317            $field.autocomplete('destroy');
     318        }
     319
     320        $field.on('keydown.shift8autocomplete', function(event) {
     321            if (event.keyCode === $.ui.keyCode.TAB && $(this).autocomplete('instance').menu.active) {
     322                event.preventDefault();
     323            }
     324        });
     325
     326        $field.autocomplete({
     327            minLength: 1,
     328            source: function(request, response) {
     329                var term = extractLast(request.term).toLowerCase();
     330                var matches = $.grep(cities, function(city) {
     331                    return city.toLowerCase().indexOf(term) === 0;
     332                });
     333                response(matches.slice(0, 15));
     334            },
     335            focus: function() {
     336                return false;
     337            },
     338            select: function(event, ui) {
     339                var terms = splitValues(this.value);
     340                terms.pop();
     341                terms.push(ui.item.value);
     342                terms.push('');
     343                this.value = terms.join(', ');
     344                return false;
     345            }
     346        });
     347    }
     348
     349    /**
     350     * Real-time validation for postal code prefix field
     351     */
     352    function initPostalPrefixValidation() {
     353        var $field = $('#postal_code_prefixes');
     354        if (!$field.length) return;
     355
     356        $field.on('blur', function() {
     357            var val = $(this).val().trim();
     358            var $feedback = $(this).next('.shift8-treb-validation-msg');
     359            if (!$feedback.length) {
     360                $feedback = $('<span class="shift8-treb-validation-msg"></span>');
     361                $(this).after($feedback);
     362            }
     363
     364            if (!val) {
     365                $feedback.text('').hide();
     366                return;
     367            }
     368
     369            var invalid = validatePostalPrefixes(val);
     370            if (invalid.length > 0) {
     371                $feedback.text('Invalid: ' + invalid.join(', ') + ' — expected format: A1A (e.g., M5V)')
     372                         .css('color', '#d63638').show();
     373            } else {
     374                var count = val.split(',').filter(function(v) { return v.trim(); }).length;
     375                $feedback.text(count + ' valid prefix' + (count !== 1 ? 'es' : ''))
     376                         .css('color', '#00a32a').show();
     377            }
     378        });
    198379    }
    199380
     
    253434        handleFrequencyChange();
    254435        initAutoSave();
     436        initGeographicFilterToggle();
     437        initPostalPrefixValidation();
     438
     439        $('#refresh-city-list').on('click', function() {
     440            loadCityAutocomplete(true);
     441        });
    255442       
    256443        // Form validation on submit
  • shift8-real-estate-listings-for-treb/trunk/admin/partials/settings-page.php

    r3385519 r3475951  
    176176                                       class="small-text" />
    177177                                <p class="description"><?php esc_html_e('Only sync listings modified within the last X days. This filters by ModificationTimestamp.', 'shift8-real-estate-listings-for-treb'); ?></p>
     178                            </td>
     179                        </tr>
     180                    </table>
     181                </div>
     182
     183                <!-- Geographic Filter Section -->
     184                <div class="card">
     185                    <h2 class="title"><?php esc_html_e('Geographic Filter', 'shift8-real-estate-listings-for-treb'); ?></h2>
     186                    <?php
     187                    $geo_type = isset($settings['geographic_filter_type']) ? $settings['geographic_filter_type'] : '';
     188                    $postal_prefixes = isset($settings['postal_code_prefixes']) ? $settings['postal_code_prefixes'] : '';
     189                    $city_filter_val = isset($settings['city_filter']) ? $settings['city_filter'] : '';
     190                    ?>
     191                    <table class="form-table">
     192                        <tr>
     193                            <th scope="row">
     194                                <label for="geographic_filter_type"><?php esc_html_e('Filter Type', 'shift8-real-estate-listings-for-treb'); ?></label>
     195                            </th>
     196                            <td>
     197                                <select id="geographic_filter_type" name="shift8_treb_settings[geographic_filter_type]">
     198                                    <option value="" <?php selected($geo_type, ''); ?>><?php esc_html_e('None (no geographic filter)', 'shift8-real-estate-listings-for-treb'); ?></option>
     199                                    <option value="postal_prefix" <?php selected($geo_type, 'postal_prefix'); ?>><?php esc_html_e('By Postal Code Prefix', 'shift8-real-estate-listings-for-treb'); ?></option>
     200                                    <option value="city" <?php selected($geo_type, 'city'); ?>><?php esc_html_e('By City', 'shift8-real-estate-listings-for-treb'); ?></option>
     201                                </select>
     202                                <p class="description"><?php esc_html_e('Optionally restrict synced listings to a geographic area. Postal prefix and city filters are mutually exclusive.', 'shift8-real-estate-listings-for-treb'); ?></p>
     203                            </td>
     204                        </tr>
     205                        <tr class="shift8-treb-postal-row" <?php echo ($geo_type !== 'postal_prefix') ? 'style="display:none;"' : ''; ?>>
     206                            <th scope="row">
     207                                <label for="postal_code_prefixes"><?php esc_html_e('Postal Code Prefixes', 'shift8-real-estate-listings-for-treb'); ?></label>
     208                            </th>
     209                            <td>
     210                                <input type="text"
     211                                       id="postal_code_prefixes"
     212                                       name="shift8_treb_settings[postal_code_prefixes]"
     213                                       value="<?php echo esc_attr($postal_prefixes); ?>"
     214                                       class="regular-text"
     215                                       placeholder="<?php esc_attr_e('e.g., M5V, M6H, M8X', 'shift8-real-estate-listings-for-treb'); ?>" />
     216                                <p class="description"><?php esc_html_e('Comma-separated postal code prefixes (FSA format: Letter-Number-Letter).', 'shift8-real-estate-listings-for-treb'); ?></p>
     217                            </td>
     218                        </tr>
     219                        <tr class="shift8-treb-city-row" <?php echo ($geo_type !== 'city') ? 'style="display:none;"' : ''; ?>>
     220                            <th scope="row">
     221                                <label for="shift8_treb_city_filter"><?php esc_html_e('City Filter', 'shift8-real-estate-listings-for-treb'); ?></label>
     222                            </th>
     223                            <td>
     224                                <input type="text"
     225                                       id="shift8_treb_city_filter"
     226                                       name="shift8_treb_settings[city_filter]"
     227                                       value="<?php echo esc_attr($city_filter_val); ?>"
     228                                       class="regular-text"
     229                                       placeholder="<?php esc_attr_e('e.g., Toronto, Mississauga, Brampton', 'shift8-real-estate-listings-for-treb'); ?>" />
     230                                <p class="description"><?php esc_html_e('Comma-separated city names. Start typing to see suggestions.', 'shift8-real-estate-listings-for-treb'); ?></p>
     231                                <p>
     232                                    <button type="button" id="shift8-treb-refresh-cities" class="button button-secondary">
     233                                        <?php esc_html_e('Refresh Cities', 'shift8-real-estate-listings-for-treb'); ?>
     234                                    </button>
     235                                    <span id="shift8-treb-city-cache-status" style="margin-left: 10px; color: #666;"></span>
     236                                </p>
    178237                            </td>
    179238                        </tr>
     
    541600    });
    542601
     602    // Geographic filter type toggle
     603    function toggleGeoFilterRows() {
     604        var filterType = $('#geographic_filter_type').val();
     605        if (filterType === 'postal_prefix') {
     606            $('.shift8-treb-postal-row').show();
     607            $('.shift8-treb-city-row').hide();
     608        } else if (filterType === 'city') {
     609            $('.shift8-treb-postal-row').hide();
     610            $('.shift8-treb-city-row').show();
     611        } else {
     612            $('.shift8-treb-postal-row').hide();
     613            $('.shift8-treb-city-row').hide();
     614        }
     615    }
     616    $('#geographic_filter_type').on('change', toggleGeoFilterRows);
     617    toggleGeoFilterRows();
     618
     619    // Refresh Cities
     620    $('#shift8-treb-refresh-cities').on('click', function() {
     621        var button = $(this);
     622        var statusSpan = $('#shift8-treb-city-cache-status');
     623        button.prop('disabled', true).text('<?php esc_html_e('Refreshing...', 'shift8-real-estate-listings-for-treb'); ?>');
     624        statusSpan.text('');
     625
     626        $.ajax({
     627            url: ajaxurl,
     628            type: 'POST',
     629            data: {
     630                action: 'shift8_treb_get_cities',
     631                nonce: '<?php echo esc_js(wp_create_nonce('shift8_treb_nonce')); ?>',
     632                refresh: 1
     633            },
     634            success: function(response) {
     635                if (response.success) {
     636                    statusSpan.text('<?php esc_html_e('City list refreshed.', 'shift8-real-estate-listings-for-treb'); ?>');
     637                } else {
     638                    statusSpan.css('color', '#dc3232').text('<?php esc_html_e('Failed to refresh city list.', 'shift8-real-estate-listings-for-treb'); ?>');
     639                }
     640            },
     641            error: function() {
     642                statusSpan.css('color', '#dc3232').text('<?php esc_html_e('Request failed.', 'shift8-real-estate-listings-for-treb'); ?>');
     643            },
     644            complete: function() {
     645                button.prop('disabled', false).text('<?php esc_html_e('Refresh Cities', 'shift8-real-estate-listings-for-treb'); ?>');
     646            }
     647        });
     648    });
     649
     650    // City autocomplete
     651    if ($.ui && $.ui.autocomplete) {
     652        function extractLast(term) {
     653            return term.split(/,\s*/).pop();
     654        }
     655        $('#shift8_treb_city_filter').on('keydown', function(event) {
     656            if (event.keyCode === $.ui.keyCode.TAB && $(this).autocomplete('instance').menu.active) {
     657                event.preventDefault();
     658            }
     659        }).autocomplete({
     660            source: function(request, response) {
     661                $.ajax({
     662                    url: ajaxurl,
     663                    type: 'POST',
     664                    data: {
     665                        action: 'shift8_treb_get_cities',
     666                        nonce: '<?php echo esc_js(wp_create_nonce('shift8_treb_nonce')); ?>'
     667                    },
     668                    success: function(data) {
     669                        if (data.success && data.data.cities) {
     670                            var term = extractLast(request.term);
     671                            var filtered = $.ui.autocomplete.filter(data.data.cities, term);
     672                            response(filtered.slice(0, 20));
     673                        } else {
     674                            response([]);
     675                        }
     676                    },
     677                    error: function() {
     678                        response([]);
     679                    }
     680                });
     681            },
     682            search: function() {
     683                var term = extractLast(this.value);
     684                if (term.length < 2) {
     685                    return false;
     686                }
     687            },
     688            focus: function() {
     689                return false;
     690            },
     691            select: function(event, ui) {
     692                var terms = this.value.split(/,\s*/);
     693                terms.pop();
     694                terms.push(ui.item.value);
     695                terms.push('');
     696                this.value = terms.join(', ');
     697                return false;
     698            }
     699        });
     700    }
     701
    543702    // Manual Sync
    544703    $('#manual-sync').on('click', function() {
  • shift8-real-estate-listings-for-treb/trunk/includes/class-shift8-treb-ampre-service.php

    r3387235 r3475951  
    237237        }
    238238
     239        // Add geographic filter based on filter type
     240        $geo_type = $this->settings['geographic_filter_type'] ?? '';
     241        if ($geo_type === 'postal_prefix' && !empty($this->settings['postal_code_prefixes'])) {
     242            $prefixes = array_map('trim', explode(',', $this->settings['postal_code_prefixes']));
     243            $prefix_filters = array();
     244            foreach ($prefixes as $prefix) {
     245                $prefix = strtoupper(sanitize_text_field($prefix));
     246                if (preg_match('/^[A-Z][0-9][A-Z]$/', $prefix)) {
     247                    $prefix_filters[] = "startswith(PostalCode, '" . $prefix . "')";
     248                }
     249            }
     250            if (!empty($prefix_filters)) {
     251                $filters[] = '(' . implode(' or ', $prefix_filters) . ')';
     252            }
     253        } elseif ($geo_type === 'city' && !empty($this->settings['city_filter'])) {
     254            $cities = array_map('trim', explode(',', $this->settings['city_filter']));
     255            $city_filters = array();
     256            foreach ($cities as $city) {
     257                $city = sanitize_text_field($city);
     258                if (!empty($city)) {
     259                    $city_filters[] = "City eq '" . $city . "'";
     260                }
     261            }
     262            if (count($city_filters) === 1) {
     263                $filters[] = $city_filters[0];
     264            } elseif (count($city_filters) > 1) {
     265                $filters[] = '(' . implode(' or ', $city_filters) . ')';
     266            }
     267        }
     268
    239269        // Combine filters
    240270        if (!empty($filters)) {
     
    394424            ));
    395425            return new WP_Error('ampre_media_error', $e->getMessage());
     426        }
     427    }
     428
     429    /**
     430     * Get city lookup values from AMPRE Lookup resource
     431     *
     432     * Fetches the canonical list of city names available in PropTX.
     433     *
     434     * @since 1.7.0
     435     * @return array|WP_Error Array of city name strings or WP_Error
     436     */
     437    public function get_city_lookups() {
     438        try {
     439            if (empty($this->bearer_token)) {
     440                throw new Exception('Bearer token is required');
     441            }
     442
     443            $endpoint = "Lookup?\$filter=LookupName eq 'City'&\$select=LookupValue&\$top=2000";
     444
     445            $response = $this->make_request($endpoint);
     446
     447            if (is_wp_error($response)) {
     448                throw new Exception('Lookup API request failed: ' . esc_html($response->get_error_message()));
     449            }
     450
     451            $response_code = wp_remote_retrieve_response_code($response);
     452            $response_body = wp_remote_retrieve_body($response);
     453
     454            if ($response_code !== 200) {
     455                throw new Exception('Lookup API returned status code: ' . esc_html($response_code));
     456            }
     457
     458            $data = json_decode($response_body, true);
     459
     460            if (json_last_error() !== JSON_ERROR_NONE || !isset($data['value']) || !is_array($data['value'])) {
     461                throw new Exception('Invalid response from Lookup API');
     462            }
     463
     464            $cities = array();
     465            foreach ($data['value'] as $item) {
     466                if (!empty($item['LookupValue'])) {
     467                    $cities[] = $item['LookupValue'];
     468                }
     469            }
     470
     471            sort($cities, SORT_STRING | SORT_FLAG_CASE);
     472
     473            shift8_treb_log('City lookups fetched from AMPRE', array(
     474                'count' => count($cities)
     475            ));
     476
     477            return $cities;
     478
     479        } catch (Exception $e) {
     480            shift8_treb_log('AMPRE city lookup error', array(
     481                'error' => esc_html($e->getMessage())
     482            ));
     483            return new WP_Error('ampre_lookup_error', $e->getMessage());
    396484        }
    397485    }
  • shift8-real-estate-listings-for-treb/trunk/includes/class-shift8-treb-cli.php

    r3385525 r3475951  
    4747     * [--skip-images]
    4848     * : Skip image downloads for faster sync (stores external URLs only)
     49     *
     50     * [--postal-prefix=<prefixes>]
     51     * : Override geographic filter with postal code prefixes (e.g., M5V,M6H)
     52     *
     53     * [--city=<cities>]
     54     * : Override geographic filter with city names (e.g., "Toronto W08,Mississauga")
    4955     *
    5056     * [--sequential-images]
     
    9298
    9399        try {
    94             // Prepare settings overrides for CLI
    95             $settings_overrides = array();
    96            
    97100            // Check bearer token before proceeding
    98101            $base_settings = get_option('shift8_treb_settings', array());
     
    110113            }
    111114
    112             // Handle listing age override
    113             if ($listing_age !== null) {
    114                 $settings_overrides['listing_age_days'] = $listing_age;
    115                 $settings_overrides['last_sync_timestamp'] = null; // Force age-based filtering
    116                 if ($verbose) {
    117                     WP_CLI::line("Using listing age override: {$listing_age} days");
    118                 }
    119             } else {
    120                 // For manual CLI sync, always use listing age instead of incremental sync
    121                 $settings_overrides['last_sync_timestamp'] = null;
    122                 if ($verbose) {
    123                     $listing_age_days = $base_settings['listing_age_days'] ?? 30;
    124                     WP_CLI::line("Using WordPress setting: {$listing_age_days} days (manual sync ignores incremental)");
    125                 }
    126             }
    127 
    128             // Enable members-only filtering if specified
    129             if ($members_only) {
    130                 $settings_overrides['members_only'] = true;
    131                 WP_CLI::line("🎯 Members-only mode: Filtering at API level for configured member IDs");
    132                
    133                 // Validate member IDs are configured
    134                 if (empty($base_settings['member_id'])) {
    135                     WP_CLI::error('Member IDs not configured in settings. Cannot use --members-only flag.');
    136                 }
    137             }
     115            $settings_overrides = $this->build_settings_overrides($assoc_args, $base_settings, $verbose);
    138116
    139117            // Handle image processing options
     
    430408     * default: 90
    431409     * ---
     410     *
     411     * [--members-only]
     412     * : Only show listings from configured member IDs (filters at API level)
     413     *
     414     * [--postal-prefix=<prefixes>]
     415     * : Override geographic filter with postal code prefixes (e.g., M5V,M6H)
     416     *
     417     * [--city=<cities>]
     418     * : Override geographic filter with city names (e.g., "Toronto W08,Mississauga")
    432419     *
    433420     * ## EXAMPLES
     
    453440        }
    454441       
    455         // Get settings and initialize AMPRE service
    456         $settings = get_option('shift8_treb_settings', array());
    457        
    458         if (empty($settings['bearer_token'])) {
    459             WP_CLI::error('Bearer token not configured');
    460         }
    461        
    462         // Decrypt token using the plugin's decryption function
    463         $decrypted_token = shift8_treb_decrypt_data($settings['bearer_token']);
    464        
    465         if (empty($decrypted_token)) {
    466             WP_CLI::error('Failed to decrypt bearer token');
    467         }
    468        
    469         // Prepare settings for AMPRE service
    470         $ampre_settings = array_merge($settings, array(
    471             'bearer_token' => $decrypted_token,
    472             'listing_age_days' => $days
    473         ));
    474        
    475         // Initialize AMPRE service
    476         require_once SHIFT8_TREB_PLUGIN_DIR . 'includes/class-shift8-treb-ampre-service.php';
    477         $ampre_service = new Shift8_TREB_AMPRE_Service($ampre_settings);
    478        
    479         WP_CLI::line('Testing API connection...');
    480        
    481442        try {
    482             $connection_result = $ampre_service->test_connection();
     443            $base_settings = get_option('shift8_treb_settings', array());
     444           
     445            if (empty($base_settings['bearer_token'])) {
     446                WP_CLI::error('Bearer token not configured');
     447            }
     448           
     449            $overrides = $this->build_settings_overrides($assoc_args, $base_settings, false);
     450           
     451            if (!isset($overrides['listing_age_days'])) {
     452                $overrides['listing_age_days'] = $days;
     453                $overrides['last_sync_timestamp'] = null;
     454            }
     455           
     456            if (isset($assoc_args['limit'])) {
     457                $overrides['max_listings_per_query'] = $limit;
     458            }
     459           
     460            require_once SHIFT8_TREB_PLUGIN_DIR . 'includes/class-shift8-treb-sync-service.php';
     461            $sync_service = new Shift8_TREB_Sync_Service($overrides);
     462           
     463            WP_CLI::line('Testing API connection...');
     464            $connection_result = $sync_service->test_connection();
    483465            if (!$connection_result['success']) {
    484466                WP_CLI::error('API connection failed: ' . $connection_result['message']);
    485467            }
    486468            WP_CLI::success('API connection successful');
    487         } catch (Exception $e) {
    488             WP_CLI::error('API connection error: ' . esc_html($e->getMessage()));
    489         }
    490        
    491         WP_CLI::line('Fetching listings data...');
    492        
    493         try {
    494             $listings_result = $ampre_service->get_listings();
     469           
     470            WP_CLI::line('Fetching listings data...');
     471           
     472            $listings_result = $sync_service->fetch_listings();
    495473           
    496474            if (is_wp_error($listings_result)) {
     
    501479            $total_found = count($listings);
    502480           
    503             // Limit the listings for analysis
    504481            if ($limit > 0 && $total_found > $limit) {
    505482                $listings = array_slice($listings, 0, $limit);
     
    508485            WP_CLI::success("Found {$total_found} total listings, analyzing first " . count($listings));
    509486           
    510             // Analyze the data
     487            $settings = get_option('shift8_treb_settings', array());
    511488            $this->analyze_listings_data($listings, $search_mls, $show_agents, $settings);
    512489           
     
    942919
    943920    /**
     921     * Preview listings from API without creating posts
     922     *
     923     * Shows a summary of what the API would return with current settings,
     924     * including price range, city breakdown, property types, and agent summary.
     925     *
     926     * ## OPTIONS
     927     *
     928     * [--limit=<number>]
     929     * : Limit number of listings to fetch
     930     *
     931     * [--listing-age=<days>]
     932     * : Override listing age in days
     933     *
     934     * [--members-only]
     935     * : Only show listings from configured member IDs
     936     *
     937     * [--postal-prefix=<prefixes>]
     938     * : Override geographic filter with postal code prefixes (e.g., M5V,M6H)
     939     *
     940     * [--city=<cities>]
     941     * : Override geographic filter with city names (e.g., "Toronto W08,Mississauga")
     942     *
     943     * [--format=<format>]
     944     * : Output format (table or json)
     945     * ---
     946     * default: table
     947     * options:
     948     *   - table
     949     *   - json
     950     * ---
     951     *
     952     * ## EXAMPLES
     953     *
     954     *     wp shift8-treb preview
     955     *     wp shift8-treb preview --postal-prefix=M5V,M6H,M8X
     956     *     wp shift8-treb preview --city="Toronto W08,Mississauga"
     957     *     wp shift8-treb preview --limit=20 --members-only
     958     *     wp shift8-treb preview --format=json
     959     *
     960     * @when after_wp_load
     961     */
     962    public function preview($args, $assoc_args) {
     963        $format = isset($assoc_args['format']) ? $assoc_args['format'] : 'table';
     964
     965        WP_CLI::line('=== Shift8 TREB Listing Preview ===');
     966        WP_CLI::line('Querying API with current settings (no posts will be created)...');
     967        WP_CLI::line('');
     968
     969        try {
     970            $base_settings = get_option('shift8_treb_settings', array());
     971
     972            if (empty($base_settings['bearer_token'])) {
     973                WP_CLI::error('Bearer token not configured.');
     974            }
     975
     976            $overrides = $this->build_settings_overrides($assoc_args, $base_settings, true);
     977
     978            require_once SHIFT8_TREB_PLUGIN_DIR . 'includes/class-shift8-treb-sync-service.php';
     979            $sync_service = new Shift8_TREB_Sync_Service($overrides);
     980
     981            WP_CLI::line('Testing API connection...');
     982            $connection = $sync_service->test_connection();
     983            if (!$connection['success']) {
     984                WP_CLI::error('API connection failed: ' . $connection['message']);
     985            }
     986            WP_CLI::success('API connection successful');
     987            WP_CLI::line('');
     988
     989            WP_CLI::line('Fetching listings...');
     990            $listings = $sync_service->fetch_listings();
     991
     992            if (is_wp_error($listings)) {
     993                WP_CLI::error('API error: ' . $listings->get_error_message());
     994            }
     995
     996            $total = count($listings);
     997            if ($total === 0) {
     998                WP_CLI::warning('No listings returned from API with current filters.');
     999                return;
     1000            }
     1001
     1002            WP_CLI::success("Retrieved {$total} listing(s)");
     1003            WP_CLI::line('');
     1004
     1005            $prices = array();
     1006            $cities = array();
     1007            $property_types = array();
     1008            $agents = array();
     1009            $member_ids = !empty($base_settings['member_id'])
     1010                ? array_map('trim', explode(',', $base_settings['member_id']))
     1011                : array();
     1012
     1013            foreach ($listings as $listing) {
     1014                if (isset($listing['ListPrice']) && $listing['ListPrice'] > 0) {
     1015                    $prices[] = $listing['ListPrice'];
     1016                }
     1017                $city = $listing['City'] ?? 'Unknown';
     1018                $cities[$city] = ($cities[$city] ?? 0) + 1;
     1019
     1020                $ptype = $listing['PropertyType'] ?? 'Unknown';
     1021                $property_types[$ptype] = ($property_types[$ptype] ?? 0) + 1;
     1022
     1023                $agent = $listing['ListAgentKey'] ?? 'Unknown';
     1024                $agents[$agent] = ($agents[$agent] ?? 0) + 1;
     1025            }
     1026
     1027            if ($format === 'json') {
     1028                $data = array(
     1029                    'total' => $total,
     1030                    'price_min' => !empty($prices) ? min($prices) : 0,
     1031                    'price_max' => !empty($prices) ? max($prices) : 0,
     1032                    'price_median' => !empty($prices) ? $prices[intval(count($prices) / 2)] : 0,
     1033                    'cities' => $cities,
     1034                    'property_types' => $property_types,
     1035                    'agents' => $agents,
     1036                );
     1037                WP_CLI::line(wp_json_encode($data, JSON_PRETTY_PRINT));
     1038                return;
     1039            }
     1040
     1041            if (!empty($prices)) {
     1042                sort($prices);
     1043                WP_CLI::line('--- Price Summary ---');
     1044                WP_CLI::line('  Min:    $' . number_format(min($prices)));
     1045                WP_CLI::line('  Max:    $' . number_format(max($prices)));
     1046                WP_CLI::line('  Median: $' . number_format($prices[intval(count($prices) / 2)]));
     1047                WP_CLI::line('');
     1048            }
     1049
     1050            arsort($cities);
     1051            WP_CLI::line('--- City Breakdown ---');
     1052            foreach ($cities as $city => $count) {
     1053                WP_CLI::line("  {$city}: {$count}");
     1054            }
     1055            WP_CLI::line('');
     1056
     1057            arsort($property_types);
     1058            WP_CLI::line('--- Property Types ---');
     1059            foreach ($property_types as $ptype => $count) {
     1060                WP_CLI::line("  {$ptype}: {$count}");
     1061            }
     1062            WP_CLI::line('');
     1063
     1064            arsort($agents);
     1065            $top_agents = array_slice($agents, 0, 10, true);
     1066            WP_CLI::line('--- Top Agents (max 10) ---');
     1067            foreach ($top_agents as $agent => $count) {
     1068                $tag = in_array($agent, $member_ids, true) ? ' [MEMBER]' : '';
     1069                WP_CLI::line("  {$agent}: {$count}{$tag}");
     1070            }
     1071
     1072            WP_CLI::line('');
     1073            WP_CLI::success("Preview complete: {$total} listings matched.");
     1074
     1075        } catch (Exception $e) {
     1076            WP_CLI::error('Preview failed: ' . esc_html($e->getMessage()));
     1077        }
     1078    }
     1079
     1080    /**
    9441081     * Clear all TREB sync logs
    9451082     *
     
    11341271        }
    11351272    }
     1273
     1274    /**
     1275     * Build settings overrides from CLI arguments
     1276     *
     1277     * @since 1.8.0
     1278     * @param array $assoc_args CLI associative arguments
     1279     * @param array $base_settings Current plugin settings
     1280     * @param bool $verbose Whether to show verbose output
     1281     * @return array Settings overrides
     1282     */
     1283    private function build_settings_overrides($assoc_args, $base_settings, $verbose = false) {
     1284        $overrides = array();
     1285
     1286        if (isset($assoc_args['listing-age'])) {
     1287            $overrides['listing_age_days'] = intval($assoc_args['listing-age']);
     1288            $overrides['last_sync_timestamp'] = null;
     1289            if ($verbose) {
     1290                WP_CLI::line("Using listing age override: " . $overrides['listing_age_days'] . " days");
     1291            }
     1292        } else {
     1293            $overrides['last_sync_timestamp'] = null;
     1294        }
     1295
     1296        if (isset($assoc_args['members-only'])) {
     1297            if (empty($base_settings['member_id'])) {
     1298                WP_CLI::error('Member IDs not configured in settings. Cannot use --members-only.');
     1299            }
     1300            $overrides['members_only'] = true;
     1301            WP_CLI::line("Members-only mode: Filtering at API level for configured member IDs");
     1302        }
     1303
     1304        if (isset($assoc_args['postal-prefix']) && isset($assoc_args['city'])) {
     1305            WP_CLI::error('--postal-prefix and --city are mutually exclusive. Use one or the other.');
     1306        }
     1307
     1308        if (isset($assoc_args['postal-prefix'])) {
     1309            $raw = strtoupper(trim($assoc_args['postal-prefix']));
     1310            $valid = array();
     1311            foreach (array_map('trim', explode(',', $raw)) as $p) {
     1312                if (preg_match('/^[A-Z][0-9][A-Z]$/', $p)) {
     1313                    $valid[] = $p;
     1314                }
     1315            }
     1316            if (!empty($valid)) {
     1317                $overrides['geographic_filter_type'] = 'postal_prefix';
     1318                $overrides['postal_code_prefixes'] = implode(',', $valid);
     1319                WP_CLI::line("Geographic filter: Postal prefix(es) " . implode(', ', $valid));
     1320            } else {
     1321                WP_CLI::warning('No valid postal code prefixes provided. Expected format: A1A (e.g., M5V)');
     1322            }
     1323        } elseif (isset($assoc_args['city'])) {
     1324            $overrides['geographic_filter_type'] = 'city';
     1325            $overrides['city_filter'] = sanitize_text_field(trim($assoc_args['city']));
     1326            WP_CLI::line("Geographic filter: City = " . esc_html($overrides['city_filter']));
     1327        }
     1328
     1329        if (isset($assoc_args['limit'])) {
     1330            $overrides['max_listings_per_query'] = intval($assoc_args['limit']);
     1331        }
     1332
     1333        return $overrides;
     1334    }
    11361335}
    11371336
  • shift8-real-estate-listings-for-treb/trunk/includes/class-shift8-treb-sync-service.php

    r3452434 r3475951  
    106106    public function test_connection() {
    107107        return $this->ampre_service->test_connection();
     108    }
     109
     110    /**
     111     * Fetch listings from API without processing them
     112     *
     113     * Uses the same settings, filters, and query construction as execute_sync()
     114     * but returns raw listing data without creating or modifying any posts.
     115     *
     116     * @since 1.7.0
     117     * @return array|WP_Error Array of listing data or WP_Error on failure
     118     */
     119    public function fetch_listings() {
     120        if (empty($this->settings['bearer_token'])) {
     121            return new WP_Error('missing_token', 'Bearer token not configured');
     122        }
     123
     124        return $this->ampre_service->get_listings();
    108125    }
    109126
  • shift8-real-estate-listings-for-treb/trunk/readme.txt

    r3452434 r3475951  
    55Tested up to: 6.8
    66Requires PHP: 7.4
    7 Stable tag: 1.7.4
     7Stable tag: 1.8.0
    88License: GPLv3 or later
    99License URI: https://www.gnu.org/licenses/gpl-3.0.html
     
    2626* **WalkScore Integration** - Walkability scoring for properties
    2727* **Member-Based Categorization** - Automatic categorization based on agent membership
     28* **Geographic Filtering** - Restrict listings by postal code prefix (FSA) or city name with autocomplete
    2829* **Sold Listing Management** - Automatically updates existing listings to sold status with title prefix and tags
    2930* **WP-CLI Support** - Full command-line interface for server management
     
    4445* **Direct MLS Import** - Import specific listings via WP-CLI
    4546* **API Diagnostics** - Raw API response analysis for troubleshooting
     47* **Geographic Filtering** - Filter by postal code prefix (FSA) or city name, mutually exclusive via admin dropdown
     48* **Listing Preview** - Preview API results via WP-CLI without creating posts
    4649* **Sync Mode Management** - Control over incremental vs age-based synchronization
    4750* **Security Focused** - All input sanitized, output escaped, encrypted credential storage
     
    132135
    133136== Changelog ==
     137
     138= 1.8.0 =
     139* **Geographic Region Filtering**: Restrict listings by postal code prefix (FSA) or city name
     140* **Postal Code Prefix Filter**: Uses OData `startswith(PostalCode, 'M5V')` for precise area targeting with FSA validation
     141* **City Name Filter**: Uses `City eq 'CityName'` with autocomplete powered by AMPRE Lookup API
     142* **Mutually Exclusive Filters**: Admin dropdown selects filter type (None, Postal Prefix, City) -- one at a time
     143* **City Autocomplete**: jQuery UI Autocomplete with cached canonical city names from AMPRE Lookup API (30-day cache)
     144* **Server-Side Validation**: Cities validated against canonical list on save; invalid entries stripped with admin warning
     145* **Client-Side Validation**: Real-time form validation against loaded city list before submission
     146* **Listing Preview Command**: New `wp shift8-treb preview` shows API results without creating posts -- includes price stats, city/agent breakdown
     147* **CLI Geographic Overrides**: `--postal-prefix=M5V,M6H` and `--city="Toronto W08,Mississauga"` flags for sync, preview, and analyze commands
     148* **CLI Code Consolidation**: Extracted shared `build_settings_overrides()` helper -- sync, preview, and analyze all parse CLI flags identically
     149* **Shared Fetch Method**: Added `Sync_Service::fetch_listings()` so preview and analyze use identical query construction as real sync
     150* **Analyze Command Upgrade**: Now uses Sync_Service instead of manual token handling -- supports all geographic filters
     151* **AMPRE API Compatibility**: Removed `tolower()` from city queries (AMPRE returns HTTP 501 for OData string functions)
     152* **Comprehensive Test Coverage**: 182 tests passing with 557 assertions, including 4 new fetch_listings tests
     153* Settings sanitization consolidated into single authoritative location, eliminating duplicate registration
    134154
    135155= 1.7.4 =
     
    300320== Upgrade Notice ==
    301321
     322= 1.8.0 =
     323New feature: Geographic region filtering allows restricting listings by postal code prefix or city name. New WP-CLI preview command for API query analysis. CLI commands consolidated to eliminate code duplication.
     324
    302325= 1.6.2 =
    303326Critical update: Resolves three major production issues - duplicate images, geocoding failures, and duplicate posts. Enhanced reliability with comprehensive test coverage and zero-tolerance testing approach. Includes intelligent address cleaning and multi-layered duplicate detection.
  • shift8-real-estate-listings-for-treb/trunk/shift8-treb.php

    r3452434 r3475951  
    44 * Plugin URI: https://github.com/stardothosting/shift8-treb
    55 * Description: Integrates Toronto Real Estate Board (TREB) listings via AMPRE API, automatically importing property listings into WordPress. Replaces the Python script with native WordPress functionality.
    6  * Version: 1.7.4
     6 * Version: 1.8.0
    77 * Author: Shift8 Web
    88 * Author URI: https://shift8web.ca
     
    2222
    2323// Plugin constants
    24 define('SHIFT8_TREB_VERSION', '1.7.4');
     24define('SHIFT8_TREB_VERSION', '1.8.0');
    2525define('SHIFT8_TREB_PLUGIN_FILE', __FILE__);
    2626define('SHIFT8_TREB_PLUGIN_DIR', plugin_dir_path(__FILE__));
     
    262262                    'listing_age_days' => '30',
    263263                    'listing_template' => 'Property Details:\n\nAddress: %ADDRESS%\nPrice: %PRICE%\nMLS: %MLS%\nBedrooms: %BEDROOMS%\nBathrooms: %BATHROOMS%\nSquare Feet: %SQFT%\n\nDescription:\n%DESCRIPTION%',
    264                     'post_excerpt_template' => '%ADDRESS%\n%LISTPRICE%\nMLS : %MLSNUMBER%'
     264                    'post_excerpt_template' => '%ADDRESS%\n%LISTPRICE%\nMLS : %MLSNUMBER%',
     265                    'geographic_filter_type' => '',
     266                    'postal_code_prefixes' => '',
     267                    'city_filter' => ''
    265268                )
    266269            )
     
    355358            $sanitized['post_excerpt_template'] = wp_kses_post($input['post_excerpt_template']);
    356359        }
    357        
     360
     361        // Sanitize geographic filter settings
     362        $allowed_geo_types = array('', 'postal_prefix', 'city');
     363        if (isset($input['geographic_filter_type']) && in_array($input['geographic_filter_type'], $allowed_geo_types, true)) {
     364            $sanitized['geographic_filter_type'] = $input['geographic_filter_type'];
     365        } else {
     366            $sanitized['geographic_filter_type'] = '';
     367        }
     368
     369        if (isset($input['postal_code_prefixes'])) {
     370            $raw_prefixes = sanitize_text_field(trim($input['postal_code_prefixes']));
     371            if (!empty($raw_prefixes)) {
     372                $prefixes = array_map('trim', explode(',', $raw_prefixes));
     373                $valid_prefixes = array();
     374                foreach ($prefixes as $prefix) {
     375                    $prefix = strtoupper($prefix);
     376                    if (preg_match('/^[A-Z][0-9][A-Z]$/', $prefix)) {
     377                        $valid_prefixes[] = $prefix;
     378                    }
     379                }
     380                $sanitized['postal_code_prefixes'] = implode(',', $valid_prefixes);
     381            } else {
     382                $sanitized['postal_code_prefixes'] = '';
     383            }
     384        }
     385
     386        if (isset($input['city_filter'])) {
     387            $raw_cities = sanitize_text_field(trim($input['city_filter']));
     388            if (!empty($raw_cities)) {
     389                $cities = array_filter(array_map('trim', explode(',', $raw_cities)));
     390                $cached_cities = get_transient('shift8_treb_city_lookups');
     391
     392                if (is_array($cached_cities) && !empty($cached_cities)) {
     393                    $lowered_cache = array_map('strtolower', $cached_cities);
     394                    $valid_cities = array();
     395                    $invalid_cities = array();
     396                    foreach ($cities as $city) {
     397                        $idx = array_search(strtolower($city), $lowered_cache, true);
     398                        if ($idx !== false) {
     399                            $valid_cities[] = $cached_cities[$idx];
     400                        } else {
     401                            $invalid_cities[] = $city;
     402                        }
     403                    }
     404                    if (!empty($invalid_cities)) {
     405                        add_settings_error(
     406                            'shift8_treb_settings',
     407                            'invalid_cities',
     408                            sprintf(
     409                                esc_html__('Unrecognised city name(s) removed: %s. Use the autocomplete suggestions.', 'shift8-real-estate-listings-for-treb'),
     410                                esc_html(implode(', ', $invalid_cities))
     411                            ),
     412                            'warning'
     413                        );
     414                    }
     415                    $sanitized['city_filter'] = implode(', ', $valid_cities);
     416                } else {
     417                    $sanitized['city_filter'] = implode(', ', $cities);
     418                }
     419            } else {
     420                $sanitized['city_filter'] = '';
     421            }
     422        }
     423
    358424        // Add success message
    359425        add_settings_error(
     
    614680                'google_maps_api_key' => '',
    615681                'listing_status_filter' => 'Active',
    616                 'city_filter' => 'Toronto',
    617                 'max_listings_per_sync' => 100
     682                'city_filter' => '',
     683                'max_listings_per_sync' => 100,
     684                'geographic_filter_type' => '',
     685                'postal_code_prefixes' => ''
    618686            ));
    619687        }
Note: See TracChangeset for help on using the changeset viewer.