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