PluginProbe
BeyondWords – AI audio for publishers / trunk
BeyondWords – AI audio for publishers vtrunk
7.1.0 trunk 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.1.0 4.1.1 4.1.2 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.3.0 4.4.0 4.5.0 4.5.1 4.6.0 4.6.1 4.6.2 4.7.0 All 43 releases
speechkit / src / post / class-sync.php

class-sync.php in BeyondWords – AI audio for publishers trunk, at src/post/class-sync.php

845 lines 25.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WordPress ↔ BeyondWords post sync: save/trash/delete handlers and meta registration.
4 *
5 * @package BeyondWords\Post
6 * @since 3.0.0
7 * @since 7.0.0 Renamed from BeyondWords\Core\Core to BeyondWords\Post\Sync.
8 */
9
10 declare( strict_types = 1 );
11
12 namespace BeyondWords\Post;
13
14 defined( 'ABSPATH' ) || exit;
15
16 /**
17 * WordPress post → BeyondWords API sync.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21 class Sync {
22
23 /**
24 * Cron hook that runs deferred audio (re)generation (VIP-only; see is_async_generation_enabled()).
25 *
26 * @since 7.0.0
27 */
28 const GENERATE_AUDIO_CRON_HOOK = 'beyondwords_generate_audio';
29
30 /**
31 * Cron hook that runs a deferred audio deletion (VIP-only).
32 *
33 * Args are the BeyondWords project + content IDs, not a post ID — the post
34 * meta is wiped or the post row gone by the time the job runs.
35 *
36 * @since 7.0.0
37 */
38 const DELETE_AUDIO_CRON_HOOK = 'beyondwords_delete_audio';
39
40 /**
41 * Maximum posts the bulk "Generate audio" action processes synchronously (off VIP).
42 *
43 * Each post is a blocking API call, so the count is capped and the remainder
44 * deferred (the caller surfaces a notice) to stay inside execution limits.
45 * The worst per-post case is a stale content ID — update (default timeout),
46 * recreate (create timeout), then the adoption probe — and a test guards
47 * the product staying within a 60s budget.
48 *
49 * @since 7.0.0
50 */
51 const BULK_GENERATE_SYNC_LIMIT = 4;
52
53 /**
54 * Outcome of one generate attempt: audio requested, nothing to do, or the API call failed.
55 *
56 * @since 7.0.0
57 */
58 const OUTCOME_GENERATED = 'generated';
59 const OUTCOME_SKIPPED = 'skipped';
60 const OUTCOME_FAILED = 'failed';
61
62 /**
63 * Post meta key for the per-post "a create is in flight" lock.
64 *
65 * Underscore-prefixed so core protects it: request bookkeeping, not post data.
66 *
67 * @since 7.0.0
68 */
69 const CREATE_LOCK_META_KEY = '_beyondwords_create_lock';
70
71 /**
72 * Seconds after which an unreleased create lock is treated as abandoned.
73 *
74 * Longer than the client's request timeout, so only a dead request's lock is stolen.
75 *
76 * @since 7.0.0
77 */
78 const CREATE_LOCK_TIMEOUT = 30;
79
80 /**
81 * Deprecated post-meta keys still exposed to the block editor over REST.
82 *
83 * On sites upgraded from legacy SpeechKit these can hold a post's only
84 * BeyondWords data, so the editor components read them as a fallback.
85 *
86 * @since 7.0.0
87 *
88 * @var string[]
89 */
90 const REST_LEGACY_META_KEYS = [
91 'beyondwords_podcast_id',
92 'speechkit_generate_audio',
93 'speechkit_project_id',
94 'speechkit_podcast_id',
95 'speechkit_error_message',
96 '_speechkit_link',
97 ];
98
99 /**
100 * REST-exposed post-meta keys that hold secrets or internal data.
101 *
102 * The hide_private_meta_from_rest() filter strips these from every
103 * non-`edit` response so unauthenticated requests never see them.
104 *
105 * @since 7.0.0
106 *
107 * @var string[]
108 */
109 const REST_PRIVATE_META_KEYS = [
110 'beyondwords_error_message',
111 'beyondwords_preview_token',
112 'speechkit_error_message',
113 '_speechkit_link',
114 ];
115
116 /**
117 * Register WordPress hooks.
118 */
119 public static function init(): void {
120 add_action( 'init', [ self::class, 'register_meta' ], 99, 3 );
121 add_action( 'rest_api_init', [ self::class, 'register_rest_meta_visibility' ] );
122
123 add_action( 'wp_after_insert_post', [ self::class, 'on_add_or_update_post' ], 99 );
124 add_action( 'wp_trash_post', [ self::class, 'on_trash_post' ] );
125 add_action( 'before_delete_post', [ self::class, 'on_delete_post' ] );
126
127 // Cron handlers are registered unconditionally so queued events still run
128 // after a config change.
129 add_action( self::GENERATE_AUDIO_CRON_HOOK, [ self::class, 'generate_audio_for_post' ] );
130 add_action( self::DELETE_AUDIO_CRON_HOOK, [ self::class, 'delete_audio_by_ids' ], 10, 2 );
131
132 add_filter( 'is_protected_meta', [ self::class, 'is_protected_meta' ], 10, 2 );
133 add_filter( 'get_post_metadata', [ self::class, 'get_lang_code_from_json_if_empty' ], 10, 3 );
134 }
135
136 /**
137 * Whether a given post status is one BeyondWords processes audio for.
138 */
139 public static function should_process_post_status( string $status ): bool {
140 $statuses = [ 'pending', 'publish', 'private', 'future' ];
141
142 /**
143 * Filters the post statuses BeyondWords processes audio for.
144 *
145 * @since 3.3.3 Introduced as `beyondwords_post_statuses`.
146 * @since 3.7.0 Added `pending` to the defaults.
147 * @since 4.3.0 Renamed to `beyondwords_settings_post_statuses`.
148 *
149 * @param string[] $statuses Post statuses to process.
150 */
151 $statuses = apply_filters( 'beyondwords_settings_post_statuses', $statuses );
152
153 return is_array( $statuses ) && in_array( $status, $statuses, true );
154 }
155
156 /**
157 * Whether to (re)generate audio for a post on save.
158 *
159 * An explicit `beyondwords_generate_audio` meta value always wins; when it
160 * is unset the Preselect setting decides, but only for editor/REST saves so
161 * a programmatic/bulk import never unexpectedly generates audio.
162 */
163 public static function should_generate_audio_for_post( int $post_id ): bool {
164 if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
165 return false;
166 }
167
168 // False when the post was deleted between a deferred job being queued and
169 // the cron firing; bail before the strict-typed status check.
170 $status = get_post_status( $post_id );
171 if ( ! $status ) {
172 return false;
173 }
174
175 if ( ! self::should_process_post_status( $status ) ) {
176 return false;
177 }
178
179 $generate_audio = \BeyondWords\Post\Meta::get_renamed_post_meta( $post_id, 'generate_audio' );
180
181 if ( '1' === $generate_audio ) {
182 return true;
183 }
184
185 if ( '0' === $generate_audio ) {
186 return false;
187 }
188
189 // Unset: honour Preselect, but only on an editor/REST save.
190 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
191 return \BeyondWords\Settings\Preselect::should_preselect_for_post( $post_id );
192 }
193
194 return false;
195 }
196
197 /**
198 * Whether audio (re)generation runs in the background instead of blocking the save.
199 *
200 * VIP-only: Cron Control runs scheduled events reliably there; elsewhere
201 * WP-Cron is traffic-triggered, so we stay synchronous. See doc/async-rest-migration.md.
202 *
203 * @since 7.0.0
204 */
205 public static function is_async_generation_enabled(): bool {
206 // These symbols only exist on WordPress VIP.
207 $enabled = class_exists( '\Automattic\WP\Cron_Control\Main' )
208 || function_exists( 'wpcom_vip_schedule_single_event' )
209 || defined( 'VIP_GO_APP_ENVIRONMENT' );
210
211 /**
212 * Filters whether BeyondWords audio (re)generation runs in the background.
213 *
214 * Defaults to true only on WordPress VIP.
215 *
216 * @since 7.0.0
217 *
218 * @param bool $enabled Whether background (cron) generation is enabled.
219 */
220 return (bool) apply_filters( 'beyondwords_async_generate_audio', $enabled );
221 }
222
223 /**
224 * Generate audio for a post if eligibility checks pass.
225 *
226 * @param int $post_id WordPress post ID.
227 *
228 * @return array<mixed>|false|null Response from the API, or false when audio wasn't generated.
229 */
230 public static function generate_audio_for_post( int $post_id ): array|false|null {
231 return self::generate_audio_result( $post_id )['response'];
232 }
233
234 /**
235 * Generate audio for a post, reporting whether it ran, was skipped, or failed.
236 *
237 * A falsy response on its own can't tell a post that had nothing to do from
238 * one whose API call failed. See doc/async-rest-migration.md.
239 *
240 * @since 7.0.0
241 *
242 * @return array{outcome:string, response:array<mixed>|false|null}
243 */
244 private static function generate_audio_result( int $post_id ): array {
245 if ( ! self::should_generate_audio_for_post( $post_id ) ) {
246 return self::skipped_result();
247 }
248
249 $post = get_post( $post_id );
250 if ( ! $post ) {
251 return self::skipped_result();
252 }
253
254 $integration_method = \BeyondWords\Settings\Fields::get_integration_method( $post );
255
256 // Client-side integration is "Magic Embed": import via the by-source-id endpoint.
257 if ( \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE === $integration_method ) {
258 update_post_meta( $post_id, 'beyondwords_integration_method', \BeyondWords\Settings\Fields::INTEGRATION_CLIENT_SIDE );
259 update_post_meta( $post_id, 'beyondwords_project_id', get_option( 'beyondwords_project_id' ) );
260
261 return self::attempted_result( \BeyondWords\Api\Client::get_player_by_source_id( $post_id ) );
262 }
263
264 update_post_meta( $post_id, 'beyondwords_integration_method', \BeyondWords\Settings\Fields::INTEGRATION_REST_API );
265
266 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id );
267
268 if ( $content_id ) {
269 if ( defined( 'BEYONDWORDS_AUTOREGENERATE' ) && ! BEYONDWORDS_AUTOREGENERATE ) {
270 return self::skipped_result();
271 }
272
273 $response = self::update_or_recreate_audio( $post_id );
274 } else {
275 // Returns early: create_audio_once() stores its own response, under the lock.
276 return self::create_audio_once( $post_id );
277 }
278
279 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
280 self::process_response( $response, $project_id, $post_id );
281
282 return self::attempted_result( $response );
283 }
284
285 /**
286 * Result for a post that needed no work — never counted as a failure.
287 *
288 * @since 7.0.0
289 *
290 * @return array{outcome:string, response:false}
291 */
292 private static function skipped_result(): array {
293 return [
294 'outcome' => self::OUTCOME_SKIPPED,
295 'response' => false,
296 ];
297 }
298
299 /**
300 * Result for an API call we actually made, where a falsy response is the failure signal.
301 *
302 * @since 7.0.0
303 *
304 * @return array{outcome:string, response:array<mixed>|false|null}
305 */
306 private static function attempted_result( array|false|null $response ): array {
307 return [
308 'outcome' => $response ? self::OUTCOME_GENERATED : self::OUTCOME_FAILED,
309 'response' => $response,
310 ];
311 }
312
313 /**
314 * Create audio for a post unless another request is already creating it.
315 *
316 * Stores its own response, so the content ID lands before the lock lifts.
317 * See doc/source-id-race.md.
318 *
319 * @since 7.0.0
320 *
321 * @return array{outcome:string, response:array<mixed>|false|null} Skipped when another request has the create covered.
322 */
323 private static function create_audio_once( int $post_id ): array {
324 if ( ! self::acquire_create_lock( $post_id ) ) {
325 return self::skipped_result();
326 }
327
328 try {
329 // The winner may have finished while we waited; the meta cache predates it.
330 wp_cache_delete( $post_id, 'post_meta' );
331
332 if ( \BeyondWords\Post\Meta::get_content_id( $post_id ) ) {
333 return self::skipped_result();
334 }
335
336 $response = \BeyondWords\Api\Client::create_audio( $post_id );
337
338 self::process_response( $response, \BeyondWords\Post\Meta::get_project_id( $post_id ), $post_id );
339
340 return self::attempted_result( $response );
341 } finally {
342 delete_post_meta( $post_id, self::CREATE_LOCK_META_KEY );
343 }
344 }
345
346 /**
347 * Take the create lock for a post, stealing one left behind by a dead request.
348 *
349 * See doc/source-id-race.md.
350 *
351 * @since 7.0.0
352 */
353 private static function acquire_create_lock( int $post_id ): bool {
354 $now = time();
355
356 // `$unique` tests for an existing row uncached, so the window is one statement.
357 if ( add_post_meta( $post_id, self::CREATE_LOCK_META_KEY, (string) $now, true ) ) {
358 return true;
359 }
360
361 // A meta cache predating the holder's write would read empty and steal a live lock.
362 wp_cache_delete( $post_id, 'post_meta' );
363
364 $locked_at = (int) get_post_meta( $post_id, self::CREATE_LOCK_META_KEY, true );
365
366 if ( $now - $locked_at < self::CREATE_LOCK_TIMEOUT ) {
367 return false;
368 }
369
370 update_post_meta( $post_id, self::CREATE_LOCK_META_KEY, (string) $now );
371
372 return true;
373 }
374
375 /**
376 * Update audio for a post, recovering from a stale content ID.
377 *
378 * A `#404:…` error meta means the content no longer exists at BeyondWords,
379 * so clear the stale IDs and create fresh content instead.
380 *
381 * @param int $post_id WordPress post ID.
382 */
383 private static function update_or_recreate_audio( int $post_id ): array|null|false {
384 $response = \BeyondWords\Api\Client::update_audio( $post_id );
385
386 $error_message = (string) get_post_meta( $post_id, 'beyondwords_error_message', true );
387
388 if ( str_starts_with( $error_message, '#404:' ) ) {
389 delete_post_meta( $post_id, 'beyondwords_content_id' );
390 delete_post_meta( $post_id, 'beyondwords_podcast_id' );
391 delete_post_meta( $post_id, 'speechkit_podcast_id' );
392
393 $response = \BeyondWords\Api\Client::create_audio( $post_id );
394 }
395
396 return $response;
397 }
398
399 /**
400 * Delete audio for a single post (DELETE /content/:id).
401 */
402 public static function delete_audio_for_post( int $post_id ): array|false|null {
403 return \BeyondWords\Api\Client::delete_audio( $post_id );
404 }
405
406 /**
407 * Bulk-delete audio for multiple posts.
408 *
409 * @param int[] $post_ids
410 */
411 public static function batch_delete_audio_for_posts( array $post_ids ): array|false|null {
412 return \BeyondWords\Api\Client::batch_delete_audio( $post_ids );
413 }
414
415 /**
416 * Dispatch bulk "Generate audio" for a set of posts.
417 *
418 * On VIP each post is queued as a background cron job; off VIP generation
419 * runs inline, capped at BULK_GENERATE_SYNC_LIMIT with the rest deferred.
420 *
421 * @since 7.0.0
422 *
423 * @param int[] $post_ids WordPress post IDs from the bulk selection.
424 *
425 * @return array{generated:int, failed:int, skipped:int, deferred:int} Per-outcome counts.
426 */
427 public static function bulk_generate_audio_for_posts( array $post_ids ): array {
428 $post_ids = array_map( 'intval', $post_ids );
429 $post_ids = array_values( array_unique( array_filter( $post_ids, static fn( int $id ): bool => $id > 0 ) ) );
430 sort( $post_ids );
431
432 // The async cron job reads this flag; off VIP it records intent for the
433 // posts deferred past the cap.
434 foreach ( $post_ids as $post_id ) {
435 update_post_meta( $post_id, 'beyondwords_generate_audio', '1' );
436 }
437
438 if ( self::is_async_generation_enabled() ) {
439 foreach ( $post_ids as $post_id ) {
440 self::schedule_audio_generation( $post_id );
441 }
442
443 return [
444 'generated' => count( $post_ids ),
445 'failed' => 0,
446 'skipped' => 0,
447 'deferred' => 0,
448 ];
449 }
450
451 // Each call is bounded by the create timeout plus the adoption probe, so
452 // the cap is what keeps the batch total inside execution limits.
453 $ordered = self::order_posts_for_bulk_generation( $post_ids );
454 $limit = self::BULK_GENERATE_SYNC_LIMIT;
455 $to_process = array_slice( $ordered, 0, $limit );
456 $deferred = count( $ordered ) - count( $to_process );
457
458 $generated = 0;
459 $failed = 0;
460 $skipped = 0;
461
462 foreach ( $to_process as $post_id ) {
463 $outcome = self::generate_audio_result( $post_id )['outcome'];
464
465 if ( self::OUTCOME_GENERATED === $outcome ) {
466 ++$generated;
467 } elseif ( self::OUTCOME_SKIPPED === $outcome ) {
468 ++$skipped;
469 } else {
470 ++$failed;
471 }
472 }
473
474 return [
475 'generated' => $generated,
476 'failed' => $failed,
477 'skipped' => $skipped,
478 'deferred' => $deferred,
479 ];
480 }
481
482 /**
483 * Order a bulk selection so posts needing audio precede regenerations.
484 *
485 * Keeps the synchronous cap making forward progress: re-running the action
486 * works through un-generated posts instead of re-updating the same ones.
487 *
488 * @param int[] $post_ids Normalised, sorted post IDs.
489 *
490 * @return int[]
491 */
492 private static function order_posts_for_bulk_generation( array $post_ids ): array {
493 $needs_create = [];
494 $needs_update = [];
495
496 foreach ( $post_ids as $post_id ) {
497 if ( \BeyondWords\Post\Meta::get_content_id( $post_id ) ) {
498 $needs_update[] = $post_id;
499 } else {
500 $needs_create[] = $post_id;
501 }
502 }
503
504 return array_merge( $needs_create, $needs_update );
505 }
506
507 /**
508 * Run a deferred audio deletion queued by the trash/delete handlers.
509 *
510 * @since 7.0.0
511 *
512 * @param int|string $project_id BeyondWords project ID.
513 * @param int|string $content_id BeyondWords content ID.
514 */
515 public static function delete_audio_by_ids( int|string $project_id, int|string $content_id ): array|false|null {
516 return \BeyondWords\Api\Client::delete_audio_by_ids( $project_id, $content_id );
517 }
518
519 /**
520 * Persist relevant fields from a BeyondWords API response into post meta.
521 *
522 * @param mixed $response API response (typically an associative array).
523 * @param int|string|false $project_id BeyondWords project ID.
524 * @param int $post_id WordPress post ID.
525 *
526 * @return mixed The response, unchanged.
527 */
528 public static function process_response( mixed $response, int|string|false $project_id, int $post_id ): mixed {
529 if ( ! is_array( $response ) ) {
530 return $response;
531 }
532
533 if ( $project_id && ! empty( $response['id'] ) ) {
534 update_post_meta( $post_id, 'beyondwords_project_id', $project_id );
535 update_post_meta( $post_id, 'beyondwords_content_id', $response['id'] );
536
537 // Deliberately don't copy `language`/`body_voice_id` back: those keys hold
538 // explicit editor choices, and echoing the API-resolved project defaults
539 // would freeze them. See Content::get_content_params().
540 $copy = [
541 'preview_token' => 'beyondwords_preview_token',
542 ];
543
544 foreach ( $copy as $api_key => $meta_key ) {
545 if ( ! empty( $response[ $api_key ] ) ) {
546 update_post_meta( $post_id, $meta_key, $response[ $api_key ] );
547 }
548 }
549 }
550
551 return $response;
552 }
553
554 /**
555 * Register every BeyondWords post-meta key for REST + auth gating.
556 *
557 * Only keys the block editor reads/writes over REST get `show_in_rest`;
558 * secrets and internal data never reach the REST API.
559 */
560 public static function register_meta(): void {
561 $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
562
563 if ( ! is_array( $post_types ) ) {
564 return;
565 }
566
567 $keys = \BeyondWords\Core\Utils::get_post_meta_keys( 'all' );
568
569 $rest_keys = array_merge(
570 \BeyondWords\Core\Utils::get_post_meta_keys( 'current' ),
571 self::REST_LEGACY_META_KEYS
572 );
573
574 foreach ( $post_types as $post_type ) {
575 foreach ( $keys as $key ) {
576 // Content IDs are interpolated into API URL paths, so REST writes need
577 // the same strict validation as the classic editor.
578 $sanitize_callback = 'beyondwords_content_id' === $key
579 ? [ \BeyondWords\Post\Meta::class, 'sanitize_content_id' ]
580 : 'sanitize_text_field';
581
582 register_meta(
583 'post',
584 $key,
585 [
586 'show_in_rest' => in_array( $key, $rest_keys, true ),
587 'single' => true,
588 'type' => 'string',
589 'default' => '',
590 'object_subtype' => $post_type,
591 'prepare_callback' => 'sanitize_text_field',
592 'sanitize_callback' => $sanitize_callback,
593 'auth_callback' => static fn(): bool => current_user_can( 'edit_posts' ),
594 ]
595 );
596 }
597 }
598 }
599
600 /**
601 * Register the REST filter that hides private BeyondWords meta, per post type.
602 *
603 * @since 7.0.0
604 */
605 public static function register_rest_meta_visibility(): void {
606 $post_types = \BeyondWords\Settings\Utils::get_compatible_post_types();
607
608 if ( ! is_array( $post_types ) ) {
609 return;
610 }
611
612 foreach ( $post_types as $post_type ) {
613 add_filter( "rest_prepare_{$post_type}", [ self::class, 'hide_private_meta_from_rest' ], 10, 3 );
614 }
615 }
616
617 /**
618 * Strip secret/internal BeyondWords meta from non-`edit` REST responses.
619 *
620 * `WP_REST_Meta_Fields` returns `show_in_rest` meta in the public `view`
621 * context with no capability check; the `edit` context is permission-gated.
622 *
623 * @since 7.0.0
624 *
625 * @param \WP_REST_Response $response The response object.
626 * @param \WP_Post $post The post the response is for.
627 * @param \WP_REST_Request $request The request object.
628 *
629 * @return \WP_REST_Response
630 */
631 public static function hide_private_meta_from_rest( $response, $post, $request ) {
632 if ( ! $response instanceof \WP_REST_Response ) {
633 return $response;
634 }
635
636 if ( 'edit' === $request->get_param( 'context' ) ) {
637 return $response;
638 }
639
640 $data = $response->get_data();
641
642 if ( ! is_array( $data ) || empty( $data['meta'] ) || ! is_array( $data['meta'] ) ) {
643 return $response;
644 }
645
646 foreach ( self::REST_PRIVATE_META_KEYS as $key ) {
647 unset( $data['meta'][ $key ] );
648 }
649
650 $response->set_data( $data );
651
652 return $response;
653 }
654
655 /**
656 * Hide BeyondWords meta from the legacy "Custom Fields" panel.
657 *
658 * The panel can break when our meta renders alongside the auto-rendered
659 * controls — https://github.com/WordPress/gutenberg/issues/23078.
660 *
661 * @param bool|null $is_protected Whether the meta is currently flagged protected.
662 * @param string|null $meta_key Meta key being checked. Null is passed by some core paths.
663 */
664 public static function is_protected_meta( $is_protected, $meta_key ): bool {
665 if ( null === $meta_key ) {
666 return (bool) $is_protected;
667 }
668
669 if ( in_array( $meta_key, \BeyondWords\Core\Utils::get_post_meta_keys( 'all' ), true ) ) {
670 return true;
671 }
672
673 return (bool) $is_protected;
674 }
675
676 /**
677 * Schedule a deferred audio-generation cron event for a post.
678 *
679 * No-ops when an event for this post is already queued, so repeated saves
680 * don't stack duplicate jobs.
681 *
682 * @since 7.0.0
683 */
684 private static function schedule_audio_generation( int $post_id ): void {
685 if ( wp_next_scheduled( self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] ) ) {
686 return;
687 }
688
689 if ( function_exists( 'wpcom_vip_schedule_single_event' ) ) {
690 wpcom_vip_schedule_single_event( time(), self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] );
691 return;
692 }
693
694 wp_schedule_single_event( time(), self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] );
695 }
696
697 /**
698 * Schedule a deferred audio-deletion cron event.
699 *
700 * Mirrors `schedule_audio_generation()`, including the duplicate-event guard.
701 *
702 * @since 7.0.0
703 */
704 private static function schedule_audio_deletion( int|string $project_id, int|string $content_id ): void {
705 $args = [ $project_id, $content_id ];
706
707 if ( wp_next_scheduled( self::DELETE_AUDIO_CRON_HOOK, $args ) ) {
708 return;
709 }
710
711 if ( function_exists( 'wpcom_vip_schedule_single_event' ) ) {
712 wpcom_vip_schedule_single_event( time(), self::DELETE_AUDIO_CRON_HOOK, $args );
713 return;
714 }
715
716 wp_schedule_single_event( time(), self::DELETE_AUDIO_CRON_HOOK, $args );
717 }
718
719 /**
720 * Clear any pending audio-generation cron event for a post.
721 *
722 * Runs before the `has_content()` checks in the lifecycle handlers because
723 * a queued post may not have written its content meta yet.
724 *
725 * @since 7.0.0
726 */
727 private static function unschedule_audio_generation( int $post_id ): void {
728 wp_clear_scheduled_hook( self::GENERATE_AUDIO_CRON_HOOK, [ $post_id ] );
729 }
730
731 /**
732 * Delete a post's BeyondWords audio, deferring to background cron on VIP.
733 *
734 * The IDs are captured now because the caller wipes the meta (trash) or
735 * WordPress deletes the row (permanent delete) before a deferred job runs.
736 *
737 * @since 7.0.0
738 */
739 private static function delete_audio_for_post_or_defer( int $post_id ): void {
740 if ( self::is_async_generation_enabled() ) {
741 $project_id = \BeyondWords\Post\Meta::get_project_id( $post_id );
742 $content_id = \BeyondWords\Post\Meta::get_content_id( $post_id, true );
743
744 if ( $project_id && $content_id ) {
745 self::schedule_audio_deletion( $project_id, $content_id );
746 }
747
748 return;
749 }
750
751 self::delete_audio_for_post( $post_id );
752 }
753
754 /**
755 * Trash hook: delete the remote audio, then remove our local metadata.
756 */
757 public static function on_trash_post( $post_id ): void {
758 $post_id = (int) $post_id;
759
760 self::unschedule_audio_generation( $post_id );
761
762 if ( ! \BeyondWords\Post\Meta::has_content( $post_id ) ) {
763 return;
764 }
765
766 self::delete_audio_for_post_or_defer( $post_id );
767 \BeyondWords\Post\Meta::remove_all_beyondwords_metadata( $post_id );
768 }
769
770 /**
771 * Permanent delete hook: same as trash, minus the meta cleanup.
772 */
773 public static function on_delete_post( $post_id ): void {
774 $post_id = (int) $post_id;
775
776 self::unschedule_audio_generation( $post_id );
777
778 if ( ! \BeyondWords\Post\Meta::has_content( $post_id ) ) {
779 return;
780 }
781
782 self::delete_audio_for_post_or_defer( $post_id );
783 }
784
785 /**
786 * `wp_after_insert_post` hook.
787 *
788 * Skips Gutenberg's second invocation via the meta-box save round-trip,
789 * which would otherwise double-process every save.
790 */
791 public static function on_add_or_update_post( $post_id ): bool {
792 $post_id = (int) $post_id;
793
794 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
795 if ( isset( $_REQUEST['meta-box-loader'] ) && '' !== sanitize_key( wp_unslash( $_REQUEST['meta-box-loader'] ) ) ) {
796 return false;
797 }
798
799 if ( '1' === get_post_meta( $post_id, 'beyondwords_delete_content', true ) ) {
800 self::delete_audio_for_post( $post_id );
801 \BeyondWords\Post\Meta::remove_all_beyondwords_metadata( $post_id );
802 return false;
803 }
804
805 // Eligibility is re-checked inside generate_audio_for_post() when the
806 // deferred job runs.
807 if ( self::is_async_generation_enabled() && self::should_generate_audio_for_post( $post_id ) ) {
808 self::schedule_audio_generation( $post_id );
809
810 return true;
811 }
812
813 return (bool) self::generate_audio_for_post( $post_id );
814 }
815
816 /**
817 * Back-fill `beyondwords_language_code` from the legacy numeric language ID.
818 *
819 * @param mixed $value Existing meta value.
820 * @param int $object_id Post ID.
821 * @param string|null $meta_key Meta key being read.
822 *
823 * @return mixed
824 */
825 public static function get_lang_code_from_json_if_empty( $value, $object_id, $meta_key ): mixed {
826 if ( 'beyondwords_language_code' !== $meta_key || ! empty( $value ) ) {
827 return $value;
828 }
829
830 $language_id = get_post_meta( $object_id, 'beyondwords_language_id', true );
831
832 if ( ! $language_id ) {
833 return $value;
834 }
835
836 $lang_codes = wp_json_file_decode( BEYONDWORDS__PLUGIN_DIR . 'assets/lang-codes.json', [ 'associative' => true ] );
837
838 if ( is_array( $lang_codes ) && array_key_exists( $language_id, $lang_codes ) ) {
839 return [ $lang_codes[ $language_id ] ];
840 }
841
842 return $value;
843 }
844 }
845