PluginProbe
BeyondWords – AI audio for publishers / 7.0.0
BeyondWords – AI audio for publishers v7.0.0
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 7.0.0, at src/post/class-sync.php

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