Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.82% |
194 / 209 |
|
66.67% |
16 / 24 |
CRAP | |
0.00% |
0 / 1 |
| Sync | |
92.82% |
194 / 209 |
|
66.67% |
16 / 24 |
89.80 | |
0.00% |
0 / 1 |
| init | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| should_process_post_status | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| should_generate_audio_for_post | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
9.02 | |||
| is_async_generation_enabled | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| generate_audio_for_post | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
7.05 | |||
| update_or_recreate_audio | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| delete_audio_for_post | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| batch_delete_audio_for_posts | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| bulk_generate_audio_for_posts | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
6 | |||
| order_posts_for_bulk_generation | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| delete_audio_by_ids | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| process_response | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| register_meta | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
5 | |||
| register_rest_meta_visibility | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| hide_private_meta_from_rest | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
7.29 | |||
| is_protected_meta | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| schedule_audio_generation | |
66.67% |
4 / 6 |
|
0.00% |
0 / 1 |
3.33 | |||
| schedule_audio_deletion | |
57.14% |
4 / 7 |
|
0.00% |
0 / 1 |
3.71 | |||
| unschedule_audio_generation | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| delete_audio_for_post_or_defer | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| on_trash_post | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| on_delete_post | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| on_add_or_update_post | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
6.73 | |||
| get_lang_code_from_json_if_empty | |
100.00% |
9 / 9 |
|
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 | |
| 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 | * 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 | } |