| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | <?php |
| 2 | +// phpcs:set WordPress.Security.ValidatedSanitizedInput customSanitizingFunctions[] ph_clean | |
| 3 | +// ph_clean() recursively sanitizes text; presence, shape and unslashing checks remain separate. | |
| 4 | + | |
| 2 | 5 | /** |
| 3 | 6 | * PropertyHive Formatting |
| 4 | 7 | * |
| 5 | 8 | * Functions for formatting data. |
| @@ -14,16 +17,152 @@ | ||
| 14 | 17 | exit; // Exit if accessed directly |
| 15 | 18 | } |
| 16 | 19 | |
| 17 | 20 | /** |
| 21 | + * Clean stored description HTML while retaining ordinary links and tour frames. | |
| 22 | + */ | |
| 23 | +function propertyhive_sanitize_description( $html ) { | |
| 24 | + $allowed = wp_kses_allowed_html( 'post' ); | |
| 25 | + $allowed['iframe'] = array_fill_keys( array( 'src', 'title', 'width', 'height', 'allow', 'allowfullscreen', 'frameborder', 'loading', 'referrerpolicy' ), true ); | |
| 26 | + $clean = wp_kses( $html, $allowed, wp_allowed_protocols() ); | |
| 27 | + | |
| 28 | + // KSES attribute callbacks are unavailable on WordPress 5.6. Check only the | |
| 29 | + // normalized iframe tags here, including quoted attributes containing >. | |
| 30 | + return preg_replace_callback( '~<iframe\b(?:[^>"\']++|"[^"]*+"|\'[^\']*+\')*>~i', static function( $match ) { | |
| 31 | + $attributes = wp_kses_hair( substr( $match[0], 7, -1 ), wp_allowed_protocols() ); | |
| 32 | + $sources = array(); | |
| 33 | + foreach ( $attributes as $name => $attribute ) { | |
| 34 | + if ( strtolower( $name ) === 'src' ) { | |
| 35 | + $sources[] = $attribute['value']; | |
| 36 | + } | |
| 37 | + } | |
| 38 | + if ( empty( $sources ) ) { | |
| 39 | + return $match[0]; | |
| 40 | + } | |
| 41 | + if ( count( $sources ) !== 1 ) { | |
| 42 | + return '<iframe>'; | |
| 43 | + } | |
| 44 | + $url = html_entity_decode( $sources[0], ENT_QUOTES, 'UTF-8' ); | |
| 45 | + $parts = wp_parse_url( $url ); | |
| 46 | + if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! preg_match( '~^(?:https?:)?//~i', $url ) || ( isset( $parts['scheme'] ) && ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) ) { | |
| 47 | + return '<iframe>'; | |
| 48 | + } | |
| 49 | + if ( ! apply_filters( 'propertyhive_description_iframe_url_allowed', true, $url ) ) { | |
| 50 | + return '<iframe>'; | |
| 51 | + } | |
| 52 | + return $match[0]; | |
| 53 | + }, $clean ); | |
| 54 | +} | |
| 55 | + | |
| 56 | +/** | |
| 57 | + * Translate built-in rent frequency labels, preserving custom labels. | |
| 58 | + * | |
| 59 | + * @param string $frequency Stored frequency. | |
| 60 | + * @return string Display label. | |
| 61 | + */ | |
| 62 | +function propertyhive_get_rent_frequency_label( $frequency ) { | |
| 63 | + $labels = array( | |
| 64 | + 'pd' => __( 'pd', 'propertyhive' ), | |
| 65 | + 'pppw' => __( 'pppw', 'propertyhive' ), | |
| 66 | + 'pw' => __( 'pw', 'propertyhive' ), | |
| 67 | + 'pcm' => __( 'pcm', 'propertyhive' ), | |
| 68 | + 'pq' => __( 'pq', 'propertyhive' ), | |
| 69 | + 'pa' => __( 'pa', 'propertyhive' ), | |
| 70 | + ); | |
| 71 | + return isset( $labels[$frequency] ) ? $labels[$frequency] : $frequency; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** | |
| 75 | + * Translate built-in CRM statuses without passing dynamic text to gettext. | |
| 76 | + * | |
| 77 | + * @param string $status Stored status. | |
| 78 | + * @return string Display label. | |
| 79 | + */ | |
| 80 | +function propertyhive_get_status_label( $status ) { | |
| 81 | + $labels = array( | |
| 82 | + 'pending' => __( 'Pending', 'propertyhive' ), | |
| 83 | + 'confirmed' => __( 'Confirmed', 'propertyhive' ), | |
| 84 | + 'unconfirmed' => __( 'Unconfirmed', 'propertyhive' ), | |
| 85 | + 'carried_out' => __( 'Carried Out', 'propertyhive' ), | |
| 86 | + 'awaiting_feedback' => __( 'Awaiting Feedback', 'propertyhive' ), | |
| 87 | + 'feedback_passed_on' => __( 'Feedback Passed On', 'propertyhive' ), | |
| 88 | + 'feedback_not_passed_on' => __( 'Feedback Not Passed On', 'propertyhive' ), | |
| 89 | + 'cancelled' => __( 'Cancelled', 'propertyhive' ), | |
| 90 | + 'no_show' => __( 'No Show', 'propertyhive' ), | |
| 91 | + 'offer_made' => __( 'Offer Made', 'propertyhive' ), | |
| 92 | + 'accepted' => __( 'Accepted', 'propertyhive' ), | |
| 93 | + 'declined' => __( 'Declined', 'propertyhive' ), | |
| 94 | + 'current' => __( 'Current', 'propertyhive' ), | |
| 95 | + 'exchanged' => __( 'Exchanged', 'propertyhive' ), | |
| 96 | + 'completed' => __( 'Completed', 'propertyhive' ), | |
| 97 | + 'fallen_through' => __( 'Fallen Through', 'propertyhive' ), | |
| 98 | + 'interested' => __( 'Interested', 'propertyhive' ), | |
| 99 | + 'not_interested' => __( 'Not Interested', 'propertyhive' ), | |
| 100 | + ); | |
| 101 | + return isset( $labels[$status] ) ? $labels[$status] : ucwords( str_replace( '_', ' ', $status ) ); | |
| 102 | +} | |
| 103 | + | |
| 104 | +/** | |
| 18 | 105 | * Clean variables using sanitize_text_field. |
| 19 | 106 | * @param string|array $var |
| 20 | 107 | * @return string|array |
| 21 | 108 | */ |
| 109 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_clean; the established callable name is part of the plugin/extension API and must remain stable. | |
| 22 | 110 | function ph_clean( $var ) { |
| 23 | - return is_array( $var ) ? array_map( 'ph_clean', $var ) : sanitize_text_field( $var ); | |
| 111 | + | |
| 112 | + if ( is_array( $var ) ) { | |
| 113 | + return array_map( 'ph_clean', $var ); | |
| 114 | + } else { | |
| 115 | + return is_scalar( $var ) ? sanitize_text_field( $var ) : $var; | |
| 116 | + } | |
| 24 | 117 | } |
| 25 | 118 | |
| 119 | +/** | |
| 120 | + * Strip none numeric or comma chars from phone numbers | |
| 121 | + * @param string | |
| 122 | + * @return string | |
| 123 | + */ | |
| 124 | + | |
| 125 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_clean_telephone_number; the established callable name is part of the plugin/extension API and must remain stable. | |
| 126 | +function ph_clean_telephone_number( $var ) { | |
| 127 | + | |
| 128 | + return preg_replace( "/[^0-9,]/", "", $var ); | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** | |
| 132 | + * Format monetary number value with decimal and thousands separators for display in a form field | |
| 133 | + * @param string | |
| 134 | + * @return string | |
| 135 | + */ | |
| 136 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_display_price_field; the established callable name is part of the plugin/extension API and must remain stable. | |
| 137 | +function ph_display_price_field( $var, $use_separator_setting = false ) | |
| 138 | +{ | |
| 139 | + $float_var = (float)$var; | |
| 140 | + | |
| 141 | + // If stored value isn't a valid number with decimals, display as it's stored | |
| 142 | + if ( $float_var !== floatval(0) ) | |
| 143 | + { | |
| 144 | + // If there are decimals on the number, display them. If not, display none | |
| 145 | + $decimals = $float_var == intval($var) ? 0 : 2; | |
| 146 | + | |
| 147 | + if ( !$use_separator_setting ) | |
| 148 | + { | |
| 149 | + $decimal_separator = '.'; | |
| 150 | + $thousands_separator = ','; | |
| 151 | + } | |
| 152 | + else | |
| 153 | + { | |
| 154 | + // Get custom thousands and decimal and separators, if set, when not displaying in an input | |
| 155 | + $thousands_separator = get_option('propertyhive_price_thousand_separator', ','); | |
| 156 | + $decimal_separator = get_option('propertyhive_price_decimal_separator', '.'); | |
| 157 | + } | |
| 158 | + | |
| 159 | + $var = number_format( $float_var, $decimals, $decimal_separator, $thousands_separator ); | |
| 160 | + } | |
| 161 | + | |
| 162 | + return (string)$var; | |
| 163 | +} | |
| 164 | + | |
| 26 | 165 | if ( ! function_exists( 'ph_rgb_from_hex' ) ) { |
| 27 | 166 | |
| 28 | 167 | /** |
| 29 | 168 | * Hex darker/lighter/contrast functions for colours. |
| @@ -30,8 +169,9 @@ | ||
| 30 | 169 | * |
| 31 | 170 | * @param mixed $color |
| 32 | 171 | * @return string |
| 33 | 172 | */ |
| 173 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_rgb_from_hex; the established callable name is part of the plugin/extension API and must remain stable. | |
| 34 | 174 | function ph_rgb_from_hex( $color ) { |
| 35 | 175 | $color = str_replace( '#', '', $color ); |
| 36 | 176 | // Convert shorthand colors to full format, e.g. "FFF" -> "FFFFFF" |
| 37 | 177 | $color = preg_replace( '~^(.)(.)(.)$~', '$1$1$2$2$3$3', $color ); |
| @@ -36,11 +176,11 @@ | ||
| 36 | 176 | // Convert shorthand colors to full format, e.g. "FFF" -> "FFFFFF" |
| 37 | 177 | $color = preg_replace( '~^(.)(.)(.)$~', '$1$1$2$2$3$3', $color ); |
| 38 | 178 | |
| 39 | 179 | $rgb = array(); |
| 40 | - $rgb['R'] = hexdec( $color{0} . $color{1} ); | |
| 41 | - $rgb['G'] = hexdec( $color{2} . $color{3} ); | |
| 42 | - $rgb['B'] = hexdec( $color{4} . $color{5} ); | |
| 180 | + $rgb['R'] = hexdec( $color[0] . $color[1] ); | |
| 181 | + $rgb['G'] = hexdec( $color[2] . $color[3] ); | |
| 182 | + $rgb['B'] = hexdec( $color[4] . $color[5] ); | |
| 43 | 183 | |
| 44 | 184 | return $rgb; |
| 45 | 185 | } |
| 46 | 186 | } |
| @@ -53,8 +193,9 @@ | ||
| 53 | 193 | * @param mixed $color |
| 54 | 194 | * @param int $factor (default: 30) |
| 55 | 195 | * @return string |
| 56 | 196 | */ |
| 197 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_hex_darker; the established callable name is part of the plugin/extension API and must remain stable. | |
| 57 | 198 | function ph_hex_darker( $color, $factor = 30 ) { |
| 58 | 199 | $base = ph_rgb_from_hex( $color ); |
| 59 | 200 | $color = '#'; |
| 60 | 201 | |
| @@ -82,8 +223,9 @@ | ||
| 82 | 223 | * @param mixed $color |
| 83 | 224 | * @param int $factor (default: 30) |
| 84 | 225 | * @return string |
| 85 | 226 | */ |
| 227 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_hex_lighter; the established callable name is part of the plugin/extension API and must remain stable. | |
| 86 | 228 | function ph_hex_lighter( $color, $factor = 30 ) { |
| 87 | 229 | $base = ph_rgb_from_hex( $color ); |
| 88 | 230 | $color = '#'; |
| 89 | 231 | |
| @@ -113,8 +255,9 @@ | ||
| 113 | 255 | * @param string $dark (default: '#000000') |
| 114 | 256 | * @param string $light (default: '#FFFFFF') |
| 115 | 257 | * @return string |
| 116 | 258 | */ |
| 259 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_light_or_dark; the established callable name is part of the plugin/extension API and must remain stable. | |
| 117 | 260 | function ph_light_or_dark( $color, $dark = '#000000', $light = '#FFFFFF' ) { |
| 118 | 261 | |
| 119 | 262 | $hex = str_replace( '#', '', $color ); |
| 120 | 263 | |
| @@ -135,8 +278,9 @@ | ||
| 135 | 278 | * |
| 136 | 279 | * @param string $hex |
| 137 | 280 | * @return string |
| 138 | 281 | */ |
| 282 | + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_format_hex; the established callable name is part of the plugin/extension API and must remain stable. | |
| 139 | 283 | function ph_format_hex( $hex ) { |
| 140 | 284 | |
| 141 | 285 | $hex = trim( str_replace( '#', '', $hex ) ); |
| 142 | 286 | |
| @@ -145,5 +289,100 @@ | ||
| 145 | 289 | } |
| 146 | 290 | |
| 147 | 291 | return $hex ? '#' . $hex : null; |
| 148 | 292 | } |
| 149 | -} | |
| 293 | +} | |
| 294 | + | |
| 295 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_nl2br; the established callable name is part of the plugin/extension API and must remain stable. | |
| 296 | +function ph_nl2br($str) | |
| 297 | +{ | |
| 298 | + // Match any <ul> or <ol> with their content | |
| 299 | + $pattern = '/(<ul[^>]*>.*?<\/ul>|<ol[^>]*>.*?<\/ol>)/is'; | |
| 300 | + $parts = preg_split($pattern, $str, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); | |
| 301 | + | |
| 302 | + foreach ($parts as &$part) { | |
| 303 | + // If the part is not a list, apply nl2br | |
| 304 | + if (!preg_match($pattern, $part)) { | |
| 305 | + $part = nl2br($part); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + // Reassemble the string | |
| 310 | + return implode('', $parts); | |
| 311 | +} | |
| 312 | + | |
| 313 | +// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound -- Legacy public global helper ph_split_address_into_fields; the established callable name is part of the plugin/extension API and must remain stable. | |
| 314 | +function ph_split_address_into_fields( $address ) | |
| 315 | +{ | |
| 316 | + $fields = [ | |
| 317 | + 'address_name_number' => '', | |
| 318 | + 'address_street' => '', | |
| 319 | + 'address_two' => '', | |
| 320 | + 'address_three' => '', | |
| 321 | + 'address_four' => '', | |
| 322 | + 'address_postcode' => '', | |
| 323 | + 'address_country' => '' | |
| 324 | + ]; | |
| 325 | + | |
| 326 | + // Replace newlines with commas and remove consecutive commas | |
| 327 | + $address = preg_replace('/\s*,\s*/', ', ', $address); | |
| 328 | + $address = preg_replace('/\s*\n\s*/', ', ', $address); | |
| 329 | + $address = preg_replace('/,+/', ',', $address); | |
| 330 | + | |
| 331 | + // Explode the address by comma | |
| 332 | + $parts = explode(',', $address); | |
| 333 | + | |
| 334 | + // Trim whitespace from each part | |
| 335 | + $parts = array_map('trim', $parts); | |
| 336 | + | |
| 337 | + // Remove empty parts | |
| 338 | + $parts = array_filter($parts); | |
| 339 | + | |
| 340 | + // Check the first part for building number and street | |
| 341 | + if (isset($parts[0])) { | |
| 342 | + if (preg_match('/^(\d+)\s+(.*)$/', $parts[0], $matches)) { | |
| 343 | + $fields['address_name_number'] = $matches[1]; | |
| 344 | + $fields['address_street'] = $matches[2]; | |
| 345 | + } else { | |
| 346 | + $fields['address_street'] = $parts[0]; | |
| 347 | + } | |
| 348 | + array_shift($parts); | |
| 349 | + } | |
| 350 | + | |
| 351 | + // Detect postcode in the remaining parts | |
| 352 | + foreach ($parts as $index => $part) { | |
| 353 | + if (preg_match('/[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}/i', $part, $postcodeMatch)) { | |
| 354 | + $fields['address_postcode'] = $postcodeMatch[0]; | |
| 355 | + // Split the part containing the postcode | |
| 356 | + $remainingPart = preg_replace('/[A-Z]{1,2}\d[A-Z\d]? \d[A-Z]{2}/i', '', $part); | |
| 357 | + if (!empty(trim($remainingPart))) { | |
| 358 | + array_splice($parts, $index, 1, trim($remainingPart)); | |
| 359 | + } else { | |
| 360 | + unset($parts[$index]); | |
| 361 | + } | |
| 362 | + // Check if the next part is the country | |
| 363 | + if (isset($parts[$index + 1])) { | |
| 364 | + $fields['address_country'] = $parts[$index + 1]; | |
| 365 | + unset($parts[$index + 1]); | |
| 366 | + } | |
| 367 | + break; | |
| 368 | + } | |
| 369 | + } | |
| 370 | + | |
| 371 | + // Assign remaining parts to Address Line 2, Town/City, and County | |
| 372 | + $remainingParts = array_values($parts); | |
| 373 | + if (isset($remainingParts[0])) $fields['address_two'] = $remainingParts[0]; | |
| 374 | + if (isset($remainingParts[1])) $fields['address_three'] = $remainingParts[1]; | |
| 375 | + if (isset($remainingParts[2])) $fields['address_four'] = $remainingParts[2]; | |
| 376 | + | |
| 377 | + return $fields; | |
| 378 | +} | |
| 379 | +/** | |
| 380 | + * Resolve translated built-in or extension-provided department labels. | |
| 381 | + * | |
| 382 | + * @param string $department Department key. | |
| 383 | + * @return string Department label. | |
| 384 | + */ | |
| 385 | +function propertyhive_get_department_label( $department ) { | |
| 386 | + $labels = ph_get_departments(); | |
| 387 | + return isset( $labels[$department] ) ? $labels[$department] : ucwords( str_replace( '-', ' ', $department ) ); | |
| 388 | +} | |