Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
132 / 136
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Updater
97.06% covered (success)
97.06%
132 / 136
62.50% covered (warning)
62.50%
5 / 8
44
0.00% covered (danger)
0.00%
0 / 1
 run
85.71% covered (success)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 migrate_preselect_format
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 delete_deprecated_options
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 migrate_disabled_to_embed_none
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 migrate_source_script_to_post_and_script
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
5
 migrate_settings
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 construct_preselect_setting
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
10.02
 rename_plugin_settings
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Plugin update routines: version-gated migrations run on every page load.
4 *
5 * @package BeyondWords\Core
6 * @since   3.0.0
7 * @since   7.0.0 Refactored to BeyondWords namespace with snake_case methods.
8 */
9
10declare( strict_types = 1 );
11
12namespace BeyondWords\Core;
13
14defined( 'ABSPATH' ) || exit;
15
16/**
17 * Version-gated migrations.
18 *
19 * @since 7.0.0 Refactored to BeyondWords namespace with snake_case methods.
20 */
21class Updater {
22
23    /**
24     * Run any pending migrations and update the recorded plugin version.
25     *
26     * The exact-version bail — not the `version_compare` gates — keeps the common
27     * path cheap: a pre-release like `7.0.0-beta.1` compares `< 7.0.0` forever.
28     */
29    public static function run(): void {
30        $version = get_option( 'beyondwords_version', '1.0.0' );
31
32        if ( BEYONDWORDS__PLUGIN_VERSION === $version ) {
33            return;
34        }
35
36        if ( version_compare( $version, '3.0.0', '<' ) ) {
37            self::migrate_settings();
38        }
39
40        if ( version_compare( $version, '3.7.0', '<' ) ) {
41            self::rename_plugin_settings();
42        }
43
44        if ( version_compare( $version, '7.0.0', '<' ) ) {
45            self::migrate_preselect_format();
46            self::delete_deprecated_options();
47            self::migrate_disabled_to_embed_none();
48            self::migrate_source_script_to_post_and_script();
49        }
50
51        // Record the activation timestamp the first time we run.
52        add_option( 'beyondwords_date_activated', gmdate( \DateTime::ATOM ), '', false );
53
54        // Always update the plugin version so FTP uploads and similar bypasses still register.
55        update_option( 'beyondwords_version', BEYONDWORDS__PLUGIN_VERSION );
56    }
57
58    /**
59     * Convert the legacy preselect option to the 7.0.0 mode-based format.
60     *
61     * Reuses the tolerant readers on `Preselect`, so it is idempotent — which
62     * keeps the re-runs pre-release builds trigger (`< 7.0.0` forever) safe.
63     *
64     * @since 7.0.0
65     */
66    public static function migrate_preselect_format(): void {
67        $preselect = get_option( 'beyondwords_preselect' );
68
69        if ( ! is_array( $preselect ) ) {
70            return;
71        }
72
73        $migrated = [];
74
75        foreach ( $preselect as $post_type => $value ) {
76            $post_type = (string) $post_type;
77            $single    = [ $post_type => $value ];
78
79            $mode = \BeyondWords\Settings\Preselect::get_mode( $post_type, $single );
80
81            if ( \BeyondWords\Settings\Preselect::MODE_ALL === $mode ) {
82                $migrated[ $post_type ] = [ 'mode' => \BeyondWords\Settings\Preselect::MODE_ALL ];
83            } elseif ( \BeyondWords\Settings\Preselect::MODE_TERMS === $mode ) {
84                $terms = \BeyondWords\Settings\Preselect::get_selected_terms( $post_type, $single );
85
86                if ( ! empty( $terms ) ) {
87                    $migrated[ $post_type ] = [
88                        'mode'  => \BeyondWords\Settings\Preselect::MODE_TERMS,
89                        'terms' => $terms,
90                    ];
91                }
92            }
93            // MODE_OFF (empty / unrecognised) → dropped.
94        }
95
96        update_option( 'beyondwords_preselect', $migrated, false );
97    }
98
99    /**
100     * v7.0.0: remove every option marked deprecated in `Utils::get_options()`.
101     *
102     * Multisite-aware because legacy installs may have stored these as site options.
103     *
104     * @since 7.0.0
105     */
106    public static function delete_deprecated_options(): void {
107        foreach ( Utils::get_options( 'deprecated' ) as $option ) {
108            if ( is_multisite() ) {
109                delete_site_option( $option );
110            } else {
111                delete_option( $option );
112            }
113        }
114    }
115
116    /**
117     * v7.0.0: migrate the legacy `beyondwords_disabled` opt-out to Embed "None".
118     *
119     * Carries the flag forward so previously-hidden players stay hidden; never
120     * overwrites an existing Embed value, and batches to keep memory bounded.
121     *
122     * @since 7.0.0
123     */
124    public static function migrate_disabled_to_embed_none(): void {
125        $batch = 100;
126
127        do {
128            $post_ids = get_posts(
129                [
130                    'post_type'   => 'any',
131                    'post_status' => 'any',
132                    'numberposts' => $batch,
133                    'fields'      => 'ids',
134                    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
135                    'meta_query'  => [
136                        [
137                            'key'   => 'beyondwords_disabled',
138                            'value' => '1',
139                        ],
140                    ],
141                ]
142            );
143
144            foreach ( $post_ids as $post_id ) {
145                if ( '' === (string) get_post_meta( $post_id, 'beyondwords_embed', true ) ) {
146                    update_post_meta(
147                        $post_id,
148                        'beyondwords_embed',
149                        \BeyondWords\Editor\Components\SettingsFields::EMBED_NONE
150                    );
151                }
152
153                delete_post_meta( $post_id, 'beyondwords_disabled' );
154            }
155
156            $found = count( $post_ids );
157        } while ( $found === $batch );
158    }
159
160    /**
161     * v7.0.0: normalise the removed script-only Source to Post + script.
162     *
163     * See doc/legacy-meta-migration.md. Batched to keep memory bounded, and
164     * idempotent because a migrated post no longer matches the query.
165     *
166     * @since 7.0.0
167     */
168    public static function migrate_source_script_to_post_and_script(): void {
169        $batch = 100;
170
171        do {
172            $post_ids = get_posts(
173                [
174                    'post_type'   => 'any',
175                    'post_status' => 'any',
176                    'numberposts' => $batch,
177                    'fields'      => 'ids',
178                    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
179                    'meta_query'  => [
180                        [
181                            'key'   => 'beyondwords_source',
182                            'value' => \BeyondWords\Editor\Components\SettingsFields::LEGACY_SOURCE_SCRIPT,
183                        ],
184                    ],
185                ]
186            );
187
188            foreach ( $post_ids as $post_id ) {
189                // Pin the asset script-only was showing, so the player doesn't switch.
190                if ( '' === (string) get_post_meta( $post_id, 'beyondwords_embed', true ) ) {
191                    $output = (string) get_post_meta( $post_id, 'beyondwords_output', true );
192
193                    $audio = '' === $output
194                        || \BeyondWords\Editor\Components\SettingsFields::output_includes_audio( $output );
195
196                    update_post_meta(
197                        $post_id,
198                        'beyondwords_embed',
199                        $audio
200                            ? \BeyondWords\Editor\Components\SettingsFields::EMBED_AUDIO_SCRIPT
201                            : \BeyondWords\Editor\Components\SettingsFields::EMBED_VIDEO_SCRIPT
202                    );
203                }
204
205                update_post_meta(
206                    $post_id,
207                    'beyondwords_source',
208                    \BeyondWords\Editor\Components\SettingsFields::SOURCE_POST_AND_SCRIPT
209                );
210            }
211
212            $found = count( $post_ids );
213        } while ( $found === $batch );
214    }
215
216    /**
217     * v3.0.0: migrate `speechkit_settings.*` array values to top-level options.
218     */
219    public static function migrate_settings(): void {
220        $old_settings = get_option( 'speechkit_settings', [] );
221
222        if ( ! is_array( $old_settings ) || empty( $old_settings ) ) {
223            return;
224        }
225
226        $settings_map = [
227            'speechkit_api_key'       => 'speechkit_api_key',
228            'speechkit_id'            => 'speechkit_project_id',
229            'speechkit_merge_excerpt' => 'speechkit_prepend_excerpt',
230        ];
231
232        foreach ( $settings_map as $old_key => $new_key ) {
233            if ( array_key_exists( $old_key, $old_settings ) && ! get_option( $new_key ) ) {
234                add_option( $new_key, $old_settings[ $old_key ] );
235            }
236        }
237
238        if ( false === get_option( 'speechkit_preselect' ) ) {
239            add_option( 'speechkit_preselect', self::construct_preselect_setting() );
240        }
241    }
242
243    /**
244     * Build a v3 preselect array from the v2 post-type + category settings.
245     *
246     * @return array<string,mixed>|false `false` when the legacy `speechkit_settings` option is missing.
247     */
248    public static function construct_preselect_setting(): array|false {
249        $old_settings = get_option( 'speechkit_settings', [] );
250
251        if ( ! is_array( $old_settings ) || empty( $old_settings ) ) {
252            return false;
253        }
254
255        $preselect = [];
256
257        if (
258            array_key_exists( 'speechkit_select_post_types', $old_settings )
259            && ! empty( $old_settings['speechkit_select_post_types'] )
260        ) {
261            $preselect = array_fill_keys( $old_settings['speechkit_select_post_types'], '1' );
262        }
263
264        if (
265            array_key_exists( 'speechkit_selected_categories', $old_settings )
266            && ! empty( $old_settings['speechkit_selected_categories'] )
267        ) {
268            $taxonomy = get_taxonomy( 'category' );
269
270            if ( $taxonomy && is_array( $taxonomy->object_type ) ) {
271                foreach ( $taxonomy->object_type as $post_type ) {
272                    $preselect[ $post_type ] = [
273                        'category' => $old_settings['speechkit_selected_categories'],
274                    ];
275                }
276            }
277        }
278
279        return $preselect;
280    }
281
282    /**
283     * v3.7.0: copy `speechkit_*` option keys to `beyondwords_*`.
284     *
285     * Originals are left in place so plugin downgrades remain safe.
286     */
287    public static function rename_plugin_settings(): void {
288        $api_key         = get_option( 'speechkit_api_key' );
289        $project_id      = get_option( 'speechkit_project_id' );
290        $prepend_excerpt = get_option( 'speechkit_prepend_excerpt' );
291        $preselect       = get_option( 'speechkit_preselect' );
292
293        if ( $api_key ) {
294            update_option( 'beyondwords_api_key', $api_key, false );
295        }
296
297        if ( $project_id ) {
298            update_option( 'beyondwords_project_id', $project_id, false );
299        }
300
301        if ( $prepend_excerpt ) {
302            update_option( 'beyondwords_prepend_excerpt', $prepend_excerpt, false );
303        }
304
305        if ( $preselect ) {
306            update_option( 'beyondwords_preselect', $preselect, false );
307        }
308    }
309}