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-publish-integration / class-wpbc-setup-wizard-publish-integration-save-handler.php

class-wpbc-setup-wizard-publish-integration-save-handler.php in Booking Calendar 11.9, at includes/page-setup-wizard/step-publish-integration/class-wpbc-setup-wizard-publish-integration-save-handler.php

768 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Progressive save handler for Setup Wizard publishing and integration.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Persist one authorized publishing destination with bounded compensation.
14 *
15 * Create-page retries recover the page by an operation marker when the page was
16 * committed but the checkpoint response was lost. Existing pages receive one
17 * marker-bounded block, so later saves replace that block without duplicating
18 * the shortcode or changing unrelated page content.
19 */
20 final class WPBC_Setup_Wizard_Publish_Integration_Save_Handler implements WPBC_Setup_Wizard_Step_Save_Handler {
21
22 /** Meta key containing the site- and Booking Calendar owner-scoped context. */
23 const META_CONTEXT = WPBC_Setup_Wizard_Publish_Integration::META_CONTEXT;
24
25 /** Meta key containing the operation that created a wizard-owned page. */
26 const META_OPERATION = WPBC_Setup_Wizard_Publish_Integration::META_OPERATION;
27
28 /** Meta key containing the last server-owned page-content choice. */
29 const META_CONTENT = WPBC_Setup_Wizard_Publish_Integration::META_CONTENT;
30
31 /** @var WPBC_Setup_Wizard_Publish_Integration */
32 private $publish_integration;
33
34 /**
35 * Build the handler around the publishing domain service.
36 *
37 * @param WPBC_Setup_Wizard_Publish_Integration|null $publish_integration Optional service for tests.
38 */
39 public function __construct( $publish_integration = null ) {
40 $this->publish_integration = $publish_integration instanceof WPBC_Setup_Wizard_Publish_Integration
41 ? $publish_integration
42 : new WPBC_Setup_Wizard_Publish_Integration();
43 }
44
45 /**
46 * Return the owned step identifier.
47 *
48 * @return string Stable step ID.
49 */
50 public function get_step_id() {
51 return 'publish_integration';
52 }
53
54 /**
55 * Return an operation-specific, server-owned action label.
56 *
57 * @param array<string,mixed> $checkpoint Current checkpoint.
58 * @param array<string,mixed> $step_context Authorized presentation context.
59 *
60 * @return string Translated action label.
61 */
62 public function get_primary_action_label( array $checkpoint, array $step_context = array() ) {
63 $destination = isset( $step_context['values']['publish_destination'] )
64 ? sanitize_key( (string) $step_context['values']['publish_destination'] )
65 : $this->get_checkpoint_destination( $checkpoint );
66
67 if ( WPBC_Setup_Wizard_Publish_Integration::DESTINATION_CREATE_PAGE === $destination ) {
68 return $this->get_saved_created_page_id( $checkpoint )
69 ? __( 'Update page & continue', 'booking' )
70 : __( 'Create page & continue', 'booking' );
71 }
72 if ( WPBC_Setup_Wizard_Publish_Integration::DESTINATION_EXISTING_PAGE === $destination ) {
73 return __( 'Update page & continue', 'booking' );
74 }
75
76 return __( 'Save & continue', 'booking' );
77 }
78
79 /**
80 * Revalidate and persist one publishing choice.
81 *
82 * @param array<string,mixed> $validated_fields Validated current-step fields.
83 * @param array<string,mixed> $checkpoint Current checkpoint.
84 * @param array<string,mixed> $operation_context Stable operation metadata.
85 *
86 * @return array<string,mixed>|WP_Error Verified result or error.
87 */
88 public function save( array $validated_fields, array $checkpoint, array $operation_context ) {
89 if ( ! $this->publish_integration->is_available() ) {
90 return new WP_Error( 'wpbc_setup_wizard_publish_restricted', __( 'Publishing from the Setup Wizard is unavailable on this website.', 'booking' ) );
91 }
92 if ( class_exists( 'WPBC_Setup_Wizard_Access' ) && ! WPBC_Setup_Wizard_Access::current_user_can_access() ) {
93 return new WP_Error( 'wpbc_setup_wizard_publish_forbidden', __( 'You are not allowed to publish from this Setup Wizard.', 'booking' ) );
94 }
95
96 $destination = isset( $validated_fields['publish_destination'] )
97 ? $this->publish_integration->validate_destination( $validated_fields['publish_destination'], true )
98 : new WP_Error( 'wpbc_setup_wizard_publish_destination_required', __( 'Choose how customers will access booking.', 'booking' ) );
99 if ( is_wp_error( $destination ) ) {
100 return $destination;
101 }
102
103 if ( WPBC_Setup_Wizard_Publish_Integration::DESTINATION_LATER === $destination ) {
104 return $this->create_metadata_result(
105 __( 'Publishing deferred. No WordPress page was created or changed.', 'booking' ),
106 array( 'destination' => $destination ),
107 $checkpoint
108 );
109 }
110
111 $content_option = $this->publish_integration->resolve_content_option(
112 $this->get_customer_journey_id( $checkpoint ),
113 $this->get_booking_form_slug( $checkpoint ),
114 isset( $validated_fields['publish_page_content'] ) ? $validated_fields['publish_page_content'] : ''
115 );
116 if ( is_wp_error( $content_option ) ) {
117 return $content_option;
118 }
119
120 if ( WPBC_Setup_Wizard_Publish_Integration::DESTINATION_MANUAL === $destination ) {
121 return $this->create_metadata_result(
122 __( 'Shortcode saved for manual placement. No WordPress page was created or changed.', 'booking' ),
123 array(
124 'destination' => $destination,
125 'content_id' => $content_option['id'],
126 'shortcode' => $content_option['shortcode'],
127 ),
128 $checkpoint
129 );
130 }
131
132 $operation_id = isset( $operation_context['operation_id'] ) ? sanitize_key( (string) $operation_context['operation_id'] ) : '';
133 if ( '' === $operation_id ) {
134 return new WP_Error( 'wpbc_setup_wizard_publish_operation_missing', __( 'The publishing operation is invalid. Reload the page and try again.', 'booking' ) );
135 }
136
137 if ( WPBC_Setup_Wizard_Publish_Integration::DESTINATION_CREATE_PAGE === $destination ) {
138 $page_title = isset( $validated_fields['publish_page_title'] )
139 ? $this->publish_integration->validate_page_title( $validated_fields['publish_page_title'], true )
140 : new WP_Error( 'wpbc_setup_wizard_publish_title_required', __( 'Enter a page title.', 'booking' ) );
141 if ( is_wp_error( $page_title ) ) {
142 return $page_title;
143 }
144
145 return $this->save_created_page( $page_title, $content_option, $checkpoint, $operation_id );
146 }
147
148 $page_id = isset( $validated_fields['publish_existing_page_id'] )
149 ? $this->publish_integration->validate_page_id( $validated_fields['publish_existing_page_id'] )
150 : new WP_Error( 'wpbc_setup_wizard_publish_page_invalid', __( 'Choose a valid WordPress page.', 'booking' ) );
151 if ( is_wp_error( $page_id ) ) {
152 return $page_id;
153 }
154
155 return $this->save_existing_page( $page_id, $content_option, $checkpoint );
156 }
157
158 /**
159 * Create or update the one page owned by this wizard context.
160 *
161 * @param string $page_title Validated WordPress page title.
162 * @param array<string,string> $content_option Server-owned content record.
163 * @param array<string,mixed> $checkpoint Current checkpoint.
164 * @param string $operation_id Stable save operation identifier.
165 *
166 * @return array<string,mixed>|WP_Error Verified result or error.
167 */
168 private function save_created_page( $page_title, array $content_option, array $checkpoint, $operation_id ) {
169 if ( ! current_user_can( 'publish_pages' ) ) {
170 return new WP_Error( 'wpbc_setup_wizard_publish_create_forbidden', __( 'Your WordPress role cannot publish pages.', 'booking' ) );
171 }
172
173 $context_fingerprint = $this->get_context_fingerprint();
174 $page_id = $this->get_saved_created_page_id( $checkpoint );
175 $page = null;
176 $recovered_operation = false;
177 if ( $page_id ) {
178 $page = $this->get_owned_page( $page_id, $context_fingerprint );
179 if ( ! $page ) {
180 return new WP_Error( 'wpbc_setup_wizard_publish_page_stale', __( 'The Setup Wizard booking page is missing or is no longer owned by this Booking Calendar account. Review WordPress Pages before continuing.', 'booking' ) );
181 }
182 }
183
184 if ( ! $page ) {
185 $recovered_page = $this->find_page_by_operation( $context_fingerprint, $operation_id );
186 if ( is_wp_error( $recovered_page ) ) {
187 return $recovered_page;
188 }
189 if ( $recovered_page ) {
190 $page = $recovered_page;
191 $page_id = absint( $page->ID );
192 $recovered_operation = true;
193 }
194 }
195
196 $managed_block = $this->publish_integration->build_managed_block( $content_option['shortcode'] );
197 $page_slug = sanitize_title( $page_title );
198 if ( ! $page ) {
199 $page_id = wp_insert_post(
200 array(
201 'post_type' => 'page',
202 'post_status' => 'publish',
203 'post_title' => $page_title,
204 'post_name' => $page_slug,
205 'post_content' => $managed_block,
206 'comment_status' => 'closed',
207 'ping_status' => 'closed',
208 'meta_input' => array(
209 self::META_CONTEXT => $context_fingerprint,
210 self::META_OPERATION => $operation_id,
211 self::META_CONTENT => $content_option['id'],
212 ),
213 ),
214 true
215 );
216 if ( is_wp_error( $page_id ) || ! $page_id ) {
217 return is_wp_error( $page_id ) ? $page_id : new WP_Error( 'wpbc_setup_wizard_publish_page_not_created', __( 'The booking page could not be created.', 'booking' ) );
218 }
219
220 $page = get_post( $page_id );
221 if ( ! $this->verify_created_page( $page, $page_title, $managed_block, $context_fingerprint, $operation_id, $content_option['id'] ) ) {
222 $was_removed = $this->remove_created_page( $page_id );
223 $message = $was_removed
224 ? __( 'The booking page could not be verified, so the incomplete page was removed.', 'booking' )
225 : __( 'The booking page could not be verified or removed completely. Review WordPress Pages before continuing.', 'booking' );
226 return new WP_Error( 'wpbc_setup_wizard_publish_page_not_verified', $message );
227 }
228
229 return $this->create_page_result( $page, $content_option, true, false );
230 }
231
232 if ( ! current_user_can( 'edit_post', $page_id ) ) {
233 return new WP_Error( 'wpbc_setup_wizard_publish_page_forbidden', __( 'You no longer have permission to update the Setup Wizard booking page.', 'booking' ) );
234 }
235
236 $before_image = $this->get_page_before_image( $page );
237 $creation_operation = (string) get_post_meta( $page_id, self::META_OPERATION, true );
238 if ( '' === $creation_operation ) {
239 return new WP_Error( 'wpbc_setup_wizard_publish_page_stale', __( 'The Setup Wizard booking page has lost its creation identity. Review WordPress Pages before continuing.', 'booking' ) );
240 }
241 $desired_meta = array(
242 self::META_CONTEXT => $context_fingerprint,
243 self::META_OPERATION => $creation_operation,
244 self::META_CONTENT => $content_option['id'],
245 );
246 $post_changed = (string) $page->post_title !== (string) $page_title
247 || ! $this->page_slug_matches_title( $page, $page_title )
248 || 'publish' !== (string) $page->post_status
249 || (string) $page->post_content !== $managed_block;
250 $meta_changed = ! $this->page_meta_matches( $page_id, $desired_meta );
251
252 if ( $post_changed ) {
253 $update_result = wp_update_post(
254 array(
255 'ID' => $page_id,
256 'post_title' => $page_title,
257 'post_name' => $page_slug,
258 'post_status' => 'publish',
259 'post_content' => $managed_block,
260 ),
261 true
262 );
263 if ( is_wp_error( $update_result ) || ! $update_result ) {
264 return is_wp_error( $update_result ) ? $update_result : new WP_Error( 'wpbc_setup_wizard_publish_page_not_updated', __( 'The booking page could not be updated.', 'booking' ) );
265 }
266 }
267 if ( $meta_changed ) {
268 $this->write_page_meta( $page_id, $desired_meta );
269 }
270
271 $page = get_post( $page_id );
272 if ( ! $this->verify_created_page( $page, $page_title, $managed_block, $context_fingerprint, $desired_meta[ self::META_OPERATION ], $content_option['id'] ) ) {
273 $was_restored = $this->restore_page( $page_id, $before_image );
274 $message = $was_restored
275 ? __( 'The booking page update could not be verified. Previous values were restored.', 'booking' )
276 : __( 'The booking page update could not be verified or restored completely. Review WordPress Pages before continuing.', 'booking' );
277 return new WP_Error( 'wpbc_setup_wizard_publish_page_update_not_verified', $message );
278 }
279
280 return $this->create_page_result( $page, $content_option, false, $post_changed || $meta_changed || $recovered_operation );
281 }
282
283 /**
284 * Add or replace the managed block on one authorized existing page.
285 *
286 * @param int $page_id Authorized WordPress page ID.
287 * @param array<string,string> $content_option Server-owned content record.
288 * @param array<string,mixed> $checkpoint Current checkpoint containing retained identities.
289 *
290 * @return array<string,mixed>|WP_Error Verified result or error.
291 */
292 private function save_existing_page( $page_id, array $content_option, array $checkpoint ) {
293 $page_id = absint( $page_id );
294 if ( ! current_user_can( 'edit_pages' ) || ! current_user_can( 'edit_post', $page_id ) ) {
295 return new WP_Error( 'wpbc_setup_wizard_publish_existing_forbidden', __( 'You are not allowed to edit the selected WordPress page.', 'booking' ) );
296 }
297 $page = get_post( $page_id );
298 if ( ! $page || 'page' !== $page->post_type || ! in_array( $page->post_status, array( 'draft', 'publish', 'private' ), true ) ) {
299 return new WP_Error( 'wpbc_setup_wizard_publish_existing_stale', __( 'The selected WordPress page is no longer available for publishing.', 'booking' ) );
300 }
301
302 $context_fingerprint = $this->get_context_fingerprint();
303 $stored_context = (string) get_post_meta( $page_id, self::META_CONTEXT, true );
304 $has_managed_block = false !== strpos( (string) $page->post_content, WPBC_Setup_Wizard_Publish_Integration::MANAGED_BLOCK_START );
305 if ( $has_managed_block && '' !== $stored_context && ! hash_equals( $stored_context, $context_fingerprint ) ) {
306 return new WP_Error( 'wpbc_setup_wizard_publish_existing_owned', __( 'This page contains a booking block managed by another Booking Calendar owner. Choose another page or edit it manually.', 'booking' ) );
307 }
308
309 $updated_content = $this->publish_integration->upsert_managed_block( $page->post_content, $content_option['shortcode'] );
310 if ( is_wp_error( $updated_content ) ) {
311 return $updated_content;
312 }
313
314 $before_image = $this->get_page_before_image( $page );
315 $desired_meta = array(
316 self::META_CONTEXT => $context_fingerprint,
317 self::META_CONTENT => $content_option['id'],
318 );
319 $post_changed = (string) $updated_content !== (string) $page->post_content;
320 $meta_changed = ! $this->page_meta_matches( $page_id, $desired_meta );
321 if ( $post_changed ) {
322 $update_result = wp_update_post(
323 array(
324 'ID' => $page_id,
325 'post_content' => $updated_content,
326 ),
327 true
328 );
329 if ( is_wp_error( $update_result ) || ! $update_result ) {
330 return is_wp_error( $update_result ) ? $update_result : new WP_Error( 'wpbc_setup_wizard_publish_existing_not_updated', __( 'The selected WordPress page could not be updated.', 'booking' ) );
331 }
332 }
333 if ( $meta_changed ) {
334 $this->write_page_meta( $page_id, $desired_meta );
335 }
336
337 $verified_page = get_post( $page_id );
338 if ( ! $verified_page || (string) $verified_page->post_content !== (string) $updated_content || ! $this->page_meta_matches( $page_id, $desired_meta ) ) {
339 $was_restored = $this->restore_page( $page_id, $before_image );
340 $message = $was_restored
341 ? __( 'The page update could not be verified. Previous content was restored.', 'booking' )
342 : __( 'The page update could not be verified or restored completely. Review the selected WordPress page before continuing.', 'booking' );
343 return new WP_Error( 'wpbc_setup_wizard_publish_existing_not_verified', $message );
344 }
345
346 $updated_ids = array( 'wp_page_existing:' . $page_id );
347 $retained_page_id = $this->get_saved_created_page_id( $checkpoint );
348 if ( $retained_page_id ) {
349 $updated_ids[] = 'wp_page_created:' . $retained_page_id;
350 }
351
352 return array(
353 'status' => 'saved',
354 'summary' => $post_changed || $meta_changed
355 ? __( 'Booking Calendar content added to the selected WordPress page.', 'booking' )
356 : __( 'The selected WordPress page already contains the current Booking Calendar content.', 'booking' ),
357 'canonical_fingerprint' => $this->get_page_fingerprint( $verified_page, $content_option ),
358 'created_ids' => array(),
359 'updated_ids' => $updated_ids,
360 'warnings' => array(),
361 'writes_canonical_settings' => true,
362 );
363 }
364
365 /**
366 * Create one checkpoint-only result for Manual and Decide later choices.
367 *
368 * A prior wizard-created page mapping is retained without mutating that page.
369 * This prevents a later return to Create page from producing a duplicate.
370 *
371 * @param string $summary Translated user-facing summary.
372 * @param array<string,mixed> $metadata Normalized publishing metadata.
373 * @param array<string,mixed> $checkpoint Current checkpoint containing retained identities.
374 *
375 * @return array<string,mixed> Save result.
376 */
377 private function create_metadata_result( $summary, array $metadata, array $checkpoint ) {
378 $retained_page_id = $this->get_saved_created_page_id( $checkpoint );
379
380 return array(
381 'status' => 'saved',
382 'summary' => $summary,
383 'canonical_fingerprint' => hash( 'sha256', wp_json_encode( $metadata ) ),
384 'created_ids' => array(),
385 'updated_ids' => $retained_page_id ? array( 'wp_page_created:' . $retained_page_id ) : array(),
386 'warnings' => array(),
387 'writes_canonical_settings' => false,
388 );
389 }
390
391 /**
392 * Create the normalized result for a verified wizard-owned page.
393 *
394 * @param WP_Post|object $page Verified WordPress page.
395 * @param array<string,string> $content_option Server-owned content record.
396 * @param bool $was_created Whether this request created the page.
397 * @param bool $was_updated Whether this request changed or recovered it.
398 *
399 * @return array<string,mixed> Save result.
400 */
401 private function create_page_result( $page, array $content_option, $was_created, $was_updated ) {
402 $page_id = absint( $page->ID );
403 if ( $was_created ) {
404 $summary = __( 'Booking page created and published.', 'booking' );
405 } elseif ( $was_updated ) {
406 $summary = __( 'Booking page updated and published.', 'booking' );
407 } else {
408 $summary = __( 'The booking page is already up to date.', 'booking' );
409 }
410
411 return array(
412 'status' => 'saved',
413 'summary' => $summary,
414 'canonical_fingerprint' => $this->get_page_fingerprint( $page, $content_option ),
415 'created_ids' => $was_created ? array( 'wp_page_created:' . $page_id ) : array(),
416 'updated_ids' => $was_created ? array() : array( 'wp_page_created:' . $page_id ),
417 'warnings' => array(),
418 'writes_canonical_settings' => true,
419 );
420 }
421
422 /**
423 * Verify exact canonical values for one wizard-owned page.
424 *
425 * @param mixed $page Candidate WP_Post object.
426 * @param string $page_title Expected title.
427 * @param string $managed_block Expected page content.
428 * @param string $context_fingerprint Expected wizard context.
429 * @param string $operation_id Expected creation operation.
430 * @param string $content_id Expected page-content ID.
431 *
432 * @return bool True when every owned value matches.
433 */
434 private function verify_created_page( $page, $page_title, $managed_block, $context_fingerprint, $operation_id, $content_id ) {
435 if ( ! is_object( $page ) || empty( $page->ID ) || 'page' !== $page->post_type ) {
436 return false;
437 }
438
439 return 'publish' === (string) $page->post_status
440 && (string) $page_title === (string) $page->post_title
441 && $this->page_slug_matches_title( $page, $page_title )
442 && (string) $managed_block === (string) $page->post_content
443 && $this->page_meta_matches(
444 absint( $page->ID ),
445 array(
446 self::META_CONTEXT => $context_fingerprint,
447 self::META_OPERATION => $operation_id,
448 self::META_CONTENT => $content_id,
449 )
450 );
451 }
452
453 /**
454 * Determine whether WordPress kept the page slug synchronized with its title.
455 *
456 * WordPress may append a numeric suffix when another page already owns the
457 * requested slug. That unique variant remains a correct title-derived URL and
458 * must not make a verified publishing operation fail or retry indefinitely.
459 *
460 * @param WP_Post|object $page Candidate WordPress page.
461 * @param string $page_title Expected page title.
462 *
463 * @return bool True for the requested slug or a WordPress numeric unique suffix.
464 */
465 private function page_slug_matches_title( $page, $page_title ) {
466 if ( ! is_object( $page ) || ! isset( $page->post_name ) ) {
467 return false;
468 }
469
470 $expected_slug = sanitize_title( (string) $page_title );
471 $actual_slug = sanitize_title( (string) $page->post_name );
472 if ( '' === $expected_slug ) {
473 return '' !== $actual_slug;
474 }
475
476 return $expected_slug === $actual_slug
477 || 1 === preg_match( '/^' . preg_quote( $expected_slug, '/' ) . '-[0-9]+$/', $actual_slug );
478 }
479
480 /**
481 * Return an owner- and site-scoped context fingerprint.
482 *
483 * The real administrator ID is intentionally excluded so another authorized
484 * administrator for the same Booking Calendar owner can continue the setup.
485 *
486 * @return string SHA-256 context fingerprint.
487 */
488 private function get_context_fingerprint() {
489 return $this->publish_integration->get_context_fingerprint();
490 }
491
492 /**
493 * Resolve a retained create-page mapping from an earlier successful save.
494 *
495 * @param array<string,mixed> $checkpoint Current checkpoint.
496 *
497 * @return int Mapped WordPress page ID, or zero.
498 */
499 private function get_saved_created_page_id( array $checkpoint ) {
500 $step_result = isset( $checkpoint['step_results']['publish_integration'] ) && is_array( $checkpoint['step_results']['publish_integration'] )
501 ? $checkpoint['step_results']['publish_integration']
502 : array();
503 $identifiers = array_merge(
504 isset( $step_result['created_ids'] ) ? (array) $step_result['created_ids'] : array(),
505 isset( $step_result['updated_ids'] ) ? (array) $step_result['updated_ids'] : array()
506 );
507 foreach ( $identifiers as $identifier ) {
508 if ( is_scalar( $identifier ) && preg_match( '/^wp_page_created:([0-9]+)$/', (string) $identifier, $matches ) ) {
509 return absint( $matches[1] );
510 }
511 }
512
513 return 0;
514 }
515
516 /**
517 * Return one mapped page only when it remains owned by this context.
518 *
519 * @param int $page_id Candidate WordPress page ID.
520 * @param string $context_fingerprint Expected owner/site fingerprint.
521 *
522 * @return WP_Post|object|null Owned page or null.
523 */
524 private function get_owned_page( $page_id, $context_fingerprint ) {
525 $page = get_post( absint( $page_id ) );
526 if ( ! $page || 'page' !== $page->post_type ) {
527 return null;
528 }
529
530 $stored_context = (string) get_post_meta( $page->ID, self::META_CONTEXT, true );
531
532 return '' !== $stored_context && hash_equals( $stored_context, $context_fingerprint ) ? $page : null;
533 }
534
535 /**
536 * Recover a page created by the same operation before checkpoint commit.
537 *
538 * @param string $context_fingerprint Expected owner/site fingerprint.
539 * @param string $operation_id Stable operation identifier.
540 *
541 * @return WP_Post|object|null|WP_Error Recovered page, null, or duplicate error.
542 */
543 private function find_page_by_operation( $context_fingerprint, $operation_id ) {
544 $page_ids = get_posts(
545 array(
546 'post_type' => 'page',
547 'post_status' => 'any',
548 'posts_per_page' => 2,
549 'fields' => 'ids',
550 'no_found_rows' => true,
551 'meta_query' => array(
552 'relation' => 'AND',
553 array(
554 'key' => self::META_CONTEXT,
555 'value' => $context_fingerprint,
556 ),
557 array(
558 'key' => self::META_OPERATION,
559 'value' => $operation_id,
560 ),
561 ),
562 )
563 );
564 if ( 1 < count( $page_ids ) ) {
565 return new WP_Error( 'wpbc_setup_wizard_publish_operation_ambiguous', __( 'More than one page is linked to this publishing operation. Review WordPress Pages before continuing.', 'booking' ) );
566 }
567
568 return empty( $page_ids ) ? null : $this->get_owned_page( absint( reset( $page_ids ) ), $context_fingerprint );
569 }
570
571 /**
572 * Capture the bounded page fields and metadata this handler may change.
573 *
574 * @param WP_Post|object $page Current WordPress page.
575 *
576 * @return array<string,mixed> Canonical before-image.
577 */
578 private function get_page_before_image( $page ) {
579 $meta_before = array();
580 foreach ( array( self::META_CONTEXT, self::META_OPERATION, self::META_CONTENT ) as $meta_key ) {
581 $meta_before[ $meta_key ] = array(
582 'exists' => metadata_exists( 'post', $page->ID, $meta_key ),
583 'value' => get_post_meta( $page->ID, $meta_key, true ),
584 );
585 }
586
587 return array(
588 'post_title' => (string) $page->post_title,
589 'post_name' => isset( $page->post_name ) ? (string) $page->post_name : '',
590 'post_status' => (string) $page->post_status,
591 'post_content' => (string) $page->post_content,
592 'meta' => $meta_before,
593 );
594 }
595
596 /**
597 * Restore the page fields and metadata owned by this handler.
598 *
599 * @param int $page_id WordPress page ID.
600 * @param array<string,mixed> $before_image Canonical before-image.
601 *
602 * @return bool True when the before-image was restored exactly.
603 */
604 private function restore_page( $page_id, array $before_image ) {
605 $restore_result = wp_update_post(
606 array(
607 'ID' => absint( $page_id ),
608 'post_title' => isset( $before_image['post_title'] ) ? $before_image['post_title'] : '',
609 'post_name' => isset( $before_image['post_name'] ) ? $before_image['post_name'] : '',
610 'post_status' => isset( $before_image['post_status'] ) ? $before_image['post_status'] : 'draft',
611 'post_content' => isset( $before_image['post_content'] ) ? $before_image['post_content'] : '',
612 ),
613 true
614 );
615 if ( is_wp_error( $restore_result ) || ! $restore_result ) {
616 return false;
617 }
618
619 foreach ( isset( $before_image['meta'] ) && is_array( $before_image['meta'] ) ? $before_image['meta'] : array() as $meta_key => $meta_before ) {
620 if ( ! empty( $meta_before['exists'] ) ) {
621 update_post_meta( $page_id, $meta_key, $meta_before['value'] );
622 } else {
623 delete_post_meta( $page_id, $meta_key );
624 }
625 }
626
627 $restored_page = get_post( $page_id );
628 if ( ! $restored_page
629 || (string) $restored_page->post_title !== (string) $before_image['post_title']
630 || (string) $restored_page->post_name !== (string) $before_image['post_name']
631 || (string) $restored_page->post_status !== (string) $before_image['post_status']
632 || (string) $restored_page->post_content !== (string) $before_image['post_content']
633 ) {
634 return false;
635 }
636
637 foreach ( $before_image['meta'] as $meta_key => $meta_before ) {
638 if ( ! empty( $meta_before['exists'] ) ) {
639 if ( ! metadata_exists( 'post', $page_id, $meta_key ) || get_post_meta( $page_id, $meta_key, true ) !== $meta_before['value'] ) {
640 return false;
641 }
642 } elseif ( metadata_exists( 'post', $page_id, $meta_key ) ) {
643 return false;
644 }
645 }
646
647 return true;
648 }
649
650 /**
651 * Remove a page created by the current failed operation.
652 *
653 * @param int $page_id WordPress page ID.
654 *
655 * @return bool True when the page no longer exists.
656 */
657 private function remove_created_page( $page_id ) {
658 wp_delete_post( absint( $page_id ), true );
659
660 return null === get_post( absint( $page_id ) );
661 }
662
663 /**
664 * Write a bounded page-meta map.
665 *
666 * @param int $page_id WordPress page ID.
667 * @param array<string,string> $desired_meta Meta values keyed by owned keys.
668 *
669 * @return void
670 */
671 private function write_page_meta( $page_id, array $desired_meta ) {
672 foreach ( $desired_meta as $meta_key => $meta_value ) {
673 update_post_meta( absint( $page_id ), $meta_key, $meta_value );
674 }
675 }
676
677 /**
678 * Compare exact stored meta values with a bounded expected map.
679 *
680 * @param int $page_id WordPress page ID.
681 * @param array<string,string> $desired_meta Expected meta values.
682 *
683 * @return bool True when every value exists and matches.
684 */
685 private function page_meta_matches( $page_id, array $desired_meta ) {
686 foreach ( $desired_meta as $meta_key => $meta_value ) {
687 if ( ! metadata_exists( 'post', absint( $page_id ), $meta_key ) || (string) get_post_meta( $page_id, $meta_key, true ) !== (string) $meta_value ) {
688 return false;
689 }
690 }
691
692 return true;
693 }
694
695 /**
696 * Hash the canonical publishing state without storing page content in metadata.
697 *
698 * @param WP_Post|object $page Verified page.
699 * @param array<string,string> $content_option Server-owned content record.
700 *
701 * @return string SHA-256 canonical fingerprint.
702 */
703 private function get_page_fingerprint( $page, array $content_option ) {
704 return hash(
705 'sha256',
706 wp_json_encode(
707 array(
708 'page_id' => absint( $page->ID ),
709 'post_title' => (string) $page->post_title,
710 'post_name' => isset( $page->post_name ) ? (string) $page->post_name : '',
711 'post_status' => (string) $page->post_status,
712 'content_hash' => hash( 'sha256', (string) $page->post_content ),
713 'content_id' => (string) $content_option['id'],
714 'shortcode' => (string) $content_option['shortcode'],
715 )
716 )
717 );
718 }
719
720 /**
721 * Read the saved Customer Journey.
722 *
723 * @param array<string,mixed> $checkpoint Current checkpoint.
724 *
725 * @return string Stable journey ID.
726 */
727 private function get_customer_journey_id( array $checkpoint ) {
728 return isset( $checkpoint['values']['customer_journey']['customer_journey'] )
729 ? sanitize_key( (string) $checkpoint['values']['customer_journey']['customer_journey'] )
730 : '';
731 }
732
733 /**
734 * Read the saved Booking Form template slug.
735 *
736 * @param array<string,mixed> $checkpoint Current checkpoint.
737 *
738 * @return string Sanitized form slug.
739 */
740 private function get_booking_form_slug( array $checkpoint ) {
741 return isset( $checkpoint['values']['booking_form_template']['booking_form_template'] )
742 ? sanitize_title( (string) $checkpoint['values']['booking_form_template']['booking_form_template'] )
743 : '';
744 }
745
746 /**
747 * Read the retained destination for action-label fallback.
748 *
749 * @param array<string,mixed> $checkpoint Current checkpoint.
750 *
751 * @return string Sanitized destination ID.
752 */
753 private function get_checkpoint_destination( array $checkpoint ) {
754 return isset( $checkpoint['values']['publish_integration']['publish_destination'] )
755 ? sanitize_key( (string) $checkpoint['values']['publish_integration']['publish_destination'] )
756 : '';
757 }
758
759 /**
760 * Publish & integrate is not the terminal route step.
761 *
762 * @return bool False.
763 */
764 public function is_terminal() {
765 return false;
766 }
767 }
768