<?php /* Plugin Name: Buildium Units Sync (Allowlist + Relationship Repair + Rollups + Daily) Description: Sync Buildium MarketRent + IsUnitListed into Pods units (CPT: units). Only sync units that exist in WP (allowlist). Repairs unit->property + unit->floorplan relationships. Repairs floorplan->property. Recalculates has_available_units + price range rollups. Daily WP-Cron. Manual: /wp-admin/?sync_buildium=1 Version: 4.2 */ if (!defined('ABSPATH')) exit; define('BUILDIUM_SYNC_CRON_HOOK', 'buildium_units_sync_daily_hook'); /** * Pods pick fields on Units (separate, correct relationships) * Units.properties => property post ID(s) * Units.floorplans => floorplan post ID(s) */ define('CH_UNIT_PROP_FIELD', 'properties'); define('CH_UNIT_FLOORPLAN_FIELD', 'floorplans'); /** * Legacy mixed relationship field on Units/Floorplans (optional, for backward compatibility) * Units.relationship can include BOTH property IDs and floorplan IDs. * Floorplans.relationship includes Property ID. */ define('CH_REL_FIELD', 'relationship'); define('CH_UPDATE_LEGACY_REL_FIELD', true); // set false if you want to stop syncing "relationship" /** * ---- CONFIG ---- * Adjust these CPT slugs only if yours differ. */ define('CH_UNITS_CPT', 'units'); define('CH_FLOORPLANS_CPT', 'floorplans'); define('CH_PROPERTIES_CPT', 'properties'); /** * Availability field slugs */ define('CH_AVAIL_FIELD', 'has_available_units'); // on properties + floorplans /** * Price field slugs (rollup string like "$850–$2,000 per month") */ define('CH_PRICE_FIELD', 'price'); // on properties + floorplans /** * Buildium meta keys */ define('CH_PROPERTY_BUILD_META', 'buildium_property_id'); // on properties define('CH_UNIT_BUILD_META', 'buildium_unit_id'); // on units /** * Relationship repair behavior * If true: every sync_buildium run and daily cron will also repair relationships. * If false: relationships only repair when you hit /wp-admin/?repair_buildium_relationships=1 */ define('CH_REPAIR_RELATIONSHIPS_ON_SYNC', true); /** * Floorplan->Property repair behavior (uses floorplan title prefix before "|") */ define('CH_REPAIR_FLOORPLAN_PROPERTY_ON_SYNC', true); /** ========================================================= * Helpers * ========================================================= */ if (!function_exists('ch_str_contains')) { function ch_str_contains($haystack, $needle) { if ($needle === '') return true; return (strpos((string)$haystack, (string)$needle) !== false); } } /** * Normalize Pods relationship field output into a flat array of IDs. */ function ch_normalize_pods_rel_ids($rel) { $ids = array(); if (empty($rel)) return $ids; if (is_numeric($rel)) { $ids[] = (int)$rel; return $ids; } if (is_array($rel)) { foreach ($rel as $item) { if (is_numeric($item)) { $ids[] = (int)$item; } elseif (is_array($item) && isset($item['ID'])) { $ids[] = (int)$item['ID']; } elseif (is_object($item) && isset($item->ID)) { $ids[] = (int)$item->ID; } } } $ids = array_filter(array_unique(array_map('intval', $ids))); return array_values($ids); } /** ========================================================= * ACTIVATE / DEACTIVATE CRON * ========================================================= */ register_activation_hook(__FILE__, function () { if (!wp_next_scheduled(BUILDIUM_SYNC_CRON_HOOK)) { wp_schedule_event(time() + 300, 'daily', BUILDIUM_SYNC_CRON_HOOK); } }); register_deactivation_hook(__FILE__, function () { $ts = wp_next_scheduled(BUILDIUM_SYNC_CRON_HOOK); if ($ts) wp_unschedule_event($ts, BUILDIUM_SYNC_CRON_HOOK); }); /** ========================================================= * MANUAL TRIGGERS * ========================================================= */ add_action('admin_init', function () { // Duplicate report if (isset($_GET['sync_buildium_duplicates'])) { if (!current_user_can('manage_options')) wp_die('Not allowed.'); $trash = isset($_GET['trash']) && $_GET['trash'] == '1'; $debug = isset($_GET['debug']) && $_GET['debug'] == '1'; echo '<h2>=== BUILDIUM DUPLICATE REPORT ===</h2>'; $report = buildium_find_duplicate_units($trash, $debug); echo '<pre>' . esc_html(print_r($report, true)) . '</pre>'; echo '<h2>=== DONE ===</h2>'; exit; } // Relationship repair only (manual) if (isset($_GET['repair_buildium_relationships'])) { if (!current_user_can('manage_options')) wp_die('Not allowed.'); $debug = isset($_GET['debug']) && $_GET['debug'] == '1'; $dry_run = isset($_GET['dry_run']) && $_GET['dry_run'] == '1'; echo '<h2>=== BUILDIUM RELATIONSHIP REPAIR START ===</h2>'; echo '<p>Dry run: <b>' . ($dry_run ? 'YES' : 'NO') . '</b> | Debug: <b>' . ($debug ? 'ON' : 'OFF') . '</b></p>'; $summary = ch_repair_unit_relationships_from_buildium($debug, $dry_run); echo '<h2>=== BUILDIUM RELATIONSHIP REPAIR COMPLETE ===</h2>'; echo '<pre>' . esc_html(print_r($summary, true)) . '</pre>'; exit; } // Floorplan->Property repair only (manual) if (isset($_GET['repair_floorplan_properties'])) { if (!current_user_can('manage_options')) wp_die('Not allowed.'); $debug = isset($_GET['debug']) && $_GET['debug'] == '1'; $dry_run = isset($_GET['dry_run']) && $_GET['dry_run'] == '1'; echo '<h2>=== FLOORPLAN → PROPERTY REPAIR START ===</h2>'; echo '<p>Dry run: <b>' . ($dry_run ? 'YES' : 'NO') . '</b> | Debug: <b>' . ($debug ? 'ON' : 'OFF') . '</b></p>'; $summary = ch_repair_floorplan_property_relationships($debug, $dry_run); echo '<h2>=== FLOORPLAN → PROPERTY REPAIR COMPLETE ===</h2>'; echo '<pre>' . esc_html(print_r($summary, true)) . '</pre>'; exit; } // Manual sync (supports dry_run) if (!isset($_GET['sync_buildium'])) return; if (!current_user_can('manage_options')) wp_die('Not allowed.'); $debug = isset($_GET['debug']) && $_GET['debug'] == '1'; $dry_run = isset($_GET['dry_run']) && $_GET['dry_run'] == '1'; echo '<h2>=== BUILDIUM SYNC START ===</h2>'; echo '<p>Dry run: <b>' . ($dry_run ? 'YES (no writes)' : 'NO (will write)') . '</b> | Debug: <b>' . ($debug ? 'ON' : 'OFF') . '</b></p>'; $summary = buildium_units_sync_run_all($debug, $dry_run); echo '<h2>=== BUILDIUM SYNC COMPLETE ===</h2>'; echo '<pre>' . esc_html(print_r($summary, true)) . '</pre>'; exit; }); /** ========================================================= * DAILY CRON SYNC * ========================================================= */ add_action(BUILDIUM_SYNC_CRON_HOOK, function () { buildium_units_sync_run_all(false, false); }); /** ========================================================= * CORE: SYNC ALL (PAGINATED) + OPTIONAL REL REPAIR + ROLLUPS * ========================================================= */ function buildium_units_sync_run_all($debug = false, $dry_run = false) { global $wpdb; $pods_active = function_exists('pods'); // Require credentials from wp-config.php if (!defined('BUILDIUM_CLIENT_ID') || !defined('BUILDIUM_CLIENT_SECRET')) { if ($debug) { echo '<p><b>Missing Buildium credentials.</b> Define BUILDIUM_CLIENT_ID and BUILDIUM_CLIENT_SECRET in wp-config.php</p>'; } return array( 'ok' => false, 'error' => 'Missing Buildium credentials (BUILDIUM_CLIENT_ID / BUILDIUM_CLIENT_SECRET).', 'pods_active' => $pods_active ); } $client_id = BUILDIUM_CLIENT_ID; $client_secret = BUILDIUM_CLIENT_SECRET; $base_url = 'https://api.buildium.com/v1/rentals/units'; $limit = 50; $offset = 0; $max_pages = 600; $checked_buildium = 0; $matched_wp = 0; $updated_wp = 0; $skipped_not_imported = 0; $errors = 0; // Relationship repair stats $rel_summary = array( 'enabled' => CH_REPAIR_RELATIONSHIPS_ON_SYNC ? 1 : 0, 'unit_rel_checked' => 0, 'unit_rel_updated' => 0, 'no_property_match' => 0, 'no_floorplan_match' => 0, ); // Floorplan->Property repair stats $fp_prop_summary = array( 'enabled' => (CH_REPAIR_FLOORPLAN_PROPERTY_ON_SYNC ? 1 : 0), 'checked' => 0, 'updated' => 0, 'no_match' => 0, ); // Cache: buildium_id => post_id $match_cache = array(); /** * ALLOWLIST: * Only sync Buildium unit IDs that exist in WP already (your imported units). */ $allowed_buildium_ids = array(); $existing_ids = $wpdb->get_col(" SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '" . esc_sql(CH_UNIT_BUILD_META) . "' AND p.post_type = '" . esc_sql(CH_UNITS_CPT) . "' AND pm.meta_value <> '' "); if (!empty($existing_ids)) { foreach ($existing_ids as $bid) { $allowed_buildium_ids[(string)$bid] = true; } } // Build lookups used for relationship repair (only if enabled) $property_lookup = array(); $floorplan_lookup = array(); if (CH_REPAIR_RELATIONSHIPS_ON_SYNC && $pods_active) { $property_lookup = ch_build_property_lookup(); $floorplan_lookup = ch_build_floorplan_title_lookup(); // now case-insensitive } /** * Rollup accumulators: * - Availability: based on IsUnitListed==1 (your current meaning) * - Price range: based on IsUnitListed==1 (listed units, even if otherwise "unavailable") */ $floorplan_has_any = array(); // floorplan_id => true $property_has_any = array(); // property_id => true $fp_min = array(); // floorplan_id => float $fp_max = array(); // floorplan_id => float $pr_min = array(); // property_id => float $pr_max = array(); // property_id => float for ($page = 1; $page <= $max_pages; $page++) { $url = add_query_arg(array('limit' => $limit, 'offset' => $offset), $base_url); $response = wp_remote_get($url, array( 'headers' => array( 'x-buildium-client-id' => $client_id, 'x-buildium-client-secret' => $client_secret, 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'WordPress Buildium Sync' ), 'timeout' => 30 )); if (is_wp_error($response)) { $errors++; if ($debug) echo '<p><b>API ERROR:</b> ' . esc_html($response->get_error_message()) . '</p>'; break; } $status = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status !== 200) { $errors++; if ($debug) { echo '<p><b>HTTP ' . intval($status) . '</b></p>'; echo '<pre>' . esc_html(substr($body, 0, 1200)) . '</pre>'; } break; } $data = json_decode($body, true); if ($data === null) { $errors++; if ($debug) echo '<p><b>JSON decode failed</b></p><pre>' . esc_html(substr($body, 0, 1200)) . '</pre>'; break; } // End condition if (is_array($data) && empty($data)) { if ($debug) echo '<pre>Reached end of results (empty array). Stop.</pre>'; break; } // Support BOTH response shapes if (isset($data['Results']) && is_array($data['Results'])) { $units = $data['Results']; } elseif (is_array($data) && isset($data[0]) && is_array($data[0])) { $units = $data; } else { $errors++; if ($debug) echo '<p><b>Unexpected response structure</b></p><pre>' . esc_html(print_r($data, true)) . '</pre>'; break; } if ($debug) echo '<pre>Page ' . intval($page) . ' offset=' . intval($offset) . ' units=' . intval(count($units)) . '</pre>'; if (empty($units)) break; foreach ($units as $unit) { if (!isset($unit['Id'])) continue; $buildium_id = (string)$unit['Id']; // Skip Buildium units that you didn't import into WP (commercial / irrelevant) if (!isset($allowed_buildium_ids[$buildium_id])) { $skipped_not_imported++; continue; } $checked_buildium++; // Buildium fields $new_rent = $unit['MarketRent'] ?? null; $new_listed = $unit['IsUnitListed'] ?? null; // Normalize $rent_norm = is_null($new_rent) ? '' : (string)$new_rent; if (is_bool($new_listed)) $listed_norm = $new_listed ? '1' : '0'; elseif (is_numeric($new_listed)) $listed_norm = (string)intval($new_listed); elseif (is_string($new_listed) && ($new_listed === 'true' || $new_listed === 'false')) $listed_norm = ($new_listed === 'true') ? '1' : '0'; else $listed_norm = (string)$new_listed; if ($debug) { echo '<hr><b>Processing Buildium Unit ID:</b> ' . esc_html($buildium_id) . '<br>'; } // Find matching WP post_id (cache) if (isset($match_cache[$buildium_id])) { $post_id = $match_cache[$buildium_id]; } else { $query = new WP_Query(array( 'post_type' => CH_UNITS_CPT, 'posts_per_page' => 1, 'fields' => 'ids', 'post_status' => 'any', 'meta_query' => array( array( 'key' => CH_UNIT_BUILD_META, 'value' => $buildium_id, 'compare' => '=' ) ), 'no_found_rows' => true, )); if (empty($query->posts)) { if ($debug) echo '❌ No WP unit found for buildium_unit_id = ' . esc_html($buildium_id) . '<br>'; continue; } $post_id = intval($query->posts[0]); $match_cache[$buildium_id] = $post_id; } $matched_wp++; /** * ---- RELATIONSHIP REPAIR (optional) ---- * Writes to: * - Units.properties * - Units.floorplans * - (optional) Units.relationship legacy field */ if (CH_REPAIR_RELATIONSHIPS_ON_SYNC && $pods_active) { $rel_summary['unit_rel_checked']++; $prop_buildium_id = isset($unit['PropertyId']) ? (string)$unit['PropertyId'] : ''; $bedrooms_raw = $unit['UnitBedrooms'] ?? null; $bathrooms_raw = $unit['UnitBathrooms'] ?? null; $rr = ch_repair_one_unit_relationships( $post_id, $prop_buildium_id, $bedrooms_raw, $bathrooms_raw, $property_lookup, $floorplan_lookup, $debug, ($dry_run ? false : true) // respect dry_run ); if (!empty($rr['updated'])) $rel_summary['unit_rel_updated']++; if (!empty($rr['no_property_match'])) $rel_summary['no_property_match']++; if (!empty($rr['no_floorplan_match'])) $rel_summary['no_floorplan_match']++; } // Always compute relationships for rollups (even if values didn't change) $rels = ch_get_unit_related_ids($post_id, $pods_active); $unit_is_listed = ((string)$listed_norm === '1'); // Availability rollups: treat "listed" as available flag (as per your existing behavior) if ($unit_is_listed) { foreach ($rels['floorplans'] as $fid) $floorplan_has_any[(int)$fid] = true; foreach ($rels['properties'] as $pid) $property_has_any[(int)$pid] = true; } // Price range rollups (listed units) $rent_val = is_numeric($rent_norm) ? floatval($rent_norm) : null; if ($unit_is_listed && $rent_val !== null) { foreach ($rels['floorplans'] as $fid) { $fid = (int)$fid; if (!isset($fp_min[$fid]) || $rent_val < $fp_min[$fid]) $fp_min[$fid] = $rent_val; if (!isset($fp_max[$fid]) || $rent_val > $fp_max[$fid]) $fp_max[$fid] = $rent_val; } foreach ($rels['properties'] as $pid) { $pid = (int)$pid; if (!isset($pr_min[$pid]) || $rent_val < $pr_min[$pid]) $pr_min[$pid] = $rent_val; if (!isset($pr_max[$pid]) || $rent_val > $pr_max[$pid]) $pr_max[$pid] = $rent_val; } } // Current values $old_rent = (string)get_post_meta($post_id, 'marketrent', true); $old_listed = (string)get_post_meta($post_id, 'isunitlisted', true); // Decide if changes $changed = false; if ((string)$rent_norm !== $old_rent) $changed = true; if ((string)$listed_norm !== $old_listed) $changed = true; if ($debug) { echo '✅ Matched WP Post ID: ' . intval($post_id) . '<br>'; echo 'Current marketrent: '; var_dump($old_rent); echo 'Current isunitlisted: '; var_dump($old_listed); echo 'API MarketRent: '; var_dump($new_rent); echo 'API IsUnitListed: '; var_dump($new_listed); echo 'Normalized rent/listed: '; var_dump($rent_norm); var_dump($listed_norm); echo 'Changed?: ' . ($changed ? 'YES' : 'NO') . '<br>'; } if (!$changed) { if ($debug) echo '— No change<br>'; continue; } if ($dry_run) { if ($debug) echo 'DRY RUN: would update marketrent/isunitlisted<br>'; continue; } // 1) Update meta update_post_meta($post_id, 'marketrent', $rent_norm); update_post_meta($post_id, 'isunitlisted', $listed_norm); // 2) Pods save fallback (recommended) if ($pods_active) { $pod = pods(CH_UNITS_CPT, $post_id); if ($pod && method_exists($pod, 'save')) { $pod->save(array( 'marketrent' => $rent_norm, 'isunitlisted' => $listed_norm, )); } } clean_post_cache($post_id); $updated_wp++; if ($debug) { $verify_rent = get_post_meta($post_id, 'marketrent', true); $verify_listed = get_post_meta($post_id, 'isunitlisted', true); echo 'After update marketrent: '; var_dump($verify_rent); echo 'After update isunitlisted: '; var_dump($verify_listed); } } $offset += $limit; } /** * ---- ROLLUPS: set has_available_units on floorplans + properties ---- */ $recalc_floorplans = ch_update_has_available_units(CH_FLOORPLANS_CPT, $floorplan_has_any, $pods_active, $debug, $dry_run); $recalc_properties = ch_update_has_available_units(CH_PROPERTIES_CPT, $property_has_any, $pods_active, $debug, $dry_run); /** * ---- PRICE RANGE ROLLUPS: set price on floorplans + properties ---- */ $recalc_floorplan_price = ch_update_price_range_rollup(CH_FLOORPLANS_CPT, $fp_min, $fp_max, $pods_active, $debug, $dry_run); $recalc_property_price = ch_update_price_range_rollup(CH_PROPERTIES_CPT, $pr_min, $pr_max, $pods_active, $debug, $dry_run); /** * ---- FLOORPLAN -> PROPERTY REPAIR (optional) ---- */ $floorplan_property_repairs = array('ok'=>true,'skipped'=>1); if (CH_REPAIR_FLOORPLAN_PROPERTY_ON_SYNC && $pods_active) { $floorplan_property_repairs = ch_repair_floorplan_property_relationships($debug, $dry_run); } return array( 'ok' => ($errors === 0), 'dry_run' => $dry_run ? 1 : 0, 'pods_active' => $pods_active, 'checked_buildium_units_allowlisted' => $checked_buildium, 'skipped_not_imported' => $skipped_not_imported, 'matched_wp_units' => $matched_wp, 'updated_wp_units' => $updated_wp, 'errors' => $errors, 'relationship_repair' => $rel_summary, 'recalc_floorplans' => $recalc_floorplans, 'recalc_properties' => $recalc_properties, 'recalc_floorplan_price' => $recalc_floorplan_price, 'recalc_property_price' => $recalc_property_price, 'floorplan_property_repairs' => $floorplan_property_repairs, ); } /** ========================================================= * LOOKUPS * ========================================================= */ /** * Build lookup map: buildium_property_id => property post ID */ function ch_build_property_lookup() { global $wpdb; $rows = $wpdb->get_results(" SELECT pm.meta_value AS bid, pm.post_id AS pid FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.post_type = '" . esc_sql(CH_PROPERTIES_CPT) . "' AND pm.meta_key = '" . esc_sql(CH_PROPERTY_BUILD_META) . "' AND pm.meta_value <> '' ", ARRAY_A); $map = []; foreach ($rows as $r) { $bid = (string)$r['bid']; $map[$bid] = (int)$r['pid']; } return $map; } /** * Build lookup map: floorplan title (lowercase) => floorplan post ID */ function ch_build_floorplan_title_lookup() { $q = new WP_Query([ 'post_type' => CH_FLOORPLANS_CPT, 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'any', 'no_found_rows' => true, ]); $map = []; foreach ($q->posts as $fid) { $title = get_the_title($fid); if ($title) $map[strtolower(trim($title))] = (int)$fid; } return $map; } /** * Use FULL property title as floorplan prefix: * "Aurelia Flats" => "Aurelia Flats" */ function ch_property_prefix_from_title($property_post_id) { $title = trim(get_the_title($property_post_id)); if ($title === '') return ''; // ✅ Minimal hard mapping for known exceptions $map = [ 'Windsor Tower' => 'Windsor', 'West Market Townhomes' => 'West Market', ]; if (isset($map[$title])) return $map[$title]; // Default: first token $parts = preg_split('/[\s\-|]+/', $title); return isset($parts[0]) ? trim($parts[0]) : $title; } /** * Normalize Buildium bedroom/bathroom fields to your naming. * Supports enums like OneBed/TwoBed/Studio and OneBath/TwoBath/OneHalfBath, etc. */ function ch_floorplan_label_from_unit($bedrooms_raw, $bathrooms_raw) { // Bedrooms -> "Studio" or "1 Bed" etc. $bed_label = ''; if (is_string($bedrooms_raw)) { $br = strtolower(trim($bedrooms_raw)); if ($br === 'studio') $bed_label = 'Studio'; elseif ($br === 'onebed') $bed_label = '1 Bed'; elseif ($br === 'twobed') $bed_label = '2 Bed'; elseif ($br === 'threebed') $bed_label = '3 Bed'; elseif ($br === 'fourbed') $bed_label = '4 Bed'; // fallback contains logic elseif (ch_str_contains($br, 'studio')) $bed_label = 'Studio'; elseif (ch_str_contains($br, 'one')) $bed_label = '1 Bed'; elseif (ch_str_contains($br, 'two')) $bed_label = '2 Bed'; elseif (ch_str_contains($br, 'three')) $bed_label = '3 Bed'; elseif (ch_str_contains($br, 'four')) $bed_label = '4 Bed'; } if ($bed_label === '' && is_numeric($bedrooms_raw)) { $bed_label = intval($bedrooms_raw) . ' Bed'; } // Bathrooms -> "1 Bath" / "2 Bath" (mainly for special titles like "2 Bed | 2 Bath") $bath_label = ''; if (is_string($bathrooms_raw)) { $ba = strtolower(trim($bathrooms_raw)); if ($ba === 'onebath') $bath_label = '1 Bath'; elseif ($ba === 'twobath') $bath_label = '2 Bath'; elseif ($ba === 'threebath') $bath_label = '3 Bath'; elseif ($ba === 'fourbath') $bath_label = '4 Bath'; elseif ($ba === 'onehalfbath' || $ba === 'oneandahalfbath') $bath_label = '2 Bath'; elseif ($ba === 'twohalfbath' || $ba === 'twoandahalfbath') $bath_label = '3 Bath'; } elseif (is_numeric($bathrooms_raw)) { $val = floatval($bathrooms_raw); if ($val >= 1.5 && $val < 2) $bath_label = '2 Bath'; else $bath_label = intval(round($val)) . ' Bath'; } return [$bed_label, $bath_label]; } /** * Choose floorplan ID using your floorplan title pattern. */ function ch_match_floorplan_id($property_post_id, $bedrooms_raw, $bathrooms_raw, $floorplan_title_map) { $prefix = ch_property_prefix_from_title($property_post_id); if ($prefix === '') return 0; list($bed_label, $bath_label) = ch_floorplan_label_from_unit($bedrooms_raw, $bathrooms_raw); if ($bed_label === '') return 0; $candidates = []; // Special: Canadiana | 2 Bed | 2 Bath if ($bed_label === '2 Bed' && $bath_label === '2 Bath') { $candidates[] = "{$prefix} | 2 Bed | 2 Bath"; } // Standard: Prefix | 1 Bed / 2 Bed / Studio $candidates[] = "{$prefix} | {$bed_label}"; // Bath-specific titles (if present) if ($bath_label !== '') { $candidates[] = "{$prefix} | {$bed_label} | {$bath_label}"; } foreach ($candidates as $t) { $key = strtolower(trim($t)); if (isset($floorplan_title_map[$key])) return (int)$floorplan_title_map[$key]; } return 0; } /** ========================================================= * RELATIONSHIP REPAIR * ========================================================= */ /** * Compute desired property + floorplan IDs for a unit based on Buildium payload. */ function ch_compute_desired_relationships($buildium_property_id, $bedrooms_raw, $bathrooms_raw, $property_lookup, $floorplan_lookup) { $out = [ 'desired_property_id' => 0, 'desired_floorplan_id' => 0, 'no_property_match' => 0, 'no_floorplan_match' => 0, ]; // Desired Property if ($buildium_property_id !== '' && isset($property_lookup[(string)$buildium_property_id])) { $out['desired_property_id'] = (int)$property_lookup[(string)$buildium_property_id]; } else { $out['no_property_match'] = 1; } // Desired Floorplan if ($out['desired_property_id'] > 0) { $out['desired_floorplan_id'] = ch_match_floorplan_id($out['desired_property_id'], $bedrooms_raw, $bathrooms_raw, $floorplan_lookup); if ($out['desired_floorplan_id'] === 0) $out['no_floorplan_match'] = 1; } else { $out['no_floorplan_match'] = 1; } return $out; } /** * Repair one unit relationships: * Writes to: * - Units.properties (Pods pick) * - Units.floorplans (Pods pick) * - (optional) Units.relationship legacy field */ function ch_repair_one_unit_relationships($unit_post_id, $buildium_property_id, $bedrooms_raw, $bathrooms_raw, $property_lookup, $floorplan_lookup, $debug = false, $save = true) { $result = [ 'updated' => 0, 'no_property_match' => 0, 'no_floorplan_match' => 0, ]; if (!function_exists('pods')) return $result; $pod = pods(CH_UNITS_CPT, $unit_post_id); if (!$pod || !method_exists($pod, 'save')) return $result; $calc = ch_compute_desired_relationships($buildium_property_id, $bedrooms_raw, $bathrooms_raw, $property_lookup, $floorplan_lookup); if ($debug && (int)$calc['no_floorplan_match'] === 1) { // show the raw bedroom/bath values and what property it thinks it is echo '<pre style="background:#fff3cd;padding:10px;border:1px solid #ffeeba;">' . "NO FLOORPLAN MATCH\n" . "Unit Post ID: {$unit_post_id}\n" . "Buildium PropertyId: {$buildium_property_id}\n" . "Bedrooms raw: " . print_r($bedrooms_raw, true) . "Bathrooms raw: " . print_r($bathrooms_raw, true) . "Desired Property Post ID: " . (int)$calc['desired_property_id'] . "\n" . "Property Title: " . get_the_title((int)$calc['desired_property_id']) . "\n" . "</pre>"; } $desired_property_id = (int)$calc['desired_property_id']; $desired_floorplan_id = (int)$calc['desired_floorplan_id']; $result['no_property_match'] = (int)$calc['no_property_match']; $result['no_floorplan_match'] = (int)$calc['no_floorplan_match']; // Read current proper pick fields $current_props = pods_field(CH_UNITS_CPT, $unit_post_id, CH_UNIT_PROP_FIELD); $current_fps = pods_field(CH_UNITS_CPT, $unit_post_id, CH_UNIT_FLOORPLAN_FIELD); $current_prop_ids = ch_normalize_pods_rel_ids($current_props); $current_fp_ids = ch_normalize_pods_rel_ids($current_fps); // Desired values for pick fields (single select behavior enforced) $new_prop_ids = ($desired_property_id > 0) ? [$desired_property_id] : []; $new_fp_ids = ($desired_floorplan_id > 0) ? [$desired_floorplan_id] : []; // Legacy field handling $current_rel = pods_field(CH_UNITS_CPT, $unit_post_id, CH_REL_FIELD); $current_legacy_ids = ch_normalize_pods_rel_ids($current_rel); $other_ids = []; $current_property_ids_in_legacy = []; $current_floorplan_ids_in_legacy = []; foreach ($current_legacy_ids as $rid) { $ptype = get_post_type($rid); if ($ptype === CH_PROPERTIES_CPT) $current_property_ids_in_legacy[] = (int)$rid; elseif ($ptype === CH_FLOORPLANS_CPT) $current_floorplan_ids_in_legacy[] = (int)$rid; else $other_ids[] = (int)$rid; } $new_legacy_ids = $current_legacy_ids; if (CH_UPDATE_LEGACY_REL_FIELD) { $new_legacy_ids = array_values(array_unique(array_merge( $other_ids, ($desired_property_id > 0 ? [$desired_property_id] : $current_property_ids_in_legacy), ($desired_floorplan_id > 0 ? [$desired_floorplan_id] : $current_floorplan_ids_in_legacy) ))); } // Change detection $changed = false; $tmp = $current_prop_ids; sort($tmp); $tmp2 = $new_prop_ids; sort($tmp2); if ($tmp !== $tmp2) $changed = true; $tmp = $current_fp_ids; sort($tmp); $tmp2 = $new_fp_ids; sort($tmp2); if ($tmp !== $tmp2) $changed = true; if (CH_UPDATE_LEGACY_REL_FIELD) { $tmp = $current_legacy_ids; sort($tmp); $tmp2 = $new_legacy_ids; sort($tmp2); if ($tmp !== $tmp2) $changed = true; } if ($debug) { echo '<pre>' . "Unit #{$unit_post_id}\n" . "BuildiumPropertyId={$buildium_property_id}\n" . "Desired property ID: {$desired_property_id}\n" . "Desired floorplan ID: {$desired_floorplan_id}\n" . "Current properties: " . print_r($current_prop_ids, true) . "New properties: " . print_r($new_prop_ids, true) . "Current floorplans: " . print_r($current_fp_ids, true) . "New floorplans: " . print_r($new_fp_ids, true) . (CH_UPDATE_LEGACY_REL_FIELD ? ("Current legacy: " . print_r($current_legacy_ids, true) . "New legacy: " . print_r($new_legacy_ids, true)) : "") . "Changed? " . ($changed ? "YES" : "NO") . "\n" . "Save? " . ($save ? "YES" : "NO") . "</pre>"; } if ($changed && $save) { $save_data = []; // Always set the pick fields (even if empty arrays) $save_data[CH_UNIT_PROP_FIELD] = $new_prop_ids; $save_data[CH_UNIT_FLOORPLAN_FIELD] = $new_fp_ids; if (CH_UPDATE_LEGACY_REL_FIELD) { $save_data[CH_REL_FIELD] = $new_legacy_ids; } $pod->save($save_data); clean_post_cache($unit_post_id); $result['updated'] = 1; } return $result; } /** * Repair relationships only (manual tool). Uses allowlist of WP units. * Manual: * /wp-admin/?repair_buildium_relationships=1 * /wp-admin/?repair_buildium_relationships=1&debug=1 * /wp-admin/?repair_buildium_relationships=1&dry_run=1&debug=1 */ function ch_repair_unit_relationships_from_buildium($debug = false, $dry_run = false) { global $wpdb; if (!function_exists('pods')) { return ['ok' => false, 'error' => 'Pods not active']; } if (!defined('BUILDIUM_CLIENT_ID') || !defined('BUILDIUM_CLIENT_SECRET')) { return ['ok' => false, 'error' => 'Missing Buildium credentials in wp-config.php']; } $client_id = BUILDIUM_CLIENT_ID; $client_secret = BUILDIUM_CLIENT_SECRET; // Allowlist of Buildium IDs that exist in WP $allowed = []; $existing_ids = $wpdb->get_col(" SELECT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = '" . esc_sql(CH_UNIT_BUILD_META) . "' AND p.post_type = '" . esc_sql(CH_UNITS_CPT) . "' AND pm.meta_value <> '' "); foreach ($existing_ids as $bid) $allowed[(string)$bid] = true; $property_lookup = ch_build_property_lookup(); $floorplan_lookup = ch_build_floorplan_title_lookup(); $base_url = 'https://api.buildium.com/v1/rentals/units'; $limit = 50; $offset = 0; $max_pages = 600; $checked = 0; $updated = 0; $no_property_match = 0; $no_floorplan_match = 0; $errors = 0; for ($page = 1; $page <= $max_pages; $page++) { $url = add_query_arg(['limit'=>$limit,'offset'=>$offset], $base_url); $response = wp_remote_get($url, array( 'headers' => array( 'x-buildium-client-id' => $client_id, 'x-buildium-client-secret' => $client_secret, 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'WordPress Buildium Relationship Repair' ), 'timeout' => 30 )); if (is_wp_error($response)) { $errors++; if ($debug) echo '<pre>API error: '.esc_html($response->get_error_message()).'</pre>'; break; } $status = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); if ($status !== 200) { $errors++; if ($debug) echo '<pre>HTTP '.intval($status).' '.esc_html(substr($body,0,800)).'</pre>'; break; } $data = json_decode($body, true); if ($data === null) { $errors++; if ($debug) echo '<pre>JSON decode failed</pre>'; break; } if (is_array($data) && empty($data)) break; if (isset($data['Results']) && is_array($data['Results'])) $units = $data['Results']; elseif (is_array($data) && isset($data[0]) && is_array($data[0])) $units = $data; else { $errors++; if ($debug) echo '<pre>Unexpected response structure</pre>'; break; } if (empty($units)) break; foreach ($units as $unit) { if (!isset($unit['Id'])) continue; $bid = (string)$unit['Id']; if (!isset($allowed[$bid])) continue; $checked++; // Find WP unit $q = new WP_Query([ 'post_type' => CH_UNITS_CPT, 'posts_per_page' => 1, 'fields' => 'ids', 'post_status' => 'any', 'meta_query' => [[ 'key' => CH_UNIT_BUILD_META, 'value' => $bid, 'compare' => '=' ]], 'no_found_rows' => true, ]); if (empty($q->posts)) continue; $unit_post_id = (int)$q->posts[0]; $prop_buildium_id = isset($unit['PropertyId']) ? (string)$unit['PropertyId'] : ''; $bedrooms_raw = $unit['UnitBedrooms'] ?? null; $bathrooms_raw = $unit['UnitBathrooms'] ?? null; $rr = ch_repair_one_unit_relationships( $unit_post_id, $prop_buildium_id, $bedrooms_raw, $bathrooms_raw, $property_lookup, $floorplan_lookup, $debug, ($dry_run ? false : true) ); if (!empty($rr['updated'])) $updated++; if (!empty($rr['no_property_match'])) $no_property_match++; if (!empty($rr['no_floorplan_match'])) $no_floorplan_match++; } $offset += $limit; } return [ 'ok' => ($errors === 0), 'dry_run' => $dry_run ? 1 : 0, 'checked_allowlisted_units' => $checked, 'updated_units' => $updated, 'no_property_match' => $no_property_match, 'no_floorplan_match' => $no_floorplan_match, 'errors' => $errors, ]; } /** ========================================================= * FLOORPLAN -> PROPERTY REPAIR * ========================================================= * Uses floorplan title prefix (before "|") to match a Property post title. * Saves to floorplans.relationship. * Manual: * /wp-admin/?repair_floorplan_properties=1 * /wp-admin/?repair_floorplan_properties=1&debug=1 * /wp-admin/?repair_floorplan_properties=1&dry_run=1&debug=1 */ function ch_repair_floorplan_property_relationships($debug = false, $dry_run = false) { if (!function_exists('pods')) { return ['ok' => false, 'error' => 'Pods not active']; } // Map property title => post ID (case-insensitive) $props = new WP_Query([ 'post_type' => CH_PROPERTIES_CPT, 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'any', 'no_found_rows' => true, ]); $prop_by_title = []; foreach ($props->posts as $pid) { $t = trim(get_the_title($pid)); if ($t !== '') $prop_by_title[strtolower($t)] = (int)$pid; } $fps = new WP_Query([ 'post_type' => CH_FLOORPLANS_CPT, 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'any', 'no_found_rows' => true, ]); $checked = 0; $updated = 0; $no_match = 0; foreach ($fps->posts as $fid) { $checked++; $title = trim(get_the_title($fid)); if ($title === '') continue; $prefix = trim(explode('|', $title)[0]); // e.g., "Aurelia Flats" $property_id = $prop_by_title[strtolower($prefix)] ?? 0; if ($property_id <= 0) { $no_match++; if ($debug) echo '<pre>No property match for floorplan #' . intval($fid) . ' title="' . esc_html($title) . '" prefix="' . esc_html($prefix) . '"</pre>'; continue; } $current = pods_field(CH_FLOORPLANS_CPT, (int)$fid, CH_REL_FIELD); $current_ids = ch_normalize_pods_rel_ids($current); if (in_array($property_id, $current_ids, true)) continue; $new_ids = array_values(array_unique(array_merge($current_ids, [$property_id]))); if ($dry_run) { if ($debug) echo '<pre>DRY RUN: would link floorplan #' . intval($fid) . ' to property #' . intval($property_id) . '</pre>'; continue; } $pod = pods(CH_FLOORPLANS_CPT, (int)$fid); if ($pod && method_exists($pod, 'save')) { $pod->save([ CH_REL_FIELD => $new_ids ]); clean_post_cache((int)$fid); $updated++; if ($debug) { echo '<pre>Linked floorplan #' . intval($fid) . ' to property #' . intval($property_id) . '</pre>'; } } } return [ 'ok' => true, 'dry_run' => $dry_run ? 1 : 0, 'checked' => $checked, 'updated' => $updated, 'no_match' => $no_match ]; } /** ========================================================= * ROLLUPS HELPERS * ========================================================= */ /** * Get related floorplan IDs + property IDs for a unit. * Prefers proper pick fields: * - Units.floorplans + Units.properties * Falls back to legacy Units.relationship if pick fields empty. */ function ch_get_unit_related_ids($unit_post_id, $pods_active) { $out = array('floorplans' => array(), 'properties' => array()); if (!$pods_active) return $out; // Prefer correct pick fields $fp_rel = pods_field(CH_UNITS_CPT, $unit_post_id, CH_UNIT_FLOORPLAN_FIELD); $pr_rel = pods_field(CH_UNITS_CPT, $unit_post_id, CH_UNIT_PROP_FIELD); $fp_ids = ch_normalize_pods_rel_ids($fp_rel); $pr_ids = ch_normalize_pods_rel_ids($pr_rel); // If both empty, fall back to legacy if (empty($fp_ids) && empty($pr_ids)) { $legacy = pods_field(CH_UNITS_CPT, $unit_post_id, CH_REL_FIELD); $legacy_ids = ch_normalize_pods_rel_ids($legacy); foreach ($legacy_ids as $rid) { $rid = (int)$rid; if ($rid <= 0) continue; $ptype = get_post_type($rid); if ($ptype === CH_FLOORPLANS_CPT) $fp_ids[] = $rid; elseif ($ptype === CH_PROPERTIES_CPT) $pr_ids[] = $rid; } } // Normalize + validate types foreach (array_unique(array_map('intval', $fp_ids)) as $fid) { if ($fid > 0 && get_post_type($fid) === CH_FLOORPLANS_CPT) $out['floorplans'][$fid] = $fid; } foreach (array_unique(array_map('intval', $pr_ids)) as $pid) { if ($pid > 0 && get_post_type($pid) === CH_PROPERTIES_CPT) $out['properties'][$pid] = $pid; } // If we have floorplans but no properties, infer via floorplan.relationship legacy if (!empty($out['floorplans']) && empty($out['properties'])) { foreach ($out['floorplans'] as $fid) { $frel = pods_field(CH_FLOORPLANS_CPT, (int)$fid, CH_REL_FIELD); $fids = ch_normalize_pods_rel_ids($frel); foreach ($fids as $pid) { $pid = (int)$pid; if ($pid > 0 && get_post_type($pid) === CH_PROPERTIES_CPT) $out['properties'][$pid] = $pid; } } } $out['floorplans'] = array_values($out['floorplans']); $out['properties'] = array_values($out['properties']); return $out; } /** * Update has_available_units on a CPT. * $has_any_map: [post_id => true] means available, else set to 0. */ function ch_update_has_available_units($post_type, $has_any_map, $pods_active, $debug = false, $dry_run = false) { $all_ids = ch_get_all_post_ids($post_type); $updated = 0; foreach ($all_ids as $pid) { $pid = (int)$pid; if ($pid <= 0) continue; $new_val = isset($has_any_map[$pid]) ? '1' : '0'; $old_val = (string)get_post_meta($pid, CH_AVAIL_FIELD, true); if ($old_val === $new_val) continue; if ($dry_run) { if ($debug) { echo '<pre>DRY RUN: would update ' . esc_html($post_type) . ' #' . intval($pid) . ' ' . esc_html(CH_AVAIL_FIELD) . ' => ' . esc_html($new_val) . '</pre>'; } continue; } update_post_meta($pid, CH_AVAIL_FIELD, $new_val); if ($pods_active) { $pod = pods($post_type, $pid); if ($pod && method_exists($pod, 'save')) { $pod->save(array(CH_AVAIL_FIELD => $new_val)); } } clean_post_cache($pid); $updated++; if ($debug) { echo '<pre>Updated ' . esc_html($post_type) . ' #' . intval($pid) . ' ' . esc_html(CH_AVAIL_FIELD) . ' => ' . esc_html($new_val) . '</pre>'; } } return $updated; } /** * Format "$850–$2,000 per month" (uses en dash). */ function ch_format_price_range($min, $max) { if ($min === null || $max === null) return ''; $min_i = (int)round($min); $max_i = (int)round($max); $min_s = number_format($min_i, 0, '.', ','); $max_s = number_format($max_i, 0, '.', ','); if ($min_i === $max_i) return '$' . $min_s . ' per month'; return '$' . $min_s . '–$' . $max_s . ' per month'; } /** * Update price (range string) on a CPT based on min/max maps. */ function ch_update_price_range_rollup($post_type, $min_map, $max_map, $pods_active, $debug = false, $dry_run = false) { $all_ids = ch_get_all_post_ids($post_type); $updated = 0; foreach ($all_ids as $pid) { $pid = (int)$pid; if ($pid <= 0) continue; $min = isset($min_map[$pid]) ? $min_map[$pid] : null; $max = isset($max_map[$pid]) ? $max_map[$pid] : null; $new_val = ch_format_price_range($min, $max); $old_val = (string)get_post_meta($pid, CH_PRICE_FIELD, true); if ($old_val === $new_val) continue; if ($dry_run) { if ($debug) { echo '<pre>DRY RUN: would update ' . esc_html($post_type) . ' #' . intval($pid) . ' ' . esc_html(CH_PRICE_FIELD) . ' => ' . esc_html($new_val) . '</pre>'; } continue; } update_post_meta($pid, CH_PRICE_FIELD, $new_val); if ($pods_active) { $pod = pods($post_type, $pid); if ($pod && method_exists($pod, 'save')) { $pod->save(array(CH_PRICE_FIELD => $new_val)); } } clean_post_cache($pid); $updated++; if ($debug) { echo '<pre>Updated ' . esc_html($post_type) . ' #' . intval($pid) . ' ' . esc_html(CH_PRICE_FIELD) . ' => ' . esc_html($new_val) . '</pre>'; } } return $updated; } function ch_get_all_post_ids($post_type) { $q = new WP_Query([ 'post_type' => $post_type, 'posts_per_page' => -1, 'fields' => 'ids', 'post_status' => 'any', 'no_found_rows' => true, ]); return $q->posts ?: []; } /** ========================================================= * DUPLICATE FINDER (buildium_unit_id) * ========================================================= */ function buildium_find_duplicate_units($trash = false, $debug = false) { global $wpdb; $meta_key = CH_UNIT_BUILD_META; $dupes = $wpdb->get_results($wpdb->prepare(" SELECT pm.meta_value AS buildium_unit_id, COUNT(*) AS cnt FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type = %s AND pm.meta_value <> '' GROUP BY pm.meta_value HAVING COUNT(*) > 1 ORDER BY cnt DESC ", $meta_key, CH_UNITS_CPT), ARRAY_A); $report = array( 'duplicate_groups' => count($dupes), 'duplicates_total_posts_in_groups' => 0, 'trashed' => 0, 'kept' => 0, 'groups' => array(), ); foreach ($dupes as $row) { $bid = $row['buildium_unit_id']; $posts = $wpdb->get_results($wpdb->prepare(" SELECT p.ID, p.post_status, p.post_date, p.post_modified, p.post_title FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID WHERE p.post_type=%s AND pm.meta_key=%s AND pm.meta_value=%s ORDER BY (p.post_status='publish') DESC, p.post_modified DESC ", CH_UNITS_CPT, $meta_key, $bid), ARRAY_A); $report['duplicates_total_posts_in_groups'] += count($posts); $keep = array_shift($posts); $report['kept']++; $group = array( 'buildium_unit_id' => $bid, 'keep' => $keep, 'others' => $posts, ); if ($trash) { foreach ($posts as $p) { $pid = intval($p['ID']); wp_trash_post($pid); $report['trashed']++; } } $report['groups'][] = $group; } return $report; } /** ========================================================= * OPTIONAL SHORTCODE: availability button on property/floorplan pages * Usage: * [ch_availability_button waitlist_url="/waitlist/"] * ========================================================= */ add_shortcode('ch_availability_button', function($atts) { $atts = shortcode_atts([ 'available_text' => 'View Available Units', 'available_url' => '', 'waitlist_text' => 'Join the Waitlist', 'waitlist_url' => '', 'field' => CH_AVAIL_FIELD, ], $atts); $post_id = get_the_ID(); $flag = get_post_meta($post_id, $atts['field'], true); $is_available = ((string)$flag === '1' || $flag === 1 || $flag === true); if ($is_available) { if (!$atts['available_url']) $atts['available_url'] = get_permalink($post_id) . '#available'; return '<a class="et_pb_button ch-available" href="'.esc_url($atts['available_url']).'">'.esc_html($atts['available_text']).'</a>'; } return '<a class="et_pb_button ch-waitlist" href="'.esc_url($atts['waitlist_url']).'">'.esc_html($atts['waitlist_text']).'</a>'; }); function ch_wmt_property_details() { $prop = get_posts(array('post_type' => 'properties', 'meta_key' => 'buildium_property_id', 'meta_value' => '171737', 'posts_per_page' => 1)); $avail = (!empty($prop) && get_post_meta($prop[0]->ID, 'has_available_units', true)) ? 'Available' : 'Waitlist'; $html = '<div class="ch-property-detail-row-wrap">'; $html .= '<div class="ch-property-detail-row">'; $html .= '<div class="ch-property-detail-item"><span class="ch-property-detail-label">Neighbourhood:</span> West Regina</div>'; $html .= '<div class="ch-property-detail-item"><span class="ch-property-detail-label">Availability:</span> ' . esc_html($avail) . '</div>'; $html .= '<div class="ch-property-detail-item"><span class="ch-property-detail-label">Price Range:</span> Starting from ' . chr(36) . '1,200/month</div>'; $html .= '</div>'; $html .= '<p class="ch-property-detail-item"><span class="ch-property-detail-label">Suite Sizes:</span> 780 - 1,350 sq. ft.</p>'; $html .= '</div>'; return $html; } add_shortcode('ch_property_west_market_townhomes', 'ch_wmt_property_details'); // TEMP: mu-plugin file reader/writer - REMOVE AFTER USE add_action('wp_ajax_ch_read_mu', function() { if (!current_user_can('manage_options')) wp_send_json_error('Unauthorized'); $f = isset($_POST['f']) ? basename($_POST['f']) : ''; if (!$f) wp_send_json_error('No filename'); $p = WPMU_PLUGIN_DIR . '/' . $f; if (!file_exists($p)) wp_send_json_error('Not found: ' . $p); wp_send_json_success(file_get_contents($p)); }); add_action('wp_ajax_ch_write_mu', function() { if (!current_user_can('manage_options')) wp_send_json_error('Unauthorized'); $f = isset($_POST['f']) ? basename($_POST['f']) : ''; $c = isset($_POST['c']) ? stripslashes($_POST['c']) : ''; if (!$f) wp_send_json_error('No filename'); $p = WPMU_PLUGIN_DIR . '/' . $f; $r = file_put_contents($p, $c); if ($r === false) wp_send_json_error('Write failed'); wp_send_json_success('Wrote ' . $r . ' bytes to ' . $p); }); // TEMP ADDED: Directory listing add_action('wp_ajax_ch_list_mu', function() { if (!current_user_can('manage_options')) wp_send_json_error('Unauthorized'); $dir = WPMU_PLUGIN_DIR; $files = glob($dir . '/*.php') ?: []; $subdirs = glob($dir . '/*', GLOB_ONLYDIR) ?: []; wp_send_json_success(['dir' => $dir, 'files' => array_map('basename', $files), 'subdirs' => array_map('basename', $subdirs)]); }); https://staging.chliving.ca/post-sitemap.xml 2026-01-27T21:49:14+00:00 https://staging.chliving.ca/page-sitemap.xml 2026-03-16T18:48:42+00:00 https://staging.chliving.ca/floorplans-sitemap.xml 2026-06-29T16:14:48+00:00 https://staging.chliving.ca/properties-sitemap.xml 2026-06-29T21:15:19+00:00 https://staging.chliving.ca/resources-sitemap.xml 2026-03-12T17:19:18+00:00 https://staging.chliving.ca/category-sitemap.xml 2026-01-27T21:49:14+00:00 https://staging.chliving.ca/post_tag-sitemap.xml 2026-01-27T21:49:14+00:00 https://staging.chliving.ca/amenities-sitemap.xml 2026-06-29T21:15:19+00:00 https://staging.chliving.ca/resource_section-sitemap.xml 2026-03-12T17:19:18+00:00 https://staging.chliving.ca/author-sitemap.xml 2026-01-25T10:39:01+00:00