Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.80% covered (success)
93.80%
227 / 242
72.41% covered (warning)
72.41%
21 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync
93.80% covered (success)
93.80%
227 / 242
72.41% covered (warning)
72.41%
21 / 29
100.29
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate_audio_result
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
7.05
 skipped_result
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 attempted_result
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 create_audio_once
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 acquire_create_lock
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 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%
34 / 34
100.00% covered (success)
100.00%
1 / 1
7
 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     * 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}