Add MLS by HansonXyz plugin for MLS Grid API integration
Features: - Full sync of NorthStar MLS properties via MLS Grid API v2 - Incremental sync using ModificationTimestamp - Local media download and storage - Rate limit compliance (2 req/sec, 7200/hr, 40000/day) - Sync state tracking with resume capability - WP-CLI commands: test, sync, status, stats, cache - Admin settings page with manual sync triggers - Public API functions: mls_get_properties, mls_get_property, etc. Database tables: - mls_properties: Listing data with full field mapping - mls_media: Downloaded images - mls_sync_state: Sync progress tracking - mls_rate_limits: API usage tracking - mls_sync_log: Debug logging Documentation: - docs/CLAUDE.md: AI development guide - docs/API.md: MLS Grid API reference - docs/USAGE.md: User documentation Tested: Connection, auth, sync 10 records, media download verified 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
<?php
|
||||
/**
|
||||
* MLS Media Handler
|
||||
*
|
||||
* Handles downloading and managing media files from MLS listings
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class MLS_Media_Handler {
|
||||
|
||||
/**
|
||||
* Upload subdirectory for MLS media
|
||||
*/
|
||||
const UPLOAD_SUBDIR = 'mls-listings';
|
||||
|
||||
/**
|
||||
* Database instance
|
||||
*/
|
||||
private $db;
|
||||
|
||||
/**
|
||||
* Logger instance
|
||||
*/
|
||||
private $logger;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(MLS_DB $db, MLS_Logger $logger) {
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base upload directory for MLS media
|
||||
*
|
||||
* @return string Absolute path
|
||||
*/
|
||||
public function get_upload_dir() {
|
||||
$upload_dir = wp_upload_dir();
|
||||
return $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base upload URL for MLS media
|
||||
*
|
||||
* @return string URL
|
||||
*/
|
||||
public function get_upload_url() {
|
||||
$upload_dir = wp_upload_dir();
|
||||
return $upload_dir['baseurl'] . '/' . self::UPLOAD_SUBDIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage directory for a specific listing
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
* @return string Absolute path
|
||||
*/
|
||||
public function get_listing_dir($listing_key) {
|
||||
// Use first 2 characters as subdirectory to prevent too many files in one folder
|
||||
$prefix = substr($listing_key, 0, 2);
|
||||
return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync media for a property
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
* @param array $media_array Media array from API
|
||||
* @param bool $force Force re-download all media
|
||||
*/
|
||||
public function sync_property_media($listing_key, $media_array, $force = false) {
|
||||
global $wpdb;
|
||||
|
||||
if (empty($media_array)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$received_keys = array();
|
||||
|
||||
foreach ($media_array as $media) {
|
||||
$media_key = $media['MediaKey'] ?? null;
|
||||
if (!$media_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$received_keys[] = $media_key;
|
||||
|
||||
// Check if media record exists
|
||||
$existing = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->media_table()}
|
||||
WHERE listing_key = %s AND media_key = %s",
|
||||
$listing_key,
|
||||
$media_key
|
||||
));
|
||||
|
||||
$data = array(
|
||||
'listing_key' => $listing_key,
|
||||
'media_key' => $media_key,
|
||||
'media_type' => $media['MediaType'] ?? 'Photo',
|
||||
'media_order' => $media['Order'] ?? 0,
|
||||
'media_url' => $media['MediaURL'] ?? null,
|
||||
'image_width' => $media['ImageWidth'] ?? null,
|
||||
'image_height' => $media['ImageHeight'] ?? null,
|
||||
'media_modification_timestamp' => isset($media['MediaModificationTimestamp'])
|
||||
? date('Y-m-d H:i:s', strtotime($media['MediaModificationTimestamp']))
|
||||
: null,
|
||||
'updated_at' => current_time('mysql'),
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing record
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
$data,
|
||||
array('id' => $existing->id)
|
||||
);
|
||||
|
||||
// Check if we need to re-download
|
||||
if ($force || $this->needs_download($existing, $media)) {
|
||||
$this->download_media($existing->id);
|
||||
}
|
||||
} else {
|
||||
// Insert new record
|
||||
$data['created_at'] = current_time('mysql');
|
||||
$wpdb->insert($this->db->media_table(), $data);
|
||||
|
||||
// Queue download
|
||||
$this->download_media($wpdb->insert_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete media that no longer exists
|
||||
if (!empty($received_keys)) {
|
||||
$placeholders = implode(',', array_fill(0, count($received_keys), '%s'));
|
||||
$values = array_merge(array($listing_key), $received_keys);
|
||||
|
||||
$orphaned = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id, local_path FROM {$this->db->media_table()}
|
||||
WHERE listing_key = %s AND media_key NOT IN ({$placeholders})",
|
||||
$values
|
||||
));
|
||||
|
||||
foreach ($orphaned as $record) {
|
||||
// Delete file if exists
|
||||
if ($record->local_path) {
|
||||
$file_path = $this->get_upload_dir() . '/' . $record->local_path;
|
||||
if (file_exists($file_path)) {
|
||||
unlink($file_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete record
|
||||
$wpdb->delete($this->db->media_table(), array('id' => $record->id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if media needs to be downloaded
|
||||
*
|
||||
* @param object $existing Existing media record
|
||||
* @param array $new_data New media data from API
|
||||
* @return bool
|
||||
*/
|
||||
private function needs_download($existing, $new_data) {
|
||||
// No local file
|
||||
if (empty($existing->local_path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// File doesn't exist
|
||||
$file_path = $this->get_upload_dir() . '/' . $existing->local_path;
|
||||
if (!file_exists($file_path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Media URL changed
|
||||
if ($existing->media_url !== ($new_data['MediaURL'] ?? null)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a media file
|
||||
*
|
||||
* @param int $media_id Media record ID
|
||||
* @return bool Success
|
||||
*/
|
||||
public function download_media($media_id) {
|
||||
global $wpdb;
|
||||
|
||||
$media = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->media_table()} WHERE id = %d",
|
||||
$media_id
|
||||
));
|
||||
|
||||
if (!$media || empty($media->media_url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment attempt counter
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array('download_attempts' => $media->download_attempts + 1),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
|
||||
// Download file
|
||||
$response = wp_remote_get($media->media_url, array(
|
||||
'timeout' => 60,
|
||||
'stream' => false,
|
||||
));
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
$this->logger->warning('Media download failed', array(
|
||||
'media_id' => $media_id,
|
||||
'error' => $response->get_error_message(),
|
||||
));
|
||||
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array('download_error' => $response->get_error_message()),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code($response);
|
||||
if ($status_code !== 200) {
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array('download_error' => "HTTP {$status_code}"),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body($response);
|
||||
if (empty($body)) {
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array('download_error' => 'Empty response'),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine file extension from content type or URL
|
||||
$content_type = wp_remote_retrieve_header($response, 'content-type');
|
||||
$extension = $this->get_extension_from_content_type($content_type, $media->media_url);
|
||||
|
||||
// Create directory
|
||||
$listing_dir = $this->get_listing_dir($media->listing_key);
|
||||
if (!file_exists($listing_dir)) {
|
||||
wp_mkdir_p($listing_dir);
|
||||
}
|
||||
|
||||
// Save file
|
||||
$filename = $media->media_order . '.' . $extension;
|
||||
$file_path = $listing_dir . '/' . $filename;
|
||||
|
||||
if (file_put_contents($file_path, $body) === false) {
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array('download_error' => 'Failed to write file'),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate relative path
|
||||
$prefix = substr($media->listing_key, 0, 2);
|
||||
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
||||
$local_url = $this->get_upload_url() . '/' . $relative_path;
|
||||
|
||||
// Update record
|
||||
$wpdb->update(
|
||||
$this->db->media_table(),
|
||||
array(
|
||||
'local_path' => $relative_path,
|
||||
'local_url' => $local_url,
|
||||
'file_size' => strlen($body),
|
||||
'mime_type' => $content_type,
|
||||
'downloaded_at' => current_time('mysql'),
|
||||
'download_error' => null,
|
||||
),
|
||||
array('id' => $media_id)
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from content type
|
||||
*
|
||||
* @param string $content_type Content type header
|
||||
* @param string $url Original URL as fallback
|
||||
* @return string File extension
|
||||
*/
|
||||
private function get_extension_from_content_type($content_type, $url) {
|
||||
// Extract main type from content-type header
|
||||
$content_type = strtolower(explode(';', $content_type)[0]);
|
||||
|
||||
$map = array(
|
||||
'image/jpeg' => 'jpg',
|
||||
'image/jpg' => 'jpg',
|
||||
'image/png' => 'png',
|
||||
'image/gif' => 'gif',
|
||||
'image/webp' => 'webp',
|
||||
);
|
||||
|
||||
if (isset($map[$content_type])) {
|
||||
return $map[$content_type];
|
||||
}
|
||||
|
||||
// Fallback to URL extension
|
||||
$path = parse_url($url, PHP_URL_PATH);
|
||||
$ext = pathinfo($path, PATHINFO_EXTENSION);
|
||||
|
||||
return $ext ?: 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all media for a property
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
*/
|
||||
public function delete_property_media($listing_key) {
|
||||
global $wpdb;
|
||||
|
||||
// Delete files
|
||||
$listing_dir = $this->get_listing_dir($listing_key);
|
||||
if (file_exists($listing_dir)) {
|
||||
$this->recursive_delete($listing_dir);
|
||||
}
|
||||
|
||||
// Delete records
|
||||
$wpdb->delete(
|
||||
$this->db->media_table(),
|
||||
array('listing_key' => $listing_key)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory
|
||||
*
|
||||
* @param string $dir Directory path
|
||||
*/
|
||||
private function recursive_delete($dir) {
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = array_diff(scandir($dir), array('.', '..'));
|
||||
|
||||
foreach ($files as $file) {
|
||||
$path = $dir . '/' . $file;
|
||||
if (is_dir($path)) {
|
||||
$this->recursive_delete($path);
|
||||
} else {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media for a listing
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
* @return array Media records
|
||||
*/
|
||||
public function get_listing_media($listing_key) {
|
||||
global $wpdb;
|
||||
|
||||
return $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->db->media_table()}
|
||||
WHERE listing_key = %s
|
||||
ORDER BY media_order ASC",
|
||||
$listing_key
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get primary image URL for a listing
|
||||
*
|
||||
* @param string $listing_key Listing key
|
||||
* @return string|null Image URL
|
||||
*/
|
||||
public function get_primary_image($listing_key) {
|
||||
global $wpdb;
|
||||
|
||||
$media = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT local_url, media_url FROM {$this->db->media_table()}
|
||||
WHERE listing_key = %s AND local_path IS NOT NULL
|
||||
ORDER BY media_order ASC
|
||||
LIMIT 1",
|
||||
$listing_key
|
||||
));
|
||||
|
||||
if ($media && $media->local_url) {
|
||||
return $media->local_url;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download pending media (for batch processing)
|
||||
*
|
||||
* @param int $limit Max media to download
|
||||
* @return array Stats
|
||||
*/
|
||||
public function download_pending($limit = 100) {
|
||||
global $wpdb;
|
||||
|
||||
$pending = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id FROM {$this->db->media_table()}
|
||||
WHERE local_path IS NULL AND media_url IS NOT NULL
|
||||
AND download_attempts < 3
|
||||
LIMIT %d",
|
||||
$limit
|
||||
));
|
||||
|
||||
$stats = array(
|
||||
'total' => count($pending),
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
);
|
||||
|
||||
foreach ($pending as $media) {
|
||||
if ($this->download_media($media->id)) {
|
||||
$stats['success']++;
|
||||
} else {
|
||||
$stats['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned media (files without database records)
|
||||
*
|
||||
* @return int Number of files deleted
|
||||
*/
|
||||
public function cleanup_orphaned_files() {
|
||||
$deleted = 0;
|
||||
$base_dir = $this->get_upload_dir();
|
||||
|
||||
if (!is_dir($base_dir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Iterate through prefix directories
|
||||
foreach (scandir($base_dir) as $prefix) {
|
||||
if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prefix_dir = $base_dir . '/' . $prefix;
|
||||
|
||||
// Iterate through listing directories
|
||||
foreach (scandir($prefix_dir) as $listing_key) {
|
||||
if ($listing_key === '.' || $listing_key === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$listing_dir = $prefix_dir . '/' . $listing_key;
|
||||
if (!is_dir($listing_dir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if listing exists in database
|
||||
global $wpdb;
|
||||
$exists = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE listing_key = %s",
|
||||
$listing_key
|
||||
));
|
||||
|
||||
if (!$exists) {
|
||||
$this->recursive_delete($listing_dir);
|
||||
$deleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user