90, 'antiSkip' => true, 'maxAttempts' => 3, 'requireLoginForGate' => false, ]; public static function init(): void { add_action( 'admin_init', [ __CLASS__, 'run' ] ); } public static function run(): void { // Must run first — subsequent passes read posts by the new post_type slug. self::rename_post_type_slugs(); self::unstamp_inherited_gating(); self::fold_optin_into_layers(); // Its own pass, not nested in the one above: that returns early once its // flag is set, so a site already migrated by an earlier build would never // have reached this. self::drop_orphaned_optin(); } /** * Rename legacy 2-char post type / taxonomy slugs to the plugin-prefixed ones. * * The wp.org directory requires plugin-owned identifiers be at least 4 * characters so they feel distinctive and don't collide with other plugins. * Prior builds used `tp_video`, `tp_preset`, `tp_video_tag`, `tp_playlist`; * installs upgrading from those keep their rows, just under the new slugs. */ private static function rename_post_type_slugs(): void { $done = 'trueplayer_migrated_truepl_slugs'; if ( get_option( $done ) ) { return; } update_option( $done, Helper::now_iso(), false ); global $wpdb; $post_type_map = [ 'tp_video' => TRUEPLAYER_VIDEO_POST_TYPE, 'tp_preset' => TRUEPLAYER_PRESET_POST_TYPE, 'tp_playlist' => 'truepl_playlist', ]; foreach ( $post_type_map as $old => $new ) { if ( $old === $new ) { continue; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema rename. $wpdb->update( $wpdb->posts, [ 'post_type' => $new ], [ 'post_type' => $old ] ); } // Taxonomy slug lives on the terms' taxonomy column. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema rename. $wpdb->update( $wpdb->term_taxonomy, [ 'taxonomy' => 'truepl_video_tag' ], [ 'taxonomy' => 'tp_video_tag' ] ); // Flush object caches so the next request doesn't still see the old slug. wp_cache_flush(); } /** * Let videos inherit the site-wide enforcement policy again. * * The gating tab used to seed hardcoded defaults and save the whole object, * so opening it once wrote `requireLoginForGate: false` (and friends) into * the video — which then permanently shadowed Settings → Enforcement, with * nothing in the UI to say so. The tab now persists only real overrides * (GatingTab), but videos saved before that still carry the stamp. * * Conservative on purpose: a video is only unstamped when all four policy * keys match the old defaults exactly. Change any one of them and the * author clearly chose these values, so the whole block is left alone. */ private static function unstamp_inherited_gating(): void { $done = 'trueplayer_migrated_gating_inherit'; if ( get_option( $done ) ) { return; } update_option( $done, Helper::now_iso(), false ); $ids = get_posts( [ 'post_type' => TRUEPLAYER_VIDEO_POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => '_trueplayer_config', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key ] ); foreach ( $ids as $id ) { $raw = get_post_meta( $id, '_trueplayer_config', true ); $config = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null; if ( ! is_array( $config ) || ! is_array( $config['gating'] ?? null ) ) { continue; } $gating = $config['gating']; foreach ( self::STAMPED_GATING as $key => $stamped ) { if ( ! array_key_exists( $key, $gating ) || $gating[ $key ] !== $stamped ) { continue 2; // An author-chosen value — leave this video untouched. } } foreach ( array_keys( self::STAMPED_GATING ) as $key ) { unset( $gating[ $key ] ); } $config['gating'] = $gating; Helper::update_json_meta( $id, '_trueplayer_config', $config ); } } /** * Carry the old Email capture gate over to the merged Email form layer. * * Email capture and the Layers email form were two ways to ask for the same * address, and only one of them ever had a provider — see * OptinController::destination(). They are one feature now, living in the * layer stack, so every video configured with the old gate gets an * equivalent `mode: 'gate'` layer. * * The old object is removed once its layer exists: nothing reads it any more, * and leaving it behind would mean a video carrying two descriptions of the * same gate with no way to tell which one is live. */ private static function fold_optin_into_layers(): void { $done = 'trueplayer_migrated_optin_to_layer'; if ( get_option( $done ) ) { return; } // Claim the flag first: this walks every video, and a second request // arriving mid-pass must not start the same walk again. update_option( $done, 1 ); $ids = get_posts( [ 'post_type' => TRUEPLAYER_VIDEO_POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', ] ); foreach ( $ids as $id ) { $raw = get_post_meta( $id, '_trueplayer_config', true ); $config = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null; if ( ! is_array( $config ) ) { continue; } $optin = $config['optin'] ?? null; if ( ! is_array( $optin ) || empty( $optin['enabled'] ) ) { continue; } $layers = isset( $config['layers'] ) && is_array( $config['layers'] ) ? $config['layers'] : []; // Idempotent even if the flag is cleared by hand: one gate per video // is what the old feature allowed, so an existing one means done. foreach ( $layers as $layer ) { if ( is_array( $layer ) && 'form' === ( $layer['type'] ?? '' ) && 'gate' === ( $layer['mode'] ?? '' ) ) { continue 2; } } $position = (string) ( $optin['position'] ?? 'pre' ); $layers[] = [ 'id' => 'ly_' . substr( md5( 'optin' . $id ), 0, 6 ), 'type' => 'form', 'mode' => 'gate', // The old `position` was a trigger, not a place on screen. 'trigger' => in_array( $position, [ 'pre', 'time', 'end' ], true ) ? $position : 'pre', 'start' => 'time' === $position ? (int) ( $optin['at'] ?? 0 ) : 0, 'end' => '', 'position' => 'middle-center', 'required' => ! empty( $optin['required'] ), 'collectName' => ! empty( $optin['collectName'] ), 'dedupe' => true, 'title' => (string) ( $optin['headline'] ?? '' ), 'description' => (string) ( $optin['description'] ?? '' ), 'buttonLabel' => (string) ( $optin['buttonText'] ?? '' ), 'provider' => (string) ( $optin['provider'] ?? '' ), 'lists' => (array) ( $optin['lists'] ?? [] ), ]; $config['layers'] = $layers; unset( $config['optin'] ); Helper::update_json_meta( $id, '_trueplayer_config', $config ); } } /** * Clear `config.optin` from videos the fold above didn't rewrite. * * Two cases reach here: a video whose capture was configured but switched * off, and one migrated by an earlier build that kept the object as a * fallback. Neither has anything reading it now, so the stale copy is * removed rather than left to contradict the layer beside it. */ private static function drop_orphaned_optin(): void { $done = 'trueplayer_dropped_legacy_optin'; if ( get_option( $done ) ) { return; } update_option( $done, 1 ); $ids = get_posts( [ 'post_type' => TRUEPLAYER_VIDEO_POST_TYPE, 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', ] ); foreach ( $ids as $id ) { $raw = get_post_meta( $id, '_trueplayer_config', true ); $config = is_string( $raw ) && '' !== $raw ? json_decode( $raw, true ) : null; if ( ! is_array( $config ) || ! array_key_exists( 'optin', $config ) ) { continue; } unset( $config['optin'] ); Helper::update_json_meta( $id, '_trueplayer_config', $config ); } } }