PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
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

795 lines 31.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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