Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
125 / 125
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Sync
100.00% covered (success)
100.00%
125 / 125
100.00% covered (success)
100.00%
7 / 7
39
100.00% covered (success)
100.00%
1 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 scheduleSyncs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 syncToWordPress
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
9
 updateOptionsFromResponses
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 syncToDashboard
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
13
 shouldSyncOptionToDashboard
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 syncOptionToDashboard
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Setting: Sync
7 *
8 * @package Beyondwords\Wordpress
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   5.0.0
11 */
12
13namespace Beyondwords\Wordpress\Component\Settings;
14
15use Beyondwords\Wordpress\Core\ApiClient;
16use Beyondwords\Wordpress\Core\Environment;
17use Symfony\Component\PropertyAccess\PropertyAccess;
18
19/**
20 * Sync
21 *
22 * @since 5.0.0
23 *
24 * @SuppressWarnings(PHPMD.CyclomaticComplexity)
25 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
26 * @SuppressWarnings(PHPMD.NPathComplexity)
27 */
28defined('ABSPATH') || exit;
29
30class Sync
31{
32    /**
33     * Map settings.
34     *
35     * @since 5.0.0
36     */
37    public const MAP_SETTINGS = [
38        // Player
39        'beyondwords_player_style'              => '[player_settings][player_style]',
40        'beyondwords_player_theme'              => '[player_settings][theme]',
41        'beyondwords_player_theme_dark'         => '[player_settings][dark_theme]',
42        'beyondwords_player_theme_light'        => '[player_settings][light_theme]',
43        'beyondwords_player_theme_video'        => '[player_settings][video_theme]',
44        'beyondwords_player_call_to_action'     => '[player_settings][call_to_action]',
45        'beyondwords_player_widget_style'       => '[player_settings][widget_style]',
46        'beyondwords_player_widget_position'    => '[player_settings][widget_position]',
47        'beyondwords_player_skip_button_style'  => '[player_settings][skip_button_style]',
48        'beyondwords_player_clickable_sections' => '[player_settings][segment_playback_enabled]',
49        // Project
50        'beyondwords_project_auto_publish_enabled'      => '[project][auto_publish_enabled]',
51        'beyondwords_project_language_code'             => '[project][language]',
52        'beyondwords_project_body_voice_id'             => '[project][body][voice][id]',
53        'beyondwords_project_body_voice_speaking_rate'  => '[project][body][voice][speaking_rate]',
54        'beyondwords_project_title_enabled'             => '[project][title][enabled]',
55        'beyondwords_project_title_voice_id'            => '[project][title][voice][id]',
56        'beyondwords_project_title_voice_speaking_rate' => '[project][title][voice][speaking_rate]',
57        // Video
58        'beyondwords_video_enabled' => '[video_settings][enabled]',
59    ];
60
61    /**
62     * Init.
63     *
64     * @since 5.0.0
65     * @since 6.0.0 Make static.
66     */
67    public static function init()
68    {
69        add_action('load-settings_page_beyondwords', [self::class, 'syncToWordPress'], 30);
70
71        if (Environment::hasAutoSyncSettings()) {
72            add_action('load-settings_page_beyondwords', [self::class, 'scheduleSyncs'], 20);
73            add_action('shutdown', [self::class, 'syncToDashboard']);
74        }
75    }
76
77    /**
78     * Should we schedule a sync on the current settings tab?
79     *
80     * @since 5.0.0
81     * @since 5.2.0 Remove API creds validation.
82     * @since 6.0.0 Make static.
83     *
84     * @return void
85     */
86    public static function scheduleSyncs()
87    {
88        $tab       = Settings::getActiveTab();
89        $endpoints = [];
90
91        switch ($tab) {
92            case 'content':
93            case 'voices':
94                $endpoints = ['project'];
95                break;
96            case 'player':
97                $endpoints = ['player_settings', 'video_settings'];
98                break;
99        }
100
101        if (count($endpoints)) {
102            wp_cache_set('beyondwords_sync_to_wordpress', $endpoints, 'beyondwords', 60);
103        }
104    }
105
106    /**
107     * Sync from the dashboard/BeyondWords REST API to WordPress.
108     *
109     * @since 5.0.0 Introduced.
110     * @since 5.4.0 Stop saving language ID – we only need the ISO code now.
111     * @since 6.0.0 Make static.
112     *
113     * @return void
114     **/
115    public static function syncToWordPress()
116    {
117        $sync_to_wordpress = wp_cache_get('beyondwords_sync_to_wordpress', 'beyondwords');
118        wp_cache_delete('beyondwords_sync_to_wordpress', 'beyondwords');
119
120        if (empty($sync_to_wordpress) || ! is_array($sync_to_wordpress)) {
121            return;
122        }
123
124        $responses = [];
125
126        if (! empty(array_intersect($sync_to_wordpress, ['all', 'project']))) {
127            $project = ApiClient::getProject();
128            if (! empty($project)) {
129                $responses['project'] = $project;
130            }
131        }
132
133        if (! empty(array_intersect($sync_to_wordpress, ['all', 'player_settings']))) {
134            $player_settings = ApiClient::getPlayerSettings();
135            if (! empty($player_settings)) {
136                $responses['player_settings'] = $player_settings;
137            }
138        }
139
140        if (! empty(array_intersect($sync_to_wordpress, ['all', 'video_settings']))) {
141            $video_settings = ApiClient::getVideoSettings();
142            if (! empty($video_settings)) {
143                $responses['video_settings'] = $video_settings;
144            }
145        }
146
147        // Update WordPress options using the REST API response data.
148        self::updateOptionsFromResponses($responses);
149    }
150
151    /**
152     * Update WordPress options from REST API responses.
153     *
154     * @since 5.0.0
155     * @since 6.0.0 Make static.
156     * @since 6.0.3 Check path is readable before getting value.
157     *
158     * @return boolean
159     **/
160    public static function updateOptionsFromResponses($responses)
161    {
162        if (empty($responses)) {
163            add_settings_error(
164                'beyondwords_settings',
165                'beyondwords_settings',
166                '<span class="dashicons dashicons-controls-volumeon"></span> Unexpected BeyondWords REST API response.',
167                'error'
168            );
169            return false;
170        }
171
172        $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
173            ->disableExceptionOnInvalidPropertyPath()
174            ->getPropertyAccessor();
175
176        $updated = false;
177
178        foreach (self::MAP_SETTINGS as $optionName => $path) {
179            if ($propertyAccessor->isReadable($responses, $path)) {
180                $value = $propertyAccessor->getValue($responses, $path);
181                if ($value !== null) {
182                    update_option($optionName, $value, false);
183                    $updated = true;
184                }
185            }
186        }
187
188        return $updated;
189    }
190
191    /**
192     * Sync from WordPress to the dashboard/BeyondWords REST API.
193     *
194     * @since 5.0.0
195     * @since 6.0.0 Make static.
196     *
197     * @return void
198     **/
199    public static function syncToDashboard()
200    {
201        $options = wp_cache_get('beyondwords_sync_to_dashboard', 'beyondwords');
202        wp_cache_delete('beyondwords_sync_to_dashboard', 'beyondwords');
203
204        if (empty($options) || ! is_array($options)) {
205            return;
206        }
207
208        $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()
209            ->disableExceptionOnInvalidPropertyPath()
210            ->getPropertyAccessor();
211
212        $settings = [];
213
214        foreach ($options as $option) {
215            if (self::shouldSyncOptionToDashboard($option)) {
216                $propertyAccessor->setValue(
217                    $settings,
218                    self::MAP_SETTINGS[$option],
219                    get_option($option)
220                );
221            }
222        }
223
224        // Sync player settings back to API
225        if (isset($settings['player_settings'])) {
226            ApiClient::updatePlayerSettings($settings['player_settings']);
227
228            add_settings_error(
229                'beyondwords_settings',
230                'beyondwords_settings',
231                '<span class="dashicons dashicons-rest-api"></span> Player settings synced from WordPress to the BeyondWords dashboard.', // phpcs:ignore Generic.Files.LineLength.TooLong
232                'success'
233            );
234        }
235
236        // Sync title voice back to API
237        if (in_array('beyondwords_project_title_voice_speaking_rate', $options)) {
238            $value = $propertyAccessor->getValue(
239                $settings,
240                self::MAP_SETTINGS['beyondwords_project_title_voice_speaking_rate']
241            );
242
243            if ($value !== null) {
244                $titleVoiceId = get_option('beyondwords_project_title_voice_id');
245                ApiClient::updateVoice($titleVoiceId, [
246                    'speaking_rate' => (int)$value,
247                ]);
248            }
249        }
250
251        // Sync body voice back to API
252        if (in_array('beyondwords_project_body_voice_speaking_rate', $options)) {
253            $value = $propertyAccessor->getValue(
254                $settings,
255                self::MAP_SETTINGS['beyondwords_project_body_voice_speaking_rate']
256            );
257
258            if ($value !== null) {
259                $bodyVoiceId = get_option('beyondwords_project_body_voice_id');
260                ApiClient::updateVoice($bodyVoiceId, [
261                    'speaking_rate' => (int)$value,
262                ]);
263            }
264        }
265
266        // Sync project settings back to API
267        if (isset($settings['project'])) {
268            // Don't send speaking rates back to /project endpoint
269            $titleSpeakingRate = $propertyAccessor->getValue(
270                $settings,
271                self::MAP_SETTINGS['beyondwords_project_title_voice_speaking_rate']
272            );
273            if ($titleSpeakingRate) {
274                unset($settings['project']['title']['voice']['speaking_rate']);
275            }
276
277            // Don't send speaking rates back to /project endpoint
278            $bodySpeakingRate = $propertyAccessor->getValue(
279                $settings,
280                self::MAP_SETTINGS['beyondwords_project_body_voice_speaking_rate']
281            );
282            if ($bodySpeakingRate) {
283                unset($settings['project']['body']['voice']['speaking_rate']);
284            }
285
286            ApiClient::updateProject($settings['project']);
287
288            add_settings_error(
289                'beyondwords_settings',
290                'beyondwords_settings',
291                '<span class="dashicons dashicons-rest-api"></span> Project settings synced from WordPress to the BeyondWords dashboard.', // phpcs:ignore Generic.Files.LineLength.TooLong
292                'success'
293            );
294        }
295    }
296
297    /**
298     * Should we sync this option to the dashboard?
299     *
300     * @since 5.0.0
301     * @since 6.0.0 Make static.
302     *
303     * @param string $option_name Option name.
304     *
305     * @return void
306     **/
307    public static function shouldSyncOptionToDashboard($option_name)
308    {
309        if (! array_key_exists($option_name, self::MAP_SETTINGS)) {
310            return false;
311        }
312
313        // Check the option was updated without error
314        $hasErrors = get_settings_errors($option_name);
315
316        return is_array($hasErrors) && count($hasErrors) === 0;
317    }
318
319    /**
320     * Sync an option to the WordPress dashboard.
321     *
322     * Note that this DOES NOT make the API call, it instead flags the field
323     * as one to sync so that we can group fields and send them in a single
324     * request to the BeyondWords REST API.
325     *
326     * @since 5.0.0
327     *
328     * @return void
329     **/
330    public static function syncOptionToDashboard($optionName)
331    {
332        $options = wp_cache_get('beyondwords_sync_to_dashboard', 'beyondwords');
333
334        if (! is_array($options)) {
335            $options = [];
336        }
337
338        $options[] = $optionName;
339        $options   = array_unique($options);
340
341        wp_cache_set('beyondwords_sync_to_dashboard', $options, 'beyondwords', 60);
342    }
343}