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 / booking-resources / class-wpbc-booking-resource-content-repository.php

class-wpbc-booking-resource-content-repository.php in Booking Calendar 11.9, at includes/booking-resources/class-wpbc-booking-resource-content-repository.php

430 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cross-edition content storage for Booking Resources.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Store and resolve editable Booking Resource identity fields.
14 *
15 * Paid editions retain `bookingtypes.title` as the canonical resource title.
16 * Photo and description are edition-neutral and live in one non-autoloaded
17 * option per resource. Booking Calendar Free also stores its implicit
18 * resource title there because the Free edition has no `bookingtypes` table.
19 *
20 * The repository lazily reads the legacy Business Large Search Availability
21 * picture and description when no new value has been saved. Writes mirror
22 * those two values back without changing searchable state, URL, or filters.
23 */
24 class WPBC_Booking_Resource_Content_Repository {
25
26 /**
27 * Prefix for per-resource WordPress options.
28 *
29 * @var string
30 */
31 const OPTION_PREFIX = 'wpbc_booking_resource_content_';
32
33 /**
34 * Return editable content for one Booking Resource.
35 *
36 * @param int $resource_id Booking Resource ID.
37 * @param string $fallback_title Cached title used when no canonical title can be loaded.
38 * @param bool $use_fallback_title Whether to prefer the supplied cached title and avoid a database query.
39 *
40 * @return array{resource_id:int,title:string,description:string,picture_url:string,attachment_id:int} Normalized content.
41 */
42 public function get( $resource_id, $fallback_title = '', $use_fallback_title = false ) {
43 $resource_id = absint( $resource_id );
44 $stored = $this->get_stored_content( $resource_id );
45 $legacy = $this->get_legacy_search_content( $resource_id );
46 $title = $use_fallback_title ? (string) $fallback_title : $this->get_canonical_title( $resource_id );
47
48 if ( '' === $title ) {
49 $title = isset( $stored['title'] ) ? (string) $stored['title'] : (string) $fallback_title;
50 }
51 if ( '' === $title && ! class_exists( 'wpdev_bk_personal' ) ) {
52 $title = __( 'Default Resource', 'booking' );
53 }
54
55 $description = array_key_exists( 'description', $stored )
56 ? (string) $stored['description']
57 : (string) $legacy['description'];
58 if ( '' === $description && ! array_key_exists( 'description', $stored ) && ! class_exists( 'wpdev_bk_personal' ) ) {
59 $description = __( 'A unique calendar for any item that can be booked.', 'booking' );
60 }
61 $picture_url = array_key_exists( 'picture_url', $stored )
62 ? (string) $stored['picture_url']
63 : (string) $legacy['picture_url'];
64 if ( '' === $picture_url && ! array_key_exists( 'picture_url', $stored ) && ! class_exists( 'wpdev_bk_personal' ) ) {
65 $picture_url = wpbc_get_starter_asset_url( 'img/resources/booking-resource.jpg' );
66 }
67
68 return array(
69 'resource_id' => $resource_id,
70 'title' => $title,
71 'description' => $description,
72 'picture_url' => $picture_url,
73 'attachment_id' => isset( $stored['attachment_id'] ) ? absint( $stored['attachment_id'] ) : 0,
74 );
75 }
76
77 /**
78 * Persist title, photo, and description for one authorized resource.
79 *
80 * The caller must verify capability, nonce, and ownership before invoking
81 * this method. The method validates storage-level invariants and refreshes
82 * the established resource cache after a successful paid-title update.
83 *
84 * @param int $resource_id Booking Resource ID.
85 * @param array<string,mixed> $fields Raw title, description, and picture URL values.
86 *
87 * @return array{resource_id:int,title:string,description:string,picture_url:string,attachment_id:int}|WP_Error Saved content or error.
88 */
89 public function save( $resource_id, $fields ) {
90 $resource_id = absint( $resource_id );
91 $title = isset( $fields['title'] ) && is_scalar( $fields['title'] )
92 ? sanitize_text_field( (string) $fields['title'] )
93 : '';
94 $description = isset( $fields['description'] ) && is_scalar( $fields['description'] )
95 ? wp_kses_post( (string) $fields['description'] )
96 : '';
97 $picture_url = isset( $fields['picture_url'] ) && is_scalar( $fields['picture_url'] )
98 ? sanitize_text_field( trim( (string) $fields['picture_url'] ) )
99 : '';
100 $resolved_picture_url = '' !== $picture_url ? esc_url_raw( wpbc_lang( $picture_url ) ) : '';
101
102 $title = function_exists( 'mb_substr' ) ? mb_substr( $title, 0, 200 ) : substr( $title, 0, 200 );
103 $description = function_exists( 'mb_substr' ) ? mb_substr( $description, 0, 2000 ) : substr( $description, 0, 2000 );
104
105 if ( ! $resource_id ) {
106 return new WP_Error( 'wpbc_booking_resource_invalid_id', __( 'The Booking Resource is invalid.', 'booking' ) );
107 }
108 if ( '' === $title ) {
109 return new WP_Error( 'wpbc_booking_resource_empty_title', __( 'Enter a title for this Booking Resource.', 'booking' ) );
110 }
111 if ( '' !== $picture_url && '' === $resolved_picture_url ) {
112 return new WP_Error( 'wpbc_booking_resource_invalid_picture', __( 'Select a valid image for this Booking Resource.', 'booking' ) );
113 }
114
115 $title_saved = $this->save_canonical_title( $resource_id, $title );
116 if ( is_wp_error( $title_saved ) ) {
117 return $title_saved;
118 }
119
120 $stored = $this->save_presentation( $resource_id, $description, $picture_url, false );
121 if ( is_wp_error( $stored ) ) {
122 return $stored;
123 }
124 if ( ! class_exists( 'wpdev_bk_personal' ) ) {
125 $stored['title'] = $title;
126 $updated = update_option( $this->get_option_name( $resource_id ), $stored, false );
127 if ( ! $updated && $this->get_stored_content( $resource_id ) !== $stored ) {
128 return new WP_Error( 'wpbc_booking_resource_title_not_saved', __( 'The Booking Resource title could not be saved.', 'booking' ) );
129 }
130 }
131
132 $this->mirror_legacy_search_content( $resource_id, $description, $picture_url );
133
134 /**
135 * Fires after cross-edition Booking Resource content is saved.
136 *
137 * @param int $resource_id Booking Resource ID.
138 * @param array<string,mixed> $stored Normalized edition-neutral fields.
139 */
140 do_action( 'wpbc_booking_resource_content_saved', $resource_id, $stored );
141
142 return $this->get( $resource_id );
143 }
144
145 /**
146 * Save only photo and description without changing the canonical title.
147 *
148 * This method keeps the legacy Business Large Searchable Resources screen
149 * synchronized with the cross-edition content source. Callers may suppress
150 * legacy mirroring when the legacy option is already the active save path.
151 *
152 * @param int $resource_id Booking Resource ID.
153 * @param string $description Raw description.
154 * @param string $picture_url Raw image URL, optionally containing WPBC language markers.
155 * @param bool $mirror_legacy Whether to mirror the saved values into Business Large search options.
156 *
157 * @return array<string,mixed>|WP_Error Stored record or a validation/storage error.
158 */
159 public function save_presentation( $resource_id, $description, $picture_url, $mirror_legacy = true ) {
160 $resource_id = absint( $resource_id );
161 $description = wp_kses_post( (string) $description );
162 $picture_url = sanitize_text_field( trim( (string) $picture_url ) );
163 $resolved_picture_url = '' !== $picture_url ? esc_url_raw( wpbc_lang( $picture_url ) ) : '';
164 $description = function_exists( 'mb_substr' ) ? mb_substr( $description, 0, 2000 ) : substr( $description, 0, 2000 );
165
166 if ( ! $resource_id ) {
167 return new WP_Error( 'wpbc_booking_resource_invalid_id', __( 'The Booking Resource is invalid.', 'booking' ) );
168 }
169 if ( '' !== $picture_url && '' === $resolved_picture_url ) {
170 return new WP_Error( 'wpbc_booking_resource_invalid_picture', __( 'Select a valid image for this Booking Resource.', 'booking' ) );
171 }
172
173 $option_name = $this->get_option_name( $resource_id );
174 $previous_stored = get_option( $option_name, null );
175 $stored = is_array( $previous_stored ) ? $previous_stored : array();
176 $stored['description'] = $description;
177 $stored['picture_url'] = $picture_url;
178 $stored['attachment_id'] = $resolved_picture_url ? absint( attachment_url_to_postid( $resolved_picture_url ) ) : 0;
179
180 if ( null === $previous_stored ) {
181 $stored_saved = add_option( $option_name, $stored, '', false );
182 } elseif ( $previous_stored === $stored ) {
183 $stored_saved = true;
184 } else {
185 $stored_saved = update_option( $option_name, $stored, false );
186 }
187 if ( ! $stored_saved ) {
188 return new WP_Error( 'wpbc_booking_resource_content_not_saved', __( 'The Booking Resource details could not be saved.', 'booking' ) );
189 }
190 if ( $mirror_legacy ) {
191 $this->mirror_legacy_search_content( $resource_id, $description, $picture_url );
192 }
193
194 return $stored;
195 }
196
197 /**
198 * Save a starter picture only when no picture choice has been recorded.
199 *
200 * An explicitly saved empty picture is a valid user choice and is therefore
201 * preserved. The legacy Searchable Resources picture is also respected.
202 * Setup Wizard uses this method to apply workflow-specific imagery without
203 * replacing customer configuration.
204 *
205 * @param int $resource_id Booking Resource ID.
206 * @param string $picture_url Sanitized starter image URL.
207 *
208 * @return true|WP_Error True when saved or already configured; otherwise a storage error.
209 */
210 public function save_starter_picture_if_unconfigured( $resource_id, $picture_url ) {
211 $resource_id = absint( $resource_id );
212 $picture_url = esc_url_raw( trim( (string) $picture_url ) );
213
214 if ( ! $resource_id ) {
215 return new WP_Error( 'wpbc_invalid_resource_id', __( 'A valid Booking Resource is required.', 'booking' ) );
216 }
217
218 if ( '' === $picture_url ) {
219 return new WP_Error( 'wpbc_invalid_resource_picture', __( 'A valid Booking Resource picture is required.', 'booking' ) );
220 }
221
222 $stored = $this->get_stored_content( $resource_id );
223 $legacy = $this->get_legacy_search_content( $resource_id );
224
225 if ( array_key_exists( 'picture_url', $stored ) || '' !== trim( (string) $legacy['picture_url'] ) ) {
226 return true;
227 }
228
229 $description = array_key_exists( 'description', $stored )
230 ? (string) $stored['description']
231 : (string) $legacy['description'];
232 $saved = $this->save_presentation( $resource_id, $description, $picture_url );
233
234 return is_wp_error( $saved ) ? $saved : true;
235 }
236
237 /**
238 * Delete content options when paid Booking Resources are deleted.
239 *
240 * @param string|int|array<int,int|string> $resource_ids Comma-separated, scalar, or array resource identifiers.
241 *
242 * @return void
243 */
244 public function delete( $resource_ids ) {
245 if ( ! is_array( $resource_ids ) ) {
246 $resource_ids = explode( ',', (string) $resource_ids );
247 }
248
249 foreach ( $resource_ids as $resource_id ) {
250 $resource_id = absint( $resource_id );
251 if ( $resource_id ) {
252 delete_option( $this->get_option_name( $resource_id ) );
253 }
254 }
255 }
256
257 /**
258 * Return the stable option name for one resource.
259 *
260 * @param int $resource_id Booking Resource ID.
261 *
262 * @return string Option name.
263 */
264 private function get_option_name( $resource_id ) {
265 return self::OPTION_PREFIX . absint( $resource_id );
266 }
267
268 /**
269 * Read and normalize one stored option without applying fallbacks.
270 *
271 * @param int $resource_id Booking Resource ID.
272 *
273 * @return array<string,mixed> Stored content.
274 */
275 private function get_stored_content( $resource_id ) {
276 $stored = get_option( $this->get_option_name( $resource_id ), array() );
277
278 return is_array( $stored ) ? $stored : array();
279 }
280
281 /**
282 * Load the raw canonical paid title or the stored Free title.
283 *
284 * @param int $resource_id Booking Resource ID.
285 *
286 * @return string Raw title, including supported WPBC language markers.
287 */
288 private function get_canonical_title( $resource_id ) {
289 if ( ! class_exists( 'wpdev_bk_personal' ) ) {
290 $stored = $this->get_stored_content( $resource_id );
291
292 return isset( $stored['title'] ) ? (string) $stored['title'] : '';
293 }
294
295 global $wpdb;
296
297 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
298 $title = $wpdb->get_var(
299 $wpdb->prepare(
300 "SELECT title FROM {$wpdb->prefix}bookingtypes WHERE booking_type_id = %d",
301 absint( $resource_id )
302 )
303 );
304
305 return is_scalar( $title ) ? wp_strip_all_tags( (string) $title ) : '';
306 }
307
308 /**
309 * Save the canonical title in the edition-appropriate storage.
310 *
311 * Free title storage is completed with the other content fields in `save()`.
312 *
313 * @param int $resource_id Booking Resource ID.
314 * @param string $title Sanitized title.
315 *
316 * @return true|WP_Error True on success or a storage error.
317 */
318 private function save_canonical_title( $resource_id, $title ) {
319 if ( ! class_exists( 'wpdev_bk_personal' ) ) {
320 return true;
321 }
322
323 global $wpdb;
324
325 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
326 $updated = $wpdb->update(
327 $wpdb->prefix . 'bookingtypes',
328 array( 'title' => $title ),
329 array( 'booking_type_id' => absint( $resource_id ) ),
330 array( '%s' ),
331 array( '%d' )
332 );
333
334 if ( false === $updated ) {
335 return new WP_Error( 'wpbc_booking_resource_title_not_saved', __( 'The Booking Resource title could not be saved.', 'booking' ) );
336 }
337 if ( 0 === $updated && '' === $this->get_canonical_title( $resource_id ) ) {
338 return new WP_Error( 'wpbc_booking_resource_not_found', __( 'The Booking Resource no longer exists.', 'booking' ) );
339 }
340
341 make_bk_action( 'wpbc_reinit_booking_resource_cache' );
342
343 return true;
344 }
345
346 /**
347 * Read legacy Business Large presentation fields as a lazy fallback.
348 *
349 * @param int $resource_id Booking Resource ID.
350 *
351 * @return array{description:string,picture_url:string} Legacy values.
352 */
353 private function get_legacy_search_content( $resource_id ) {
354 $legacy_content = array(
355 'description' => '',
356 'picture_url' => '',
357 );
358 if ( ! function_exists( 'wpbc_searchable_resources__get_all_options' ) ) {
359 return $legacy_content;
360 }
361
362 $all_options = (array) wpbc_searchable_resources__get_all_options();
363 if ( empty( $all_options[ $resource_id ] ) || ! is_array( $all_options[ $resource_id ] ) ) {
364 return $legacy_content;
365 }
366
367 $resource_options = $all_options[ $resource_id ];
368 $legacy_content['description'] = isset( $resource_options['description'] ) ? (string) $resource_options['description'] : '';
369 $legacy_content['picture_url'] = isset( $resource_options['picture'] ) ? (string) $resource_options['picture'] : '';
370
371 return $legacy_content;
372 }
373
374 /**
375 * Mirror shared content into the legacy Business Large Search UI option.
376 *
377 * Only the two historically overlapping fields are changed. Searchable
378 * state, result URL, title override, categories, and filters are preserved.
379 *
380 * @param int $resource_id Booking Resource ID.
381 * @param string $description Sanitized description.
382 * @param string $picture_url Sanitized picture URL.
383 *
384 * @return void
385 */
386 private function mirror_legacy_search_content( $resource_id, $description, $picture_url ) {
387 if (
388 ! function_exists( 'wpbc_searchable_resources__get_all_options' )
389 || ! function_exists( 'wpbc_searchable_resources__save_all_options' )
390 ) {
391 return;
392 }
393
394 $all_options = (array) wpbc_searchable_resources__get_all_options();
395 if ( ! isset( $all_options[ $resource_id ] ) || ! is_array( $all_options[ $resource_id ] ) ) {
396 $all_options[ $resource_id ] = array();
397 }
398 $all_options[ $resource_id ]['description'] = $description;
399 $all_options[ $resource_id ]['picture'] = $picture_url;
400 wpbc_searchable_resources__save_all_options( $all_options );
401 }
402 }
403
404 /**
405 * Return the shared Booking Resource content repository.
406 *
407 * @return WPBC_Booking_Resource_Content_Repository Repository instance.
408 */
409 function wpbc_booking_resource_content_repository() {
410 static $repository = null;
411
412 if ( null === $repository ) {
413 $repository = new WPBC_Booking_Resource_Content_Repository();
414 }
415
416 return $repository;
417 }
418
419 /**
420 * Remove cross-edition content after a paid resource deletion.
421 *
422 * @param string|int|array<int,int|string> $resource_ids Deleted resource identifiers.
423 *
424 * @return void
425 */
426 function wpbc_booking_resource_content_delete_on_resource_delete( $resource_ids ) {
427 wpbc_booking_resource_content_repository()->delete( $resource_ids );
428 }
429 add_action( 'wpbc_deleted_booking_resources', 'wpbc_booking_resource_content_delete_on_resource_delete', 20, 1 );
430