Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.04% covered (success)
96.04%
97 / 101
57.14% covered (danger)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Updater
96.04% covered (success)
96.04%
97 / 101
57.14% covered (danger)
57.14%
4 / 7
39
0.00% covered (danger)
0.00%
0 / 1
 run
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
5.09
 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_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        }
49
50        // Record the activation timestamp the first time we run.
51        add_option( 'beyondwords_date_activated', gmdate( \DateTime::ATOM ), '', false );
52
53        // Always update the plugin version so FTP uploads and similar bypasses still register.
54        update_option( 'beyondwords_version', BEYONDWORDS__PLUGIN_VERSION );
55    }
56
57    /**
58     * Convert the legacy preselect option to the 7.0.0 mode-based format.
59     *
60     * Reuses the tolerant readers on `Preselect`, so it is idempotent — which
61     * keeps the re-runs pre-release builds trigger (`< 7.0.0` forever) safe.
62     *
63     * @since 7.0.0
64     */
65    public static function migrate_preselect_format(): void {
66        $preselect = get_option( 'beyondwords_preselect' );
67
68        if ( ! is_array( $preselect ) ) {
69            return;
70        }
71
72        $migrated = [];
73
74        foreach ( $preselect as $post_type => $value ) {
75            $post_type = (string) $post_type;
76            $single    = [ $post_type => $value ];
77
78            $mode = \BeyondWords\Settings\Preselect::get_mode( $post_type, $single );
79
80            if ( \BeyondWords\Settings\Preselect::MODE_ALL === $mode ) {
81                $migrated[ $post_type ] = [ 'mode' => \BeyondWords\Settings\Preselect::MODE_ALL ];
82            } elseif ( \BeyondWords\Settings\Preselect::MODE_TERMS === $mode ) {
83                $terms = \BeyondWords\Settings\Preselect::get_selected_terms( $post_type, $single );
84
85                if ( ! empty( $terms ) ) {
86                    $migrated[ $post_type ] = [
87                        'mode'  => \BeyondWords\Settings\Preselect::MODE_TERMS,
88                        'terms' => $terms,
89                    ];
90                }
91            }
92            // MODE_OFF (empty / unrecognised) → dropped.
93        }
94
95        update_option( 'beyondwords_preselect', $migrated, false );
96    }
97
98    /**
99     * v7.0.0: remove every option marked deprecated in `Utils::get_options()`.
100     *
101     * Multisite-aware because legacy installs may have stored these as site options.
102     *
103     * @since 7.0.0
104     */
105    public static function delete_deprecated_options(): void {
106        foreach ( Utils::get_options( 'deprecated' ) as $option ) {
107            if ( is_multisite() ) {
108                delete_site_option( $option );
109            } else {
110                delete_option( $option );
111            }
112        }
113    }
114
115    /**
116     * v7.0.0: migrate the legacy `beyondwords_disabled` opt-out to Embed "None".
117     *
118     * Carries the flag forward so previously-hidden players stay hidden; never
119     * overwrites an existing Embed value, and batches to keep memory bounded.
120     *
121     * @since 7.0.0
122     */
123    public static function migrate_disabled_to_embed_none(): void {
124        $batch = 100;
125
126        do {
127            $post_ids = get_posts(
128                [
129                    'post_type'   => 'any',
130                    'post_status' => 'any',
131                    'numberposts' => $batch,
132                    'fields'      => 'ids',
133                    // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
134                    'meta_query'  => [
135                        [
136                            'key'   => 'beyondwords_disabled',
137                            'value' => '1',
138                        ],
139                    ],
140                ]
141            );
142
143            foreach ( $post_ids as $post_id ) {
144                if ( '' === (string) get_post_meta( $post_id, 'beyondwords_embed', true ) ) {
145                    update_post_meta(
146                        $post_id,
147                        'beyondwords_embed',
148                        \BeyondWords\Editor\Components\SettingsFields::EMBED_NONE
149                    );
150                }
151
152                delete_post_meta( $post_id, 'beyondwords_disabled' );
153            }
154
155            $found = count( $post_ids );
156        } while ( $found === $batch );
157    }
158
159    /**
160     * v3.0.0: migrate `speechkit_settings.*` array values to top-level options.
161     */
162    public static function migrate_settings(): void {
163        $old_settings = get_option( 'speechkit_settings', [] );
164
165        if ( ! is_array( $old_settings ) || empty( $old_settings ) ) {
166            return;
167        }
168
169        $settings_map = [
170            'speechkit_api_key'       => 'speechkit_api_key',
171            'speechkit_id'            => 'speechkit_project_id',
172            'speechkit_merge_excerpt' => 'speechkit_prepend_excerpt',
173        ];
174
175        foreach ( $settings_map as $old_key => $new_key ) {
176            if ( array_key_exists( $old_key, $old_settings ) && ! get_option( $new_key ) ) {
177                add_option( $new_key, $old_settings[ $old_key ] );
178            }
179        }
180
181        if ( false === get_option( 'speechkit_preselect' ) ) {
182            add_option( 'speechkit_preselect', self::construct_preselect_setting() );
183        }
184    }
185
186    /**
187     * Build a v3 preselect array from the v2 post-type + category settings.
188     *
189     * @return array<string,mixed>|false `false` when the legacy `speechkit_settings` option is missing.
190     */
191    public static function construct_preselect_setting(): array|false {
192        $old_settings = get_option( 'speechkit_settings', [] );
193
194        if ( ! is_array( $old_settings ) || empty( $old_settings ) ) {
195            return false;
196        }
197
198        $preselect = [];
199
200        if (
201            array_key_exists( 'speechkit_select_post_types', $old_settings )
202            && ! empty( $old_settings['speechkit_select_post_types'] )
203        ) {
204            $preselect = array_fill_keys( $old_settings['speechkit_select_post_types'], '1' );
205        }
206
207        if (
208            array_key_exists( 'speechkit_selected_categories', $old_settings )
209            && ! empty( $old_settings['speechkit_selected_categories'] )
210        ) {
211            $taxonomy = get_taxonomy( 'category' );
212
213            if ( $taxonomy && is_array( $taxonomy->object_type ) ) {
214                foreach ( $taxonomy->object_type as $post_type ) {
215                    $preselect[ $post_type ] = [
216                        'category' => $old_settings['speechkit_selected_categories'],
217                    ];
218                }
219            }
220        }
221
222        return $preselect;
223    }
224
225    /**
226     * v3.7.0: copy `speechkit_*` option keys to `beyondwords_*`.
227     *
228     * Originals are left in place so plugin downgrades remain safe.
229     */
230    public static function rename_plugin_settings(): void {
231        $api_key         = get_option( 'speechkit_api_key' );
232        $project_id      = get_option( 'speechkit_project_id' );
233        $prepend_excerpt = get_option( 'speechkit_prepend_excerpt' );
234        $preselect       = get_option( 'speechkit_preselect' );
235
236        if ( $api_key ) {
237            update_option( 'beyondwords_api_key', $api_key, false );
238        }
239
240        if ( $project_id ) {
241            update_option( 'beyondwords_project_id', $project_id, false );
242        }
243
244        if ( $prepend_excerpt ) {
245            update_option( 'beyondwords_prepend_excerpt', $prepend_excerpt, false );
246        }
247
248        if ( $preselect ) {
249            update_option( 'beyondwords_preselect', $preselect, false );
250        }
251    }
252}