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