Files
homeproz/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-manual-property.php
T
root 57b752f54e Manual property enhancements: MLS status sync, agent clone, description formatting
- Manual properties linked to MLS now inherit status (Active/Pending/Closed) and
  days_on_market from the MLS listing dynamically
- Properties not in MLS default to Closed status
- Clone feature now auto-populates listing agent by matching MLS ID to Agent CPT
- Description formatter detects embedded headers (unpunctuated text after sentences)
  and splits them into separate paragraphs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:28:44 +00:00

1257 lines
43 KiB
PHP
Executable File

<?php
/**
* Manual Property Entry System
*
* Allows manual entry of properties that integrate with MLS-synced listings.
* Supports override of MLS listings by matching MLS ID.
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Manual_Property {
/**
* @var MLS_DB
*/
private $db;
/**
* @var MLS_Geocoder
*/
private $geocoder;
/**
* Constructor
*/
public function __construct($db = null) {
$this->db = $db ?: new MLS_DB();
// Load geocoder if available
if (class_exists('MLS_Geocoder')) {
$this->geocoder = new MLS_Geocoder();
}
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Register CPT
add_action('init', array($this, 'register_post_type'));
// Register ACF fields
add_action('acf/init', array($this, 'register_acf_fields'));
// Sync to database on save
add_action('acf/save_post', array($this, 'sync_to_database'), 20);
// Delete from database on trash/delete
add_action('wp_trash_post', array($this, 'on_trash_post'));
add_action('before_delete_post', array($this, 'on_delete_post'));
// Restore from trash
add_action('untrashed_post', array($this, 'on_untrash_post'));
// Admin columns
add_filter('manage_manual_property_posts_columns', array($this, 'add_admin_columns'));
add_action('manage_manual_property_posts_custom_column', array($this, 'render_admin_columns'), 10, 2);
add_filter('manage_edit-manual_property_sortable_columns', array($this, 'sortable_columns'));
// Admin row actions
add_filter('post_row_actions', array($this, 'add_row_actions'), 10, 2);
// Clone from MLS AJAX handlers
add_action('wp_ajax_mls_search_for_clone', array($this, 'ajax_search_for_clone'));
add_action('wp_ajax_mls_clone_listing', array($this, 'ajax_clone_listing'));
// Admin scripts
add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_scripts'));
// Add clone button to admin
add_action('edit_form_top', array($this, 'render_clone_button'));
}
/**
* Register the Custom Post Type
*/
public function register_post_type() {
$labels = array(
'name' => 'Manual Properties',
'singular_name' => 'Manual Property',
'menu_name' => 'Manual Properties',
'add_new' => 'Add Property',
'add_new_item' => 'Add New Property',
'edit_item' => 'Edit Property',
'new_item' => 'New Property',
'view_item' => 'View Property',
'search_items' => 'Search Properties',
'not_found' => 'No properties found',
'not_found_in_trash' => 'No properties found in trash',
'all_items' => 'All Properties',
);
$args = array(
'labels' => $labels,
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_position' => 26,
'menu_icon' => 'dashicons-building',
'capability_type' => 'post',
'hierarchical' => false,
'supports' => array('title'),
'has_archive' => false,
'rewrite' => false,
'query_var' => false,
'show_in_rest' => false,
);
register_post_type('manual_property', $args);
}
/**
* Register ACF fields programmatically
*/
public function register_acf_fields() {
if (!function_exists('acf_add_local_field_group')) {
return;
}
acf_add_local_field_group(array(
'key' => 'group_manual_property',
'title' => 'Property Details',
'fields' => $this->get_field_definitions(),
'location' => array(
array(
array(
'param' => 'post_type',
'operator' => '==',
'value' => 'manual_property',
),
),
),
'menu_order' => 0,
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
'instruction_placement' => 'label',
));
}
/**
* Get ACF field definitions
*/
private function get_field_definitions() {
return array(
// Tab: Basic Info
array(
'key' => 'field_mp_tab_basic',
'label' => 'Basic Info',
'type' => 'tab',
),
array(
'key' => 'field_mp_listing_id',
'label' => 'MLS #',
'name' => 'listing_id',
'type' => 'text',
'instructions' => 'Optional. If set, this listing will override any MLS-synced listing with the same MLS ID.',
),
array(
'key' => 'field_mp_standard_status',
'label' => 'Status',
'name' => 'standard_status',
'type' => 'select',
'choices' => array(
'Active' => 'Active',
'Pending' => 'Pending',
'Sold' => 'Sold',
'Cancelled' => 'Cancelled',
'Expired' => 'Expired',
'Withdrawn' => 'Withdrawn',
),
'default_value' => 'Active',
'required' => 1,
),
array(
'key' => 'field_mp_list_price',
'label' => 'List Price',
'name' => 'list_price',
'type' => 'number',
'prepend' => '$',
'min' => 0,
),
array(
'key' => 'field_mp_close_price',
'label' => 'Close Price',
'name' => 'close_price',
'type' => 'number',
'prepend' => '$',
'min' => 0,
'instructions' => 'Final sale price (for sold properties).',
),
array(
'key' => 'field_mp_property_type',
'label' => 'Property Type',
'name' => 'property_type',
'type' => 'select',
'choices' => array(
'Residential' => 'Residential',
'Land' => 'Land',
'Commercial' => 'Commercial',
'Multi-Family' => 'Multi-Family',
'Farm' => 'Farm',
),
'default_value' => 'Residential',
),
array(
'key' => 'field_mp_property_sub_type',
'label' => 'Property Subtype',
'name' => 'property_sub_type',
'type' => 'text',
'instructions' => 'E.g., Single Family, Townhouse, Condo, etc.',
),
array(
'key' => 'field_mp_is_homeproz',
'label' => 'HomeProz Listing',
'name' => 'is_homeproz',
'type' => 'true_false',
'message' => 'This is a HomeProz listing',
'default_value' => 1,
'ui' => 1,
),
array(
'key' => 'field_mp_is_featured',
'label' => 'Featured Listing',
'name' => 'is_featured',
'type' => 'true_false',
'message' => 'Feature this listing on the homepage',
'default_value' => 0,
'ui' => 1,
),
// Tab: Location
array(
'key' => 'field_mp_tab_location',
'label' => 'Location',
'type' => 'tab',
),
array(
'key' => 'field_mp_full_address',
'label' => 'Full Address',
'name' => 'full_address',
'type' => 'text',
'instructions' => 'Enter the full address. City, state, zip, and coordinates will be auto-filled via geocoding.',
'placeholder' => '123 Main St, Albert Lea, MN 56007',
),
array(
'key' => 'field_mp_unit_number',
'label' => 'Unit Number',
'name' => 'unit_number',
'type' => 'text',
),
array(
'key' => 'field_mp_city',
'label' => 'City',
'name' => 'city',
'type' => 'text',
'instructions' => 'Auto-filled from geocoding, but can be edited.',
),
array(
'key' => 'field_mp_state',
'label' => 'State',
'name' => 'state_or_province',
'type' => 'text',
'default_value' => 'MN',
),
array(
'key' => 'field_mp_postal_code',
'label' => 'ZIP Code',
'name' => 'postal_code',
'type' => 'text',
),
array(
'key' => 'field_mp_county',
'label' => 'County',
'name' => 'county',
'type' => 'text',
),
array(
'key' => 'field_mp_latitude',
'label' => 'Latitude',
'name' => 'latitude',
'type' => 'number',
'step' => 'any',
'instructions' => 'Auto-filled from geocoding.',
),
array(
'key' => 'field_mp_longitude',
'label' => 'Longitude',
'name' => 'longitude',
'type' => 'number',
'step' => 'any',
'instructions' => 'Auto-filled from geocoding.',
),
// Tab: Property Details
array(
'key' => 'field_mp_tab_details',
'label' => 'Details',
'type' => 'tab',
),
array(
'key' => 'field_mp_beds',
'label' => 'Bedrooms',
'name' => 'bedrooms_total',
'type' => 'number',
'min' => 0,
),
array(
'key' => 'field_mp_baths',
'label' => 'Bathrooms',
'name' => 'bathrooms_total',
'type' => 'number',
'min' => 0,
'step' => 0.5,
),
array(
'key' => 'field_mp_baths_full',
'label' => 'Full Bathrooms',
'name' => 'bathrooms_full',
'type' => 'number',
'min' => 0,
),
array(
'key' => 'field_mp_baths_half',
'label' => 'Half Bathrooms',
'name' => 'bathrooms_half',
'type' => 'number',
'min' => 0,
),
array(
'key' => 'field_mp_sqft',
'label' => 'Square Feet',
'name' => 'living_area',
'type' => 'number',
'min' => 0,
'append' => 'sq ft',
),
array(
'key' => 'field_mp_lot_acres',
'label' => 'Lot Size (Acres)',
'name' => 'lot_size_area',
'type' => 'number',
'min' => 0,
'step' => 0.01,
),
array(
'key' => 'field_mp_year_built',
'label' => 'Year Built',
'name' => 'year_built',
'type' => 'number',
'min' => 1800,
'max' => 2100,
),
array(
'key' => 'field_mp_stories',
'label' => 'Stories',
'name' => 'stories',
'type' => 'number',
'min' => 0,
),
array(
'key' => 'field_mp_garage',
'label' => 'Garage Spaces',
'name' => 'garage_spaces',
'type' => 'number',
'min' => 0,
),
array(
'key' => 'field_mp_style',
'label' => 'Architectural Style',
'name' => 'architectural_style',
'type' => 'text',
),
// Tab: Description
array(
'key' => 'field_mp_tab_description',
'label' => 'Description',
'type' => 'tab',
),
array(
'key' => 'field_mp_remarks',
'label' => 'Public Description',
'name' => 'public_remarks',
'type' => 'textarea',
'rows' => 6,
),
array(
'key' => 'field_mp_private_remarks',
'label' => 'Private Notes',
'name' => 'private_remarks',
'type' => 'textarea',
'rows' => 3,
'instructions' => 'Internal notes (not shown on frontend).',
),
// Tab: Media
array(
'key' => 'field_mp_tab_media',
'label' => 'Media',
'type' => 'tab',
),
array(
'key' => 'field_mp_gallery',
'label' => 'Property Photos',
'name' => 'gallery',
'type' => 'gallery',
'return_format' => 'array',
'preview_size' => 'medium',
'library' => 'all',
'min' => 0,
'max' => 50,
'instructions' => 'Upload property photos. First image is the primary photo.',
),
// Tab: Agent
array(
'key' => 'field_mp_tab_agent',
'label' => 'Agent',
'type' => 'tab',
),
array(
'key' => 'field_mp_agent',
'label' => 'Listing Agent',
'name' => 'agent',
'type' => 'post_object',
'post_type' => array('agent'),
'return_format' => 'id',
'allow_null' => 1,
),
array(
'key' => 'field_mp_co_agent',
'label' => 'Co-Listing Agent',
'name' => 'co_list_agent',
'type' => 'post_object',
'post_type' => array('agent'),
'return_format' => 'id',
'allow_null' => 1,
),
// Tab: Dates
array(
'key' => 'field_mp_tab_dates',
'label' => 'Dates',
'type' => 'tab',
),
array(
'key' => 'field_mp_list_date',
'label' => 'List Date',
'name' => 'list_date',
'type' => 'date_picker',
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
),
array(
'key' => 'field_mp_contract_date',
'label' => 'Contract Date',
'name' => 'contract_date',
'type' => 'date_picker',
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
),
array(
'key' => 'field_mp_close_date',
'label' => 'Close Date',
'name' => 'close_date',
'type' => 'date_picker',
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
),
array(
'key' => 'field_mp_expiration_date',
'label' => 'Expiration Date',
'name' => 'expiration_date',
'type' => 'date_picker',
'display_format' => 'F j, Y',
'return_format' => 'Y-m-d',
),
// Tab: Additional
array(
'key' => 'field_mp_tab_additional',
'label' => 'Additional',
'type' => 'tab',
),
array(
'key' => 'field_mp_virtual_tour',
'label' => 'Virtual Tour URL',
'name' => 'virtual_tour_url',
'type' => 'url',
),
array(
'key' => 'field_mp_directions',
'label' => 'Directions',
'name' => 'directions',
'type' => 'textarea',
'rows' => 3,
),
array(
'key' => 'field_mp_hoa_fee',
'label' => 'HOA/Association Fee',
'name' => 'association_fee',
'type' => 'number',
'prepend' => '$',
'append' => '/month',
'min' => 0,
),
);
}
/**
* Sync property data to database on save
*/
public function sync_to_database($post_id) {
// Only for manual_property
if (get_post_type($post_id) !== 'manual_property') {
return;
}
// Skip autosave
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
// Skip revisions
if (wp_is_post_revision($post_id)) {
return;
}
// Skip if trashed
if (get_post_status($post_id) === 'trash') {
return;
}
global $wpdb;
// Generate listing key
$listing_key = 'MANUAL-' . $post_id;
// Geocode address if changed
$full_address = get_field('full_address', $post_id);
$this->maybe_geocode_address($post_id, $full_address);
// Parse address components
$address_parts = $this->parse_address($full_address);
// Get gallery photos count
$gallery = get_field('gallery', $post_id);
$photos_count = is_array($gallery) ? count($gallery) : 0;
// Prepare data
$data = array(
'wp_post_id' => $post_id,
'listing_key' => $listing_key,
'listing_id' => get_field('listing_id', $post_id) ?: null,
'standard_status' => get_field('standard_status', $post_id) ?: 'Active',
'list_price' => get_field('list_price', $post_id) ?: null,
'close_price' => get_field('close_price', $post_id) ?: null,
'original_list_price' => get_field('list_price', $post_id) ?: null,
'street_number' => $address_parts['street_number'] ?? null,
'street_name' => $address_parts['street_name'] ?? null,
'street_suffix' => $address_parts['street_suffix'] ?? null,
'unit_number' => get_field('unit_number', $post_id) ?: null,
'full_address' => $full_address ?: null,
'city' => get_field('city', $post_id) ?: null,
'state_or_province' => get_field('state_or_province', $post_id) ?: 'MN',
'postal_code' => get_field('postal_code', $post_id) ?: null,
'county' => get_field('county', $post_id) ?: null,
'latitude' => get_field('latitude', $post_id) ?: null,
'longitude' => get_field('longitude', $post_id) ?: null,
'property_type' => get_field('property_type', $post_id) ?: null,
'property_sub_type' => get_field('property_sub_type', $post_id) ?: null,
'bedrooms_total' => get_field('bedrooms_total', $post_id) ?: null,
'bathrooms_total' => get_field('bathrooms_total', $post_id) ?: null,
'bathrooms_full' => get_field('bathrooms_full', $post_id) ?: null,
'bathrooms_half' => get_field('bathrooms_half', $post_id) ?: null,
'living_area' => get_field('living_area', $post_id) ?: null,
'lot_size_area' => get_field('lot_size_area', $post_id) ?: null,
'lot_size_units' => 'Acres',
'year_built' => get_field('year_built', $post_id) ?: null,
'stories' => get_field('stories', $post_id) ?: null,
'garage_spaces' => get_field('garage_spaces', $post_id) ?: null,
'architectural_style' => get_field('architectural_style', $post_id) ?: null,
'public_remarks' => get_field('public_remarks', $post_id) ?: null,
'private_remarks' => get_field('private_remarks', $post_id) ?: null,
'directions' => get_field('directions', $post_id) ?: null,
'list_agent_post_id' => get_field('agent', $post_id) ?: null,
'co_list_agent_post_id' => get_field('co_list_agent', $post_id) ?: null,
'is_homeproz' => get_field('is_homeproz', $post_id) ? 1 : 0,
'is_featured' => get_field('is_featured', $post_id) ? 1 : 0,
'virtual_tour_url' => get_field('virtual_tour_url', $post_id) ?: null,
'association_fee' => get_field('association_fee', $post_id) ?: null,
'list_date' => get_field('list_date', $post_id) ?: null,
'contract_date' => get_field('contract_date', $post_id) ?: null,
'close_date' => get_field('close_date', $post_id) ?: null,
'expiration_date' => get_field('expiration_date', $post_id) ?: null,
'photos_count' => $photos_count,
);
// Check if record exists
$table = $this->db->manual_properties_table();
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM {$table} WHERE wp_post_id = %d",
$post_id
));
if ($exists) {
$wpdb->update($table, $data, array('wp_post_id' => $post_id));
} else {
$wpdb->insert($table, $data);
}
}
/**
* Geocode address if changed
*/
private function maybe_geocode_address($post_id, $full_address) {
if (!$this->geocoder || empty($full_address)) {
return;
}
// Check if address changed
$stored_address = get_post_meta($post_id, '_geocoded_address', true);
if ($stored_address === $full_address) {
return; // Already geocoded
}
// Geocode the address
$result = $this->geocoder->geocode($full_address);
if (!$result) {
return;
}
// Update fields
if (!empty($result['city'])) {
update_field('city', $result['city'], $post_id);
}
if (!empty($result['state'])) {
update_field('state_or_province', $result['state'], $post_id);
}
if (!empty($result['postal_code'])) {
update_field('postal_code', $result['postal_code'], $post_id);
}
if (!empty($result['county'])) {
update_field('county', $result['county'], $post_id);
}
if (!empty($result['latitude'])) {
update_field('latitude', $result['latitude'], $post_id);
}
if (!empty($result['longitude'])) {
update_field('longitude', $result['longitude'], $post_id);
}
// Mark as geocoded
update_post_meta($post_id, '_geocoded_address', $full_address);
}
/**
* Parse address string into components
*/
private function parse_address($address) {
if (empty($address)) {
return array();
}
$parts = array();
// Try to extract street number
if (preg_match('/^(\d+)\s+/', $address, $matches)) {
$parts['street_number'] = $matches[1];
$address = trim(substr($address, strlen($matches[0])));
}
// Try to extract street suffix from common patterns
$suffixes = array('St', 'Street', 'Ave', 'Avenue', 'Blvd', 'Boulevard', 'Dr', 'Drive', 'Ln', 'Lane', 'Rd', 'Road', 'Way', 'Ct', 'Court', 'Cir', 'Circle', 'Pl', 'Place');
foreach ($suffixes as $suffix) {
if (preg_match('/^(.+?)\s+(' . preg_quote($suffix, '/') . ')\b/i', $address, $matches)) {
$parts['street_name'] = $matches[1];
$parts['street_suffix'] = $matches[2];
break;
}
}
return $parts;
}
/**
* Handle post trash
*/
public function on_trash_post($post_id) {
if (get_post_type($post_id) !== 'manual_property') {
return;
}
global $wpdb;
$table = $this->db->manual_properties_table();
// Update status to indicate trashed (optional: could also delete)
$wpdb->update(
$table,
array('standard_status' => 'Withdrawn'),
array('wp_post_id' => $post_id)
);
}
/**
* Handle post delete
*/
public function on_delete_post($post_id) {
if (get_post_type($post_id) !== 'manual_property') {
return;
}
global $wpdb;
$table = $this->db->manual_properties_table();
$wpdb->delete($table, array('wp_post_id' => $post_id));
}
/**
* Handle post restore from trash
*/
public function on_untrash_post($post_id) {
if (get_post_type($post_id) !== 'manual_property') {
return;
}
// Re-sync to database
$this->sync_to_database($post_id);
}
/**
* Add admin columns
*/
public function add_admin_columns($columns) {
$new_columns = array();
foreach ($columns as $key => $value) {
$new_columns[$key] = $value;
if ($key === 'title') {
$new_columns['mp_status'] = 'Status';
$new_columns['mp_price'] = 'Price';
$new_columns['mp_city'] = 'City';
$new_columns['mp_homeproz'] = 'HomeProz';
$new_columns['mp_featured'] = 'Featured';
$new_columns['mp_view'] = 'View';
}
}
// Remove date column, move to end
if (isset($new_columns['date'])) {
unset($new_columns['date']);
$new_columns['date'] = 'Date';
}
return $new_columns;
}
/**
* Render admin column values
*/
public function render_admin_columns($column, $post_id) {
switch ($column) {
case 'mp_status':
$status = get_field('standard_status', $post_id);
$status_colors = array(
'Active' => '#28a745',
'Pending' => '#ffc107',
'Sold' => '#6c757d',
'Cancelled' => '#dc3545',
'Expired' => '#6c757d',
'Withdrawn' => '#6c757d',
);
$color = $status_colors[$status] ?? '#6c757d';
echo '<span style="background:' . esc_attr($color) . ';color:#fff;padding:2px 8px;border-radius:3px;font-size:12px;">' . esc_html($status) . '</span>';
break;
case 'mp_price':
$price = get_field('list_price', $post_id);
echo $price ? '$' . number_format($price) : '-';
break;
case 'mp_city':
echo esc_html(get_field('city', $post_id) ?: '-');
break;
case 'mp_homeproz':
echo get_field('is_homeproz', $post_id) ? '<span style="color:green;">Yes</span>' : 'No';
break;
case 'mp_featured':
echo get_field('is_featured', $post_id) ? '<span style="color:green;">Yes</span>' : 'No';
break;
case 'mp_view':
$listing_key = 'MANUAL-' . $post_id;
$url = add_query_arg('listing', $listing_key, home_url('/properties/'));
echo '<a href="' . esc_url($url) . '" target="_blank">View</a>';
break;
}
}
/**
* Make columns sortable
*/
public function sortable_columns($columns) {
$columns['mp_status'] = 'mp_status';
$columns['mp_price'] = 'mp_price';
$columns['mp_city'] = 'mp_city';
return $columns;
}
/**
* Add row actions
*/
public function add_row_actions($actions, $post) {
if ($post->post_type !== 'manual_property') {
return $actions;
}
// Add view link
$listing_key = 'MANUAL-' . $post->ID;
$url = add_query_arg('listing', $listing_key, home_url('/properties/'));
$actions['view_property'] = '<a href="' . esc_url($url) . '" target="_blank">View Property</a>';
return $actions;
}
/**
* Enqueue admin scripts
*/
public function enqueue_admin_scripts($hook) {
global $post_type;
if ($post_type !== 'manual_property') {
return;
}
wp_enqueue_script(
'mls-manual-property-admin',
MLS_PLUGIN_URL . 'admin/js/manual-property.js',
array('jquery'),
MLS_PLUGIN_VERSION,
true
);
wp_localize_script('mls-manual-property-admin', 'mlsManualProperty', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mls_clone_listing'),
));
wp_enqueue_style(
'mls-manual-property-admin',
MLS_PLUGIN_URL . 'admin/css/manual-property.css',
array(),
MLS_PLUGIN_VERSION
);
}
/**
* Render clone button on add new page
*/
public function render_clone_button($post) {
if ($post->post_type !== 'manual_property') {
return;
}
// Only show on new posts
if ($post->post_status !== 'auto-draft') {
return;
}
?>
<div id="mls-clone-section" style="background:#f0f0f1;border:1px solid #c3c4c7;padding:15px;margin-bottom:20px;border-radius:4px;">
<h3 style="margin-top:0;">Clone from MLS Listing</h3>
<p>Enter an MLS ID to copy data from an existing MLS listing:</p>
<input type="text" id="mls-clone-search" placeholder="Enter MLS ID (e.g., 6693299)" style="width:200px;">
<button type="button" id="mls-clone-btn" class="button button-secondary">Clone Listing</button>
<span id="mls-clone-status" style="margin-left:10px;"></span>
</div>
<?php
}
/**
* AJAX: Search for MLS listing to clone
*/
public function ajax_search_for_clone() {
check_ajax_referer('mls_clone_listing', 'nonce');
if (!current_user_can('edit_posts')) {
wp_send_json_error('Unauthorized');
}
$mls_id = sanitize_text_field($_POST['mls_id'] ?? '');
if (empty($mls_id)) {
wp_send_json_error('MLS ID required');
}
$property = mls_get_property($mls_id);
if (!$property) {
wp_send_json_error('Listing not found');
}
wp_send_json_success(array(
'listing_key' => $property->listing_key,
'listing_id' => $property->listing_id,
'address' => trim($property->street_number . ' ' . $property->street_name . ' ' . $property->street_suffix),
'city' => $property->city,
'price' => $property->list_price,
));
}
/**
* AJAX: Clone MLS listing
*/
public function ajax_clone_listing() {
check_ajax_referer('mls_clone_listing', 'nonce');
if (!current_user_can('edit_posts')) {
wp_send_json_error('Unauthorized');
}
$listing_key = sanitize_text_field($_POST['listing_key'] ?? '');
$post_id = intval($_POST['post_id'] ?? 0);
if (empty($listing_key) || !$post_id) {
wp_send_json_error('Missing parameters');
}
$property = mls_get_property($listing_key);
if (!$property) {
wp_send_json_error('Listing not found');
}
// Clone field values
$this->clone_property_fields($post_id, $property);
// Clone images
$images_imported = $this->clone_property_images($post_id, $listing_key);
wp_send_json_success(array(
'message' => 'Listing cloned successfully',
'images_imported' => $images_imported,
));
}
/**
* Clone property fields from MLS listing
*/
private function clone_property_fields($post_id, $property) {
// Update post title and change status from auto-draft to publish
$address = trim($property->street_number . ' ' . $property->street_name . ' ' . $property->street_suffix);
$title = $address . ', ' . $property->city . ', ' . $property->state_or_province . ' ' . $property->postal_code;
wp_update_post(array(
'ID' => $post_id,
'post_title' => $title,
'post_status' => 'publish',
));
// Map MLS fields to ACF fields
$field_map = array(
'listing_id' => $property->listing_id,
'standard_status' => $property->standard_status,
'list_price' => $property->list_price,
'close_price' => $property->close_price,
'property_type' => $property->property_type,
'property_sub_type' => $property->property_sub_type,
'is_homeproz' => $property->is_homeproz ? 1 : 0,
'full_address' => $address . ', ' . $property->city . ', ' . $property->state_or_province . ' ' . $property->postal_code,
'unit_number' => $property->unit_number,
'city' => $property->city,
'state_or_province' => $property->state_or_province,
'postal_code' => $property->postal_code,
'county' => $property->county,
'latitude' => $property->latitude,
'longitude' => $property->longitude,
'bedrooms_total' => $property->bedrooms_total,
'bathrooms_total' => $property->bathrooms_total,
'bathrooms_full' => $property->bathrooms_full,
'bathrooms_half' => $property->bathrooms_half,
'living_area' => $property->living_area,
'lot_size_area' => $property->lot_size_area,
'year_built' => $property->year_built,
'garage_spaces' => $property->garage_spaces,
'public_remarks' => $property->public_remarks,
'directions' => $property->directions,
'list_date' => $property->listing_contract_date,
'close_date' => $property->close_date,
);
foreach ($field_map as $field_name => $value) {
if ($value !== null && $value !== '') {
update_field($field_name, $value, $post_id);
}
}
// Match and set listing agent by MLS ID
if (!empty($property->list_agent_mls_id)) {
$agent_post_id = $this->find_agent_by_mls_id($property->list_agent_mls_id);
if ($agent_post_id) {
update_field('agent', $agent_post_id, $post_id);
}
}
// Match and set co-listing agent by MLS ID
if (!empty($property->co_list_agent_mls_id)) {
$co_agent_post_id = $this->find_agent_by_mls_id($property->co_list_agent_mls_id);
if ($co_agent_post_id) {
update_field('co_list_agent', $co_agent_post_id, $post_id);
}
}
// Mark the geocoded address
update_post_meta($post_id, '_geocoded_address', $field_map['full_address']);
}
/**
* Clone property images from MLS listing
*/
private function clone_property_images($post_id, $listing_key) {
// Get all images for the listing (fetch up to 20 on-demand)
$media = mls_get_property_images($listing_key, 20);
if (empty($media)) {
return 0;
}
$attachment_ids = array();
$imported = 0;
foreach ($media as $item) {
// Try local_url first, then use the image endpoint URL which fetches on-demand
$image_url = null;
if (!empty($item->local_url)) {
$image_url = $item->local_url;
} elseif (function_exists('mls_get_image_url')) {
// Use the image endpoint which will fetch on-demand and return the image
$image_url = mls_get_image_url($listing_key, $item->media_order, 'full');
}
if (empty($image_url)) {
continue;
}
// Download image to media library
$attachment_id = $this->sideload_image($image_url, $post_id);
if ($attachment_id) {
$attachment_ids[] = $attachment_id;
$imported++;
}
// Limit to 20 images to avoid timeout
if ($imported >= 20) {
break;
}
}
// Update gallery field
if (!empty($attachment_ids)) {
update_field('gallery', $attachment_ids, $post_id);
}
return $imported;
}
/**
* Find an Agent CPT post by MLS ID
*
* @param string $mls_id The agent's MLS ID
* @return int|null Agent post ID or null if not found
*/
private function find_agent_by_mls_id($mls_id) {
if (empty($mls_id)) {
return null;
}
$agent_query = new WP_Query(array(
'post_type' => 'agent',
'posts_per_page' => 1,
'post_status' => 'publish',
'meta_query' => array(
array(
'key' => 'agent_mls_id',
'value' => $mls_id,
'compare' => '=',
),
),
));
$agent_id = null;
if ($agent_query->have_posts()) {
$agent_id = $agent_query->posts[0]->ID;
}
wp_reset_postdata();
return $agent_id;
}
/**
* Sideload image to media library
*/
private function sideload_image($url, $post_id) {
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
// Download file
$tmp = download_url($url);
if (is_wp_error($tmp)) {
return 0;
}
// Get filename from URL
$filename = basename(parse_url($url, PHP_URL_PATH));
$file_array = array(
'name' => $filename,
'tmp_name' => $tmp,
);
// Sideload to media library
$attachment_id = media_handle_sideload($file_array, $post_id);
if (is_wp_error($attachment_id)) {
@unlink($tmp);
return 0;
}
return $attachment_id;
}
/**
* Get all manual property listing IDs that should override MLS
*
* @return array Array of MLS IDs (listing_id) that have manual overrides
*/
public static function get_override_listing_ids() {
global $wpdb;
$db = new MLS_DB();
$table = $db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
return array();
}
$ids = $wpdb->get_col(
"SELECT listing_id FROM {$table}
WHERE listing_id IS NOT NULL
AND listing_id != ''
AND standard_status NOT IN ('Withdrawn')"
);
return array_filter($ids);
}
/**
* Get images for a manual property
*
* @param string $listing_key Manual property listing key (MANUAL-xxx)
* @return array Array of image objects compatible with MLS media format
*/
public static function get_manual_property_images($listing_key) {
// Extract post ID from listing key
if (strpos($listing_key, 'MANUAL-') !== 0) {
return array();
}
$post_id = (int) str_replace('MANUAL-', '', $listing_key);
if (!$post_id) {
return array();
}
$gallery = get_field('gallery', $post_id);
if (empty($gallery) || !is_array($gallery)) {
return array();
}
$images = array();
$order = 0;
foreach ($gallery as $image) {
$order++;
$images[] = (object) array(
'id' => $image['ID'],
'listing_key' => $listing_key,
'media_key' => 'manual-' . $image['ID'],
'media_type' => 'Photo',
'media_order' => $order,
'media_url' => $image['url'],
'local_path' => get_attached_file($image['ID']),
'local_url' => $image['url'],
'file_size' => filesize(get_attached_file($image['ID'])) ?: null,
'mime_type' => $image['mime_type'],
'image_width' => $image['width'],
'image_height' => $image['height'],
'downloaded_at' => get_the_date('Y-m-d H:i:s', $image['ID']),
'download_status' => 'complete',
);
}
return $images;
}
/**
* Get a manual property by listing key or listing ID
*
* @param string $identifier Listing key (MANUAL-xxx) or listing_id
* @return object|null
*/
public static function get_manual_property($identifier) {
global $wpdb;
$db = new MLS_DB();
$table = $db->manual_properties_table();
// Check if table exists
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
DB_NAME,
$table
));
if (!$table_exists) {
return null;
}
// Try by listing_key first
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_key = %s",
$identifier
));
if (!$property) {
// Try by listing_id
$property = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE listing_id = %s",
$identifier
));
}
return $property;
}
}