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