booking
/
includes
/
page-setup-wizard
/
step-date-time-formats
/
class-wpbc-setup-wizard-date-time-formats.php
class-wpbc-setup-wizard-date-time-formats.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-date-time-formats/class-wpbc-setup-wizard-date-time-formats.php
| 1 | <?php |
| 2 | /** |
| 3 | * Reusable date, time, and local-language editor for Setup Wizard Step 3. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | */ |
| 7 | |
| 8 | if ( ! defined( 'ABSPATH' ) ) { |
| 9 | exit; |
| 10 | } |
| 11 | |
| 12 | /** |
| 13 | * Own the Step 3 field contract and the explicit Local translation operation. |
| 14 | * |
| 15 | * Reading the step is side-effect free. The only canonical mutation is exposed |
| 16 | * through {@see install_site_local_translation()} and must be called by the |
| 17 | * separately authorized AJAX action after an explicit user click. |
| 18 | */ |
| 19 | final class WPBC_Setup_Wizard_Date_Time_Formats { |
| 20 | |
| 21 | /** |
| 22 | * Return the ordered draft field identifiers owned by this editor. |
| 23 | * |
| 24 | * @return string[] Field allow-list. |
| 25 | */ |
| 26 | public function get_field_names() { |
| 27 | return array( 'date_format', 'time_format', 'start_day_of_week' ); |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Return fields required when the consumer advances. |
| 32 | * |
| 33 | * @return string[] Required field identifiers. |
| 34 | */ |
| 35 | public function get_required_fields() { |
| 36 | return $this->get_field_names(); |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Return current settings or safe read-only suggestions. |
| 41 | * |
| 42 | * @return array<string,string> Initial field values. |
| 43 | */ |
| 44 | public function get_initial_values() { |
| 45 | return array( |
| 46 | 'date_format' => $this->get_initial_choice( 'booking_date_format', 'date_format', $this->get_date_formats(), 'j M Y' ), |
| 47 | 'time_format' => $this->get_initial_choice( 'booking_time_format', 'time_format', $this->get_time_formats(), 'H:i' ), |
| 48 | 'start_day_of_week' => $this->get_initial_week_start(), |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Build the data-only presentation context for Step 3. |
| 54 | * |
| 55 | * @param array<string,mixed> $field_values Validated draft values. |
| 56 | * |
| 57 | * @return array<string,mixed> Authorized template context. |
| 58 | */ |
| 59 | public function get_context( array $field_values ) { |
| 60 | return array( |
| 61 | 'values' => $field_values, |
| 62 | 'date_formats' => $this->get_date_format_options(), |
| 63 | 'time_formats' => $this->get_time_format_options(), |
| 64 | 'week_start_days' => $this->get_week_start_options(), |
| 65 | 'site_language' => $this->get_site_language_context(), |
| 66 | 'date_time_settings_url' => $this->get_date_time_settings_url(), |
| 67 | 'translation_settings_url' => $this->get_translation_settings_url(), |
| 68 | ); |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Validate one allow-listed Step 3 draft field. |
| 73 | * |
| 74 | * @param string $field_id Stable field identifier. |
| 75 | * @param mixed $raw_value Untrusted submitted value. |
| 76 | * @param bool $is_required Whether an empty value is invalid. |
| 77 | * |
| 78 | * @return string|WP_Error Normalized value or a validation error. |
| 79 | */ |
| 80 | public function validate_field( $field_id, $raw_value, $is_required ) { |
| 81 | $allowed_values = array( |
| 82 | 'date_format' => $this->get_date_formats(), |
| 83 | 'time_format' => $this->get_time_formats(), |
| 84 | 'start_day_of_week' => array_map( 'strval', array_keys( $this->get_week_start_options() ) ), |
| 85 | ); |
| 86 | |
| 87 | if ( ! isset( $allowed_values[ $field_id ] ) ) { |
| 88 | return new WP_Error( 'wpbc_setup_wizard_date_time_field_unknown', __( 'The Dates and times editor received an unsupported field.', 'booking' ) ); |
| 89 | } |
| 90 | |
| 91 | if ( ! is_scalar( $raw_value ) ) { |
| 92 | return new WP_Error( 'wpbc_setup_wizard_date_time_field_invalid', __( 'Choose one of the available options.', 'booking' ) ); |
| 93 | } |
| 94 | |
| 95 | $field_value = sanitize_text_field( (string) $raw_value ); |
| 96 | if ( '' === trim( $field_value ) ) { |
| 97 | return $is_required |
| 98 | ? new WP_Error( 'wpbc_setup_wizard_date_time_field_required', __( 'This field is required.', 'booking' ) ) |
| 99 | : ''; |
| 100 | } |
| 101 | |
| 102 | if ( ! in_array( $field_value, $allowed_values[ $field_id ], true ) ) { |
| 103 | return new WP_Error( 'wpbc_setup_wizard_date_time_choice_invalid', __( 'Choose one of the available options.', 'booking' ) ); |
| 104 | } |
| 105 | |
| 106 | return $field_value; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Return the server-derived Local translation presentation record. |
| 111 | * |
| 112 | * The website locale is never accepted from the browser. Available Local |
| 113 | * translations are discovered only from Booking Calendar's language folder, |
| 114 | * so rendering this context performs no remote request. |
| 115 | * |
| 116 | * @return array<string,mixed> Site-language presentation data. |
| 117 | */ |
| 118 | public function get_site_language_context() { |
| 119 | $site_locale = $this->get_site_locale(); |
| 120 | $local_locale = $this->find_local_translation_locale( $site_locale ); |
| 121 | $translation_freshness = $this->get_local_translation_freshness( $local_locale ); |
| 122 | $is_builtin = 0 === strpos( strtolower( $site_locale ), 'en' ); |
| 123 | $is_local_active = 'wpbc' === (string) get_bk_option( 'booking_translation_load_from', 'wp.org' ); |
| 124 | $translation_source = $is_local_active ? 'wpbc' : 'wp.org'; |
| 125 | $translation_source_label = $is_local_active ? __( 'Local', 'booking' ) : __( 'WordPress.org', 'booking' ); |
| 126 | $is_demo = function_exists( 'wpbc_is_this_demo' ) && wpbc_is_this_demo(); |
| 127 | $can_manage_files = current_user_can( 'activate_plugins' ); |
| 128 | $language_name = $this->get_language_name( $site_locale ); |
| 129 | $language_label = sprintf( |
| 130 | /* translators: 1: Site language name, 2: WordPress locale. */ |
| 131 | __( '%1$s (%2$s)', 'booking' ), |
| 132 | $language_name, |
| 133 | $site_locale |
| 134 | ); |
| 135 | |
| 136 | if ( $is_local_active ) { |
| 137 | $status = 'active'; |
| 138 | $status_label = __( 'Local translations active', 'booking' ); |
| 139 | $help = '' === $local_locale && ! $is_builtin |
| 140 | ? __( 'Booking Calendar is configured to prefer Local translations. The current website language is not present in the installed Local archive, so the WordPress.org translation remains the fallback.', 'booking' ) |
| 141 | : __( 'Booking Calendar is configured to load its Local translation first. You can update the complete Local translation archive at any time.', 'booking' ); |
| 142 | } elseif ( $is_builtin ) { |
| 143 | $status = 'builtin'; |
| 144 | $status_label = __( 'Built in', 'booking' ); |
| 145 | $help = __( 'English is built into Booking Calendar. You can still download the complete Local translation archive for later language changes.', 'booking' ); |
| 146 | } elseif ( '' === $local_locale ) { |
| 147 | $status = 'unavailable'; |
| 148 | $status_label = __( 'Local translation unavailable', 'booking' ); |
| 149 | $help = __( 'The currently installed Local archive does not include this website language. You can update the archive and make Local translations the preferred source.', 'booking' ); |
| 150 | } else { |
| 151 | $status = 'available'; |
| 152 | $status_label = __( 'Local translation available', 'booking' ); |
| 153 | $help = __( 'A matching Booking Calendar Local translation is installed. You can update the complete archive and make Local translations the preferred source.', 'booking' ); |
| 154 | } |
| 155 | |
| 156 | $is_local_source_recommended = 'available' === $status && 'wp.org' === $translation_source; |
| 157 | $local_source_recommendation = $is_local_source_recommended |
| 158 | ? __( 'Recommended: Update and use Local translations for this website language.', 'booking' ) |
| 159 | : ''; |
| 160 | $action_label = $is_local_active |
| 161 | ? __( 'Update Local translations', 'booking' ) |
| 162 | : __( 'Download and use Local translations', 'booking' ); |
| 163 | |
| 164 | return array( |
| 165 | 'locale' => $site_locale, |
| 166 | 'local_locale' => $local_locale, |
| 167 | 'label' => $language_label, |
| 168 | 'status' => $status, |
| 169 | 'status_label' => $status_label, |
| 170 | 'help' => $help, |
| 171 | 'translation_source' => $translation_source, |
| 172 | 'translation_source_label' => $translation_source_label, |
| 173 | 'is_builtin' => $is_builtin, |
| 174 | 'is_available' => $is_builtin || '' !== $local_locale, |
| 175 | 'is_local_active' => $is_local_active, |
| 176 | 'is_local_source_recommended' => $is_local_source_recommended, |
| 177 | 'local_source_recommendation' => $local_source_recommendation, |
| 178 | 'is_demo' => $is_demo, |
| 179 | 'can_install' => ! $is_demo && $can_manage_files, |
| 180 | 'can_manage' => $can_manage_files, |
| 181 | 'action_label' => $action_label, |
| 182 | 'updated_timestamp' => $translation_freshness['updated_timestamp'], |
| 183 | 'updated_date' => $translation_freshness['updated_date'], |
| 184 | 'updated_label' => $translation_freshness['updated_label'], |
| 185 | 'is_update_recommended' => $translation_freshness['is_update_recommended'], |
| 186 | 'update_recommendation' => $translation_freshness['update_recommendation'], |
| 187 | ); |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Resolve an exact or two-letter Local locale without filesystem access. |
| 192 | * |
| 193 | * This pure matcher mirrors the canonical translation loader and exists so |
| 194 | * consumers and tests can verify locale behavior independently of rendering |
| 195 | * or installation. |
| 196 | * |
| 197 | * WordPress returns the basename of each MO file, so Booking Calendar's |
| 198 | * bundled files arrive as values such as `booking-de_DE`. Normalizing that |
| 199 | * fixed text-domain prefix here keeps filesystem discovery and the loader's |
| 200 | * locale contract aligned. |
| 201 | * |
| 202 | * @param mixed $site_locale Proposed WordPress website locale. |
| 203 | * @param string[] $available_locales Available Local locale or MO basename records. |
| 204 | * |
| 205 | * @return string Matching locale, or an empty string. |
| 206 | */ |
| 207 | public function resolve_local_translation_locale( $site_locale, array $available_locales ) { |
| 208 | if ( ! is_scalar( $site_locale ) ) { |
| 209 | return ''; |
| 210 | } |
| 211 | |
| 212 | $site_locale = (string) $site_locale; |
| 213 | $is_valid = function_exists( 'wpbc_validate_request_locale' ) |
| 214 | ? wpbc_validate_request_locale( $site_locale ) |
| 215 | : 1 === preg_match( '/\A[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*\z/D', $site_locale ); |
| 216 | |
| 217 | if ( ! $is_valid ) { |
| 218 | return ''; |
| 219 | } |
| 220 | |
| 221 | $normalized_locales = array(); |
| 222 | foreach ( $available_locales as $available_locale ) { |
| 223 | if ( ! is_scalar( $available_locale ) ) { |
| 224 | continue; |
| 225 | } |
| 226 | |
| 227 | $available_locale = (string) $available_locale; |
| 228 | if ( 0 === strpos( $available_locale, 'booking-' ) ) { |
| 229 | $available_locale = substr( $available_locale, strlen( 'booking-' ) ); |
| 230 | } |
| 231 | |
| 232 | if ( 1 === preg_match( '/\A[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*\z/D', $available_locale ) ) { |
| 233 | $normalized_locales[] = $available_locale; |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | $available_locales = array_values( array_unique( $normalized_locales ) ); |
| 238 | |
| 239 | if ( in_array( $site_locale, $available_locales, true ) ) { |
| 240 | return $site_locale; |
| 241 | } |
| 242 | |
| 243 | $short_locale = substr( $site_locale, 0, 2 ); |
| 244 | |
| 245 | return in_array( $short_locale, $available_locales, true ) ? $short_locale : ''; |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Download the WPBC Local language archive and activate the Local source. |
| 250 | * |
| 251 | * This reuses the same upgrader function and canonical option used by the |
| 252 | * existing Settings > Admin Panel > Translations workflow. The active locale |
| 253 | * is derived again at apply time and never comes from request data. |
| 254 | * |
| 255 | * @return array<string,mixed>|WP_Error Updated language context or an error. |
| 256 | */ |
| 257 | public function install_site_local_translation() { |
| 258 | $language_context = $this->get_site_language_context(); |
| 259 | $previous_status = get_bk_option( 'booking_translation_update_status', '0' ); |
| 260 | $previous_source = get_bk_option( 'booking_translation_load_from', 'wp.org' ); |
| 261 | $operation_log = array( |
| 262 | $this->create_operation_log_entry( 'info', __( 'Preparing the Booking Calendar Local translation update.', 'booking' ) ), |
| 263 | ); |
| 264 | |
| 265 | if ( $language_context['is_demo'] ) { |
| 266 | $operation_log[] = $this->create_operation_log_entry( 'error', __( 'Local translation downloads are unavailable on live demo sites.', 'booking' ) ); |
| 267 | |
| 268 | return $this->create_operation_error( 'wpbc_setup_wizard_translation_demo_denied', __( 'Local translation downloads are unavailable on live demo sites.', 'booking' ), $operation_log ); |
| 269 | } |
| 270 | |
| 271 | if ( ! current_user_can( 'activate_plugins' ) ) { |
| 272 | $operation_log[] = $this->create_operation_log_entry( 'error', __( 'You are not allowed to install Booking Calendar translations.', 'booking' ) ); |
| 273 | |
| 274 | return $this->create_operation_error( 'wpbc_setup_wizard_translation_capability_denied', __( 'You are not allowed to install Booking Calendar translations.', 'booking' ), $operation_log ); |
| 275 | } |
| 276 | |
| 277 | if ( ! function_exists( 'wpbc_translation_download_from_wpbc' ) ) { |
| 278 | $operation_log[] = $this->create_operation_log_entry( 'error', __( 'The Booking Calendar translation installer is unavailable.', 'booking' ) ); |
| 279 | |
| 280 | return $this->create_operation_error( 'wpbc_setup_wizard_translation_installer_unavailable', __( 'The Booking Calendar translation installer is unavailable.', 'booking' ), $operation_log ); |
| 281 | } |
| 282 | |
| 283 | $operation_log[] = $this->create_operation_log_entry( 'info', __( 'Selecting Local as the preferred Booking Calendar translation source.', 'booking' ) ); |
| 284 | update_bk_option( 'booking_translation_load_from', 'wpbc' ); |
| 285 | |
| 286 | if ( 'wpbc' !== (string) get_bk_option( 'booking_translation_load_from', '' ) ) { |
| 287 | update_bk_option( 'booking_translation_load_from', $previous_source ); |
| 288 | $operation_log[] = $this->create_operation_log_entry( 'error', __( 'The Local translation preference could not be saved, so the archive update was not started.', 'booking' ) ); |
| 289 | |
| 290 | return $this->create_operation_error( 'wpbc_setup_wizard_translation_activation_failed', __( 'Booking Calendar could not select the Local translation source.', 'booking' ), $operation_log ); |
| 291 | } |
| 292 | |
| 293 | $operation_log[] = $this->create_operation_log_entry( 'success', __( 'Local is now the preferred Booking Calendar translation source.', 'booking' ) ); |
| 294 | |
| 295 | if ( class_exists( 'WPBC_Action_Scheduler_Compatibility' ) ) { |
| 296 | WPBC_Action_Scheduler_Compatibility::raise_memory_limit(); |
| 297 | WPBC_Action_Scheduler_Compatibility::raise_time_limit( 300 ); |
| 298 | } |
| 299 | |
| 300 | $operation_log[] = $this->create_operation_log_entry( 'info', __( 'Downloading the official Local translation archive from wpbookingcalendar.com.', 'booking' ) ); |
| 301 | require_once WPBC_PLUGIN_DIR . '/core/class/wpbc-class-upgrader-translation-skin.php'; |
| 302 | $translation_skin = new WPBC_Upgrader_Translation_Skin( |
| 303 | array( |
| 304 | 'skip_header_footer' => true, |
| 305 | 'suppress_output' => true, |
| 306 | ) |
| 307 | ); |
| 308 | $output_buffer_level = ob_get_level(); |
| 309 | ob_start(); |
| 310 | $download_result = wpbc_translation_download_from_wpbc( $translation_skin ); |
| 311 | $this->discard_output_buffers_above_level( $output_buffer_level ); |
| 312 | $installer_error = $translation_skin->get_installer_error(); |
| 313 | $install_failure = $this->get_translation_install_failure( $download_result, $installer_error ); |
| 314 | |
| 315 | if ( null !== $install_failure ) { |
| 316 | $operation_log[] = $this->create_operation_log_entry( 'error', $install_failure['log_message'] ); |
| 317 | |
| 318 | return $this->create_operation_error( $install_failure['error_code'], $install_failure['message'], $operation_log ); |
| 319 | } |
| 320 | |
| 321 | $operation_log[] = $this->create_operation_log_entry( 'success', __( 'The Local translation archive was downloaded and unpacked.', 'booking' ) ); |
| 322 | $this->invalidate_local_translation_file_cache(); |
| 323 | update_bk_option( 'booking_translation_update_status', 'translations_updated_from_wpbc' ); |
| 324 | |
| 325 | if ( 'translations_updated_from_wpbc' !== (string) get_bk_option( 'booking_translation_update_status', '' ) ) { |
| 326 | update_bk_option( 'booking_translation_update_status', $previous_status ); |
| 327 | $operation_log[] = $this->create_operation_log_entry( 'error', __( 'The archive was updated and Local remains selected, but the translation update status could not be saved.', 'booking' ) ); |
| 328 | |
| 329 | return $this->create_operation_error( 'wpbc_setup_wizard_translation_status_failed', __( 'The Local translation was downloaded, but Booking Calendar could not save the update status.', 'booking' ), $operation_log ); |
| 330 | } |
| 331 | |
| 332 | $updated_context = $this->get_site_language_context(); |
| 333 | if ( ! $updated_context['is_builtin'] && ! $updated_context['is_available'] ) { |
| 334 | $operation_log[] = $this->create_operation_log_entry( 'warning', __( 'The archive was updated, but it does not contain a Local translation matching the current website language. WordPress.org remains the fallback for this locale.', 'booking' ) ); |
| 335 | } |
| 336 | $updated_context['message'] = sprintf( |
| 337 | /* translators: %s: Site language and locale. */ |
| 338 | __( 'Local translations were updated and selected as the preferred source for %s. The setting will be used on the next page load.', 'booking' ), |
| 339 | $updated_context['label'] |
| 340 | ); |
| 341 | $updated_context['logs'] = $operation_log; |
| 342 | |
| 343 | return $updated_context; |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Normalize the WordPress upgrader result into one safe failure contract. |
| 348 | * |
| 349 | * `WP_Upgrader::run()` is the authoritative operation result. A skin can |
| 350 | * retain a warning or cleanup error after the archive was installed, so a |
| 351 | * populated skin error must not override a successful non-false result. The |
| 352 | * skin is consulted only when an integration returns no operation result. |
| 353 | * Raw upgrader messages are never returned because they may contain paths or |
| 354 | * remote-request details. |
| 355 | * |
| 356 | * @param mixed $download_result Result returned by the canonical downloader. |
| 357 | * @param WP_Error|null $installer_error Error retained by the silent upgrader skin. |
| 358 | * |
| 359 | * @return array{error_code:string,message:string,log_message:string}|null Safe failure or null on success. |
| 360 | */ |
| 361 | private function get_translation_install_failure( $download_result, $installer_error ) { |
| 362 | if ( false === $download_result ) { |
| 363 | return array( |
| 364 | 'error_code' => 'wpbc_setup_wizard_translation_filesystem_unavailable', |
| 365 | 'message' => __( 'WordPress could not access the filesystem to install the Local translation. Check filesystem access, then try again.', 'booking' ), |
| 366 | 'log_message' => __( 'WordPress could not establish filesystem access for the Local translation update.', 'booking' ), |
| 367 | ); |
| 368 | } |
| 369 | |
| 370 | $upgrader_error = $this->get_nonempty_wordpress_error( $download_result ); |
| 371 | if ( null === $upgrader_error && null === $download_result ) { |
| 372 | $upgrader_error = $this->get_nonempty_wordpress_error( $installer_error ); |
| 373 | } |
| 374 | |
| 375 | if ( null === $upgrader_error ) { |
| 376 | return null; |
| 377 | } |
| 378 | |
| 379 | return $this->get_safe_translation_error_presentation( $upgrader_error ); |
| 380 | } |
| 381 | |
| 382 | /** |
| 383 | * Return a WordPress error only when it contains a real error code. |
| 384 | * |
| 385 | * Some upgrader skins receive an empty `WP_Error` container during their |
| 386 | * lifecycle. Treating that container as a failed installation produces a |
| 387 | * false error after files were updated successfully. |
| 388 | * |
| 389 | * @param mixed $candidate Candidate upgrader result or skin error. |
| 390 | * |
| 391 | * @return WP_Error|null Populated WordPress error or null. |
| 392 | */ |
| 393 | private function get_nonempty_wordpress_error( $candidate ) { |
| 394 | if ( ! is_wp_error( $candidate ) || empty( $candidate->get_error_codes() ) ) { |
| 395 | return null; |
| 396 | } |
| 397 | |
| 398 | return $candidate; |
| 399 | } |
| 400 | |
| 401 | /** |
| 402 | * Map a raw upgrader error to a bounded user-facing failure category. |
| 403 | * |
| 404 | * Error codes are used only for categorization. Raw messages and data are not |
| 405 | * exposed to the browser because WordPress or filesystem transports can add |
| 406 | * server paths, remote responses, or credential-related details. |
| 407 | * |
| 408 | * @param WP_Error $upgrader_error Populated WordPress upgrader error. |
| 409 | * |
| 410 | * @return array{error_code:string,message:string,log_message:string} Safe failure presentation. |
| 411 | */ |
| 412 | private function get_safe_translation_error_presentation( $upgrader_error ) { |
| 413 | $upgrader_error_code = sanitize_key( (string) $upgrader_error->get_error_code() ); |
| 414 | $network_error_codes = array( |
| 415 | 'download_failed', |
| 416 | 'http_404', |
| 417 | 'http_no_file', |
| 418 | 'http_no_url', |
| 419 | 'http_request_failed', |
| 420 | 'md5_mismatch', |
| 421 | 'signature_verification_failed', |
| 422 | ); |
| 423 | $archive_error_codes = array( |
| 424 | 'copy_failed', |
| 425 | 'disk_full_unzip_file', |
| 426 | 'empty_archive', |
| 427 | 'incompatible_archive', |
| 428 | 'mkdir_failed', |
| 429 | ); |
| 430 | |
| 431 | if ( in_array( $upgrader_error_code, $network_error_codes, true ) ) { |
| 432 | return array( |
| 433 | 'error_code' => 'wpbc_setup_wizard_translation_network_failed', |
| 434 | 'message' => __( 'WordPress could not download the Local translation archive. Check the site connection to wpbookingcalendar.com, then try again.', 'booking' ), |
| 435 | 'log_message' => __( 'The Local translation archive download did not complete.', 'booking' ), |
| 436 | ); |
| 437 | } |
| 438 | |
| 439 | if ( in_array( $upgrader_error_code, $archive_error_codes, true ) ) { |
| 440 | return array( |
| 441 | 'error_code' => 'wpbc_setup_wizard_translation_archive_failed', |
| 442 | 'message' => __( 'WordPress downloaded the Local translation archive but could not unpack or install it. Check filesystem access, then try again.', 'booking' ), |
| 443 | 'log_message' => __( 'The Local translation archive could not be unpacked or copied into the plugin languages directory.', 'booking' ), |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | return array( |
| 448 | 'error_code' => 'wpbc_setup_wizard_translation_download_failed', |
| 449 | 'message' => __( 'Booking Calendar could not update the Local translation. Check network and filesystem access, then try again.', 'booking' ), |
| 450 | 'log_message' => __( 'WordPress reported an error while updating the Local translation archive.', 'booking' ), |
| 451 | ); |
| 452 | } |
| 453 | |
| 454 | /** |
| 455 | * Discard installer output without closing an output buffer it already ended. |
| 456 | * |
| 457 | * WordPress upgrader skins can alter the output-buffer stack while installing |
| 458 | * an archive. Restoring only removable buffers above the caller's initial |
| 459 | * level prevents upgrader markup and notices from corrupting the AJAX JSON. |
| 460 | * |
| 461 | * @param int $output_buffer_level Output-buffer level before capture started. |
| 462 | * @return void |
| 463 | */ |
| 464 | private function discard_output_buffers_above_level( $output_buffer_level ) { |
| 465 | $output_buffer_level = max( 0, (int) $output_buffer_level ); |
| 466 | |
| 467 | while ( ob_get_level() > $output_buffer_level ) { |
| 468 | $output_buffer_status = ob_get_status(); |
| 469 | |
| 470 | if ( |
| 471 | ! is_array( $output_buffer_status ) |
| 472 | || ! isset( $output_buffer_status['flags'] ) |
| 473 | || 0 === ( (int) $output_buffer_status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE ) |
| 474 | ) { |
| 475 | break; |
| 476 | } |
| 477 | |
| 478 | ob_end_clean(); |
| 479 | } |
| 480 | } |
| 481 | |
| 482 | /** |
| 483 | * Create one safe operation-console record. |
| 484 | * |
| 485 | * @param string $level One of info, success, warning, or error. |
| 486 | * @param string $message Translated operation message. |
| 487 | * |
| 488 | * @return array{level:string,message:string} JSON-safe console record. |
| 489 | */ |
| 490 | private function create_operation_log_entry( $level, $message ) { |
| 491 | $allowed_levels = array( 'info', 'success', 'warning', 'error' ); |
| 492 | |
| 493 | return array( |
| 494 | 'level' => in_array( $level, $allowed_levels, true ) ? $level : 'info', |
| 495 | 'message' => sanitize_text_field( $message ), |
| 496 | ); |
| 497 | } |
| 498 | |
| 499 | /** |
| 500 | * Create a bounded installation error with safe operation-console records. |
| 501 | * |
| 502 | * Raw upgrader errors may contain filesystem details, so only the controlled |
| 503 | * log records created by this service are returned to the browser. |
| 504 | * |
| 505 | * @param string $error_code Stable error identifier. |
| 506 | * @param string $error_message Translated user-facing error. |
| 507 | * @param array<int,array{level:string,message:string}> $operation_log Safe console records. |
| 508 | * |
| 509 | * @return WP_Error Installation error with JSON-safe log data. |
| 510 | */ |
| 511 | private function create_operation_error( $error_code, $error_message, array $operation_log ) { |
| 512 | return new WP_Error( |
| 513 | $error_code, |
| 514 | $error_message, |
| 515 | array( |
| 516 | 'logs' => $operation_log, |
| 517 | ) |
| 518 | ); |
| 519 | } |
| 520 | |
| 521 | /** |
| 522 | * Return the fixed date-format allow-list. |
| 523 | * |
| 524 | * @return string[] Allowed WordPress date formats. |
| 525 | */ |
| 526 | private function get_date_formats() { |
| 527 | return array( 'j M Y', 'F j, Y', 'd/m/Y', 'd.m.Y', 'd-m-Y', 'm/d/Y', 'm.d.Y', 'm-d-Y', 'Y/m/d', 'Y.m.d', 'Y-m-d' ); |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * Return the fixed time-format allow-list. |
| 532 | * |
| 533 | * @return string[] Allowed WordPress time formats. |
| 534 | */ |
| 535 | private function get_time_formats() { |
| 536 | return array( 'g:i a', 'g:i A', 'H:i' ); |
| 537 | } |
| 538 | |
| 539 | /** |
| 540 | * Return date-format options with localized examples. |
| 541 | * |
| 542 | * @return array<int,array{value:string,label:string}> Date format records. |
| 543 | */ |
| 544 | private function get_date_format_options() { |
| 545 | $options = array(); |
| 546 | $sample_timestamp = current_time( 'timestamp' ); |
| 547 | |
| 548 | foreach ( $this->get_date_formats() as $date_format ) { |
| 549 | $options[] = array( |
| 550 | 'value' => $date_format, |
| 551 | 'label' => date_i18n( $date_format, $sample_timestamp ), |
| 552 | ); |
| 553 | } |
| 554 | |
| 555 | return $options; |
| 556 | } |
| 557 | |
| 558 | /** |
| 559 | * Return time-format options with localized examples. |
| 560 | * |
| 561 | * @return array<int,array{value:string,label:string}> Time format records. |
| 562 | */ |
| 563 | private function get_time_format_options() { |
| 564 | $options = array(); |
| 565 | $sample_timestamp = current_time( 'timestamp' ); |
| 566 | |
| 567 | foreach ( $this->get_time_formats() as $time_format ) { |
| 568 | $options[] = array( |
| 569 | 'value' => $time_format, |
| 570 | 'label' => date_i18n( $time_format, $sample_timestamp ), |
| 571 | ); |
| 572 | } |
| 573 | |
| 574 | return $options; |
| 575 | } |
| 576 | |
| 577 | /** |
| 578 | * Return localized weekday choices. |
| 579 | * |
| 580 | * @return array<string,string> Weekday labels keyed by numeric string. |
| 581 | */ |
| 582 | private function get_week_start_options() { |
| 583 | return array( |
| 584 | '0' => __( 'Sunday', 'booking' ), |
| 585 | '1' => __( 'Monday', 'booking' ), |
| 586 | '2' => __( 'Tuesday', 'booking' ), |
| 587 | '3' => __( 'Wednesday', 'booking' ), |
| 588 | '4' => __( 'Thursday', 'booking' ), |
| 589 | '5' => __( 'Friday', 'booking' ), |
| 590 | '6' => __( 'Saturday', 'booking' ), |
| 591 | ); |
| 592 | } |
| 593 | |
| 594 | /** |
| 595 | * Read a WPBC setting with a WordPress and fixed fallback. |
| 596 | * |
| 597 | * @param string $wpbc_option_name WPBC option name. |
| 598 | * @param string $wp_option_name WordPress option name, or empty string. |
| 599 | * @param string[] $allowed_values Accepted canonical values. |
| 600 | * @param string $fallback_value Final source-supported default. |
| 601 | * |
| 602 | * @return string Allowed initial value. |
| 603 | */ |
| 604 | private function get_initial_choice( $wpbc_option_name, $wp_option_name, array $allowed_values, $fallback_value ) { |
| 605 | $wpbc_value = get_bk_option( $wpbc_option_name ); |
| 606 | if ( is_scalar( $wpbc_value ) && in_array( (string) $wpbc_value, $allowed_values, true ) ) { |
| 607 | return (string) $wpbc_value; |
| 608 | } |
| 609 | |
| 610 | if ( '' !== $wp_option_name ) { |
| 611 | $wp_value = get_option( $wp_option_name, '' ); |
| 612 | if ( is_scalar( $wp_value ) && in_array( (string) $wp_value, $allowed_values, true ) ) { |
| 613 | return (string) $wp_value; |
| 614 | } |
| 615 | } |
| 616 | |
| 617 | return $fallback_value; |
| 618 | } |
| 619 | |
| 620 | /** |
| 621 | * Read the WPBC week-start option with a WordPress fallback. |
| 622 | * |
| 623 | * @return string Numeric weekday value from zero through six. |
| 624 | */ |
| 625 | private function get_initial_week_start() { |
| 626 | $allowed_values = array_map( 'strval', array_keys( $this->get_week_start_options() ) ); |
| 627 | $wpbc_value = get_bk_option( 'booking_start_day_weeek' ); |
| 628 | |
| 629 | if ( is_scalar( $wpbc_value ) && in_array( (string) $wpbc_value, $allowed_values, true ) ) { |
| 630 | return (string) $wpbc_value; |
| 631 | } |
| 632 | |
| 633 | $wp_value = get_option( 'start_of_week', 0 ); |
| 634 | |
| 635 | return in_array( (string) $wp_value, $allowed_values, true ) ? (string) $wp_value : '0'; |
| 636 | } |
| 637 | |
| 638 | /** |
| 639 | * Return the website locale without accepting an AJAX locale override. |
| 640 | * |
| 641 | * @return string Valid locale identifier. |
| 642 | */ |
| 643 | private function get_site_locale() { |
| 644 | $site_locale = get_locale(); |
| 645 | $is_valid = function_exists( 'wpbc_validate_request_locale' ) |
| 646 | ? wpbc_validate_request_locale( $site_locale ) |
| 647 | : ( is_string( $site_locale ) && 1 === preg_match( '/\A[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*\z/D', $site_locale ) ); |
| 648 | |
| 649 | return $is_valid ? (string) $site_locale : 'en_US'; |
| 650 | } |
| 651 | |
| 652 | /** |
| 653 | * Find the exact or two-letter Local translation used by the WPBC loader. |
| 654 | * |
| 655 | * @param string $site_locale Valid website locale. |
| 656 | * |
| 657 | * @return string Matching bundled locale, or an empty string. |
| 658 | */ |
| 659 | private function find_local_translation_locale( $site_locale ) { |
| 660 | $language_directory = trailingslashit( WPBC_PLUGIN_DIR ) . 'languages'; |
| 661 | $available_locales = function_exists( 'get_available_languages' ) |
| 662 | ? get_available_languages( $language_directory ) |
| 663 | : array(); |
| 664 | |
| 665 | return $this->resolve_local_translation_locale( $site_locale, $available_locales ); |
| 666 | } |
| 667 | |
| 668 | /** |
| 669 | * Return the installed Local translation file date and freshness state. |
| 670 | * |
| 671 | * The locale is first resolved from WordPress' own language-file discovery, |
| 672 | * then constrained again before it is used to construct a path. Both legacy |
| 673 | * MO files and WordPress 6.5+ PHP translation files are supported. The newest |
| 674 | * matching file timestamp represents the installed archive state; no remote |
| 675 | * metadata or browser-supplied path is consulted. |
| 676 | * |
| 677 | * @param string $local_locale Server-resolved Local translation locale. |
| 678 | * |
| 679 | * @return array{updated_timestamp:int,updated_date:string,updated_label:string,is_update_recommended:bool,update_recommendation:string} Translation freshness record. |
| 680 | */ |
| 681 | private function get_local_translation_freshness( $local_locale ) { |
| 682 | $freshness = array( |
| 683 | 'updated_timestamp' => 0, |
| 684 | 'updated_date' => '', |
| 685 | 'updated_label' => '', |
| 686 | 'is_update_recommended' => false, |
| 687 | 'update_recommendation' => '', |
| 688 | ); |
| 689 | |
| 690 | if ( ! is_string( $local_locale ) || 1 !== preg_match( '/\A[A-Za-z0-9]+(?:[_-][A-Za-z0-9]+)*\z/D', $local_locale ) ) { |
| 691 | return $freshness; |
| 692 | } |
| 693 | |
| 694 | $language_directory = trailingslashit( WPBC_PLUGIN_DIR ) . 'languages/'; |
| 695 | $translation_files = array( |
| 696 | $language_directory . 'booking-' . $local_locale . '.mo', |
| 697 | $language_directory . 'booking-' . $local_locale . '.l10n.php', |
| 698 | ); |
| 699 | $updated_timestamp = 0; |
| 700 | |
| 701 | foreach ( $translation_files as $translation_file ) { |
| 702 | clearstatcache( true, $translation_file ); |
| 703 | if ( ! is_file( $translation_file ) || ! is_readable( $translation_file ) ) { |
| 704 | continue; |
| 705 | } |
| 706 | |
| 707 | $file_timestamp = filemtime( $translation_file ); |
| 708 | if ( false !== $file_timestamp ) { |
| 709 | $updated_timestamp = max( $updated_timestamp, absint( $file_timestamp ) ); |
| 710 | } |
| 711 | } |
| 712 | |
| 713 | if ( $updated_timestamp <= 0 ) { |
| 714 | return $freshness; |
| 715 | } |
| 716 | |
| 717 | $date_format = get_option( 'date_format', 'F j, Y' ); |
| 718 | $date_format = is_scalar( $date_format ) && '' !== trim( (string) $date_format ) ? (string) $date_format : 'F j, Y'; |
| 719 | $updated_date = function_exists( 'wp_date' ) |
| 720 | ? wp_date( $date_format, $updated_timestamp ) |
| 721 | : date_i18n( $date_format, $updated_timestamp ); |
| 722 | $is_stale = $updated_timestamp < ( time() - MONTH_IN_SECONDS ); |
| 723 | |
| 724 | $freshness['updated_timestamp'] = $updated_timestamp; |
| 725 | $freshness['updated_date'] = $updated_date; |
| 726 | $freshness['updated_label'] = sprintf( |
| 727 | /* translators: %s: Installed Local translation file date. */ |
| 728 | __( 'Installed Local translation updated: %s', 'booking' ), |
| 729 | $updated_date |
| 730 | ); |
| 731 | $freshness['is_update_recommended'] = $is_stale; |
| 732 | $freshness['update_recommendation'] = $is_stale |
| 733 | ? __( 'This Local translation is more than one month old. Update it to get the latest translations.', 'booking' ) |
| 734 | : ''; |
| 735 | |
| 736 | return $freshness; |
| 737 | } |
| 738 | |
| 739 | /** |
| 740 | * Invalidate WordPress' cached file list for the WPBC language directory. |
| 741 | * |
| 742 | * WordPress 6.5 and newer cache translation-file discovery by directory for |
| 743 | * one hour. The WPBC archive installer writes to its own language directory, |
| 744 | * so the core upgrader invalidation for wp-content/languages/plugins does not |
| 745 | * clear this entry. Removing the exact cache key lets the success response |
| 746 | * verify the newly installed locale in the same request. The cache deletion |
| 747 | * is harmless on older WordPress versions that do not use this cache. |
| 748 | * |
| 749 | * @return void |
| 750 | */ |
| 751 | private function invalidate_local_translation_file_cache() { |
| 752 | $language_directory = trailingslashit( WPBC_PLUGIN_DIR ) . 'languages'; |
| 753 | $cache_path = trailingslashit( $language_directory ); |
| 754 | |
| 755 | wp_cache_delete( md5( $cache_path ), 'translation_files' ); |
| 756 | clearstatcache(); |
| 757 | } |
| 758 | |
| 759 | /** |
| 760 | * Return a human-readable locale name without requiring a remote lookup. |
| 761 | * |
| 762 | * @param string $site_locale Valid website locale. |
| 763 | * |
| 764 | * @return string Display label. |
| 765 | */ |
| 766 | private function get_language_name( $site_locale ) { |
| 767 | $language_name = function_exists( 'locale_get_display_name' ) ? locale_get_display_name( $site_locale, $site_locale ) : ''; |
| 768 | $language_name = is_string( $language_name ) ? sanitize_text_field( $language_name ) : ''; |
| 769 | |
| 770 | return '' !== trim( $language_name ) ? $language_name : str_replace( array( '_', '-' ), ' ', $site_locale ); |
| 771 | } |
| 772 | |
| 773 | /** |
| 774 | * Return the canonical Date / Time Formats settings URL. |
| 775 | * |
| 776 | * @return string Server-owned administration URL. |
| 777 | */ |
| 778 | private function get_date_time_settings_url() { |
| 779 | $settings_url = function_exists( 'wpbc_get_settings_url' ) ? wpbc_get_settings_url() : admin_url( 'admin.php?page=wpbc-settings' ); |
| 780 | |
| 781 | return add_query_arg( 'scroll_to_section', 'wpbc_general_settings_datestimes_tab', $settings_url ); |
| 782 | } |
| 783 | |
| 784 | /** |
| 785 | * Return the canonical Admin Panel > Translations settings URL. |
| 786 | * |
| 787 | * @return string Server-owned administration URL. |
| 788 | */ |
| 789 | private function get_translation_settings_url() { |
| 790 | $settings_url = function_exists( 'wpbc_get_settings_url' ) ? wpbc_get_settings_url() : admin_url( 'admin.php?page=wpbc-settings' ); |
| 791 | |
| 792 | return add_query_arg( 'scroll_to_section', 'wpbc_general_settings_translations_tab', $settings_url ); |
| 793 | } |
| 794 | } |
| 795 |