Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.78% covered (danger)
57.78%
104 / 180
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Inspect
57.78% covered (danger)
57.78%
104 / 180
18.18% covered (danger)
18.18%
2 / 11
146.69
0.00% covered (danger)
0.00%
0 / 1
 init
60.00% covered (danger)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
3.58
 adminEnqueueScripts
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 hideMetaBox
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 addMetaBox
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 renderMetaBoxContent
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 postMetaTable
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
8
 formatPostMetaValue
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 getClipboardText
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 save
81.82% covered (success)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
6.22
 restApiInit
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 restApiResponse
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3declare(strict_types=1);
4
5/**
6 * BeyondWords Post Inspect Panel.
7 *
8 * @package Beyondwords\Wordpress
9 * @author  Stuart McAlpine <stu@beyondwords.io>
10 * @since   3.0.0
11 */
12
13namespace Beyondwords\Wordpress\Component\Post\Panel\Inspect;
14
15use Beyondwords\Wordpress\Component\Post\PostMetaUtils;
16use Beyondwords\Wordpress\Component\Settings\SettingsUtils;
17use Beyondwords\Wordpress\Core\ApiClient;
18
19/**
20 * Inspect
21 *
22 * @since 3.0.0
23 */
24class Inspect
25{
26    /**
27     * Constructor
28     */
29    public function init()
30    {
31        add_action('admin_enqueue_scripts', array($this, 'adminEnqueueScripts'));
32        add_action('add_meta_boxes', array($this, 'addMetaBox'));
33        add_action('rest_api_init', array($this, 'restApiInit'));
34
35        add_filter('default_hidden_meta_boxes', array($this, 'hideMetaBox'));
36
37        add_action('wp_loaded', function () {
38            $postTypes = SettingsUtils::getCompatiblePostTypes();
39
40            if (is_array($postTypes)) {
41                foreach ($postTypes as $postType) {
42                    add_action("save_post_{$postType}", array($this, 'save'), 5);
43                }
44            }
45        });
46    }
47
48    /**
49     * Enqueue JS for Inspect feature.
50     */
51    public function adminEnqueueScripts($hook)
52    {
53        // Only enqueue for Post screens
54        if ($hook === 'post.php' || $hook === 'post-new.php') {
55            wp_enqueue_script(
56                'beyondwords-inspect',
57                BEYONDWORDS__PLUGIN_URI . 'src/Component/Post/Panel/Inspect/js/inspect.js',
58                array('jquery'),
59                BEYONDWORDS__PLUGIN_VERSION,
60                true
61            );
62        }
63    }
64
65    /**
66     * Hides the metabox by default.
67     *
68     * @param string[] $hidden An array of IDs of meta boxes hidden by default.
69     */
70    public function hideMetaBox($hidden)
71    {
72        $hidden[] = 'beyondwords__inspect';
73        return $hidden;
74    }
75
76    /**
77     * Adds the meta box container for the Classic Editor.
78     *
79     * The Block Editor UI is handled using JavaScript.
80     *
81     * @param string $postType
82     */
83    public function addMetaBox($postType)
84    {
85        $postTypes = SettingsUtils::getCompatiblePostTypes();
86
87        if (is_array($postTypes) && ! in_array($postType, $postTypes)) {
88            return;
89        }
90
91        add_meta_box(
92            'beyondwords__inspect',
93            __('BeyondWords', 'speechkit') . ': ' . __('Inspect', 'speechkit'),
94            array($this, 'renderMetaBoxContent'),
95            $postType,
96            'advanced',
97            'low',
98            [
99                '__back_compat_meta_box' => true,
100            ]
101        );
102    }
103
104    /**
105     * Render Meta Box content.
106     *
107     * @param WP_Post $post The post object.
108     */
109    public function renderMetaBoxContent($post)
110    {
111        $metadata = PostMetaUtils::getAllBeyondwordsMetadata($post->ID);
112
113        $this->postMetaTable($metadata);
114        ?>
115        <button
116            type="button"
117            id="beyondwords__inspect--copy"
118            class="button button-large"
119            style="margin: 10px 0 0;"
120            data-clipboard-text="<?php echo esc_attr($this->getClipboardText($metadata)); ?>"
121        >
122            <?php esc_html_e('Copy', 'speechkit'); ?>
123            <span
124                id="beyondwords__inspect--copy-confirm"
125                style="display: none; margin: 5px 0 0;"
126                class="dashicons dashicons-yes"
127            ></span>
128        </button>
129
130        <button
131            type="button"
132            id="beyondwords__inspect--remove"
133            class="button button-large button-link-delete"
134            style="margin: 10px 0 0; float: right;"
135        >
136            <?php esc_html_e('Remove', 'speechkit'); ?>
137            <span
138                id="beyondwords__inspect--remove"
139                style="display: none; margin: 5px 0 0;"
140                class="dashicons dashicons-yes"
141            ></span>
142        </button>
143
144        <?php
145    }
146
147    /**
148     * Render Meta Box table.
149     *
150     * @param array   $metadata The metadata returned by has_meta.
151     *
152     * @since v3.0.0
153     * @since v3.9.0 Change $postMetaKeys param to $metadata, to support meta_ids.
154     */
155    public function postMetaTable($metadata)
156    {
157        if (! is_array($metadata)) {
158            return;
159        }
160        ?>
161        <div id="postcustomstuff">
162            <table id="inspect-table">
163                <thead>
164                    <tr>
165                        <th class="left"><?php esc_html_e('Name', 'speechkit'); ?></th>
166                        <th><?php esc_html_e('Value', 'speechkit'); ?></th>
167                    </tr>
168                </thead>
169                <tbody id="inspect-table-list">
170                    <?php
171                    foreach ($metadata as $item) :
172                        if (
173                            ! is_array($item) ||
174                            ! array_key_exists('meta_id', $item) ||
175                            ! array_key_exists('meta_key', $item) ||
176                            ! array_key_exists('meta_value', $item)
177                        ) {
178                            continue;
179                        }
180
181                        $metaId    = $item['meta_id'] ? $item['meta_id'] : $item['meta_key'];
182                        $metaKey   = $item['meta_key'];
183                        $metaValue = $this->formatPostMetaValue($item['meta_value']);
184                        ?>
185                        <tr id="beyondwords-inspect-<?php echo esc_attr($metaId); ?>" class="alternate">
186                            <td class="left">
187                                <label
188                                    class="screen-reader-text"
189                                    for="beyondwords-inspect-<?php echo esc_attr($metaId); ?>-key"
190                                >
191                                    <?php esc_html_e('Key', 'speechkit'); ?>
192                                </label>
193                                <input
194                                    id="beyondwords-inspect-<?php echo esc_attr($metaId); ?>-key"
195                                    type="text"
196                                    size="20"
197                                    value="<?php echo esc_attr($metaKey); ?>"
198                                    readonly
199                                />
200                            </td>
201                            <td>
202                                <label
203                                    class="screen-reader-text"
204                                    for="beyondwords-inspect-<?php echo esc_attr($metaId); ?>-value"
205                                >
206                                    <?php esc_html_e('Value', 'speechkit'); ?>
207                                </label>
208                                <textarea
209                                    id="beyondwords-inspect-<?php echo esc_attr($metaId); ?>-value"
210                                    rows="2"
211                                    cols="30"
212                                    data-beyondwords-metavalue="true"
213                                    readonly
214                                ><?php echo esc_html($metaValue); ?></textarea>
215                            </td>
216                        </tr>
217                        <?php
218                    endforeach;
219
220                    wp_nonce_field('beyondwords_delete_content', 'beyondwords_delete_content_nonce');
221                    ?>
222                    <input
223                        type="hidden"
224                        id="beyondwords_delete_content"
225                        name="beyondwords_delete_content"
226                        value="1"
227                        disabled
228                    />
229                </tbody>
230            </table>
231        </div>
232        <?php
233    }
234
235    /**
236     * Format post meta value.
237     *
238     * @param mixed $value The metadata value.
239     *
240     * @since v3.9.0
241     */
242    public function formatPostMetaValue($value)
243    {
244        if (is_numeric($value) || is_string($value)) {
245            return $value;
246        }
247
248        return wp_json_encode($value);
249    }
250
251    /**
252     * Get Clipboard Text.
253     *
254     * @param array $metadata Post metadata.
255     *
256     * @since 3.0.0
257     * @since 3.9.0 Output all post meta data from the earlier has_meta() call instead of
258     *              the previous multiple get_post_meta() calls.
259     *
260     * @return string
261     */
262    public function getClipboardText($metadata)
263    {
264        $lines = [];
265
266        foreach ($metadata as $m) {
267            $lines[] = $m['meta_key'] . "\r\n" . $this->formatPostMetaValue($m['meta_value']);
268        }
269
270        $lines[] = "=== " . __('Copied using the Classic Editor', 'speechkit') . " ===\r\n\r\n";
271
272        return implode("\r\n\r\n", $lines);
273    }
274
275    /**
276     * Runs when a post is saved.
277     *
278     * If "Remove" has been pressed in the Classic Editor we set the `beyondwords_delete_content`
279     * custom field. At a later priority we check for this custom field and if it's set
280     * we make a DELETE request to the BeyondWords REST API, keeping WordPress and the
281     * REST API in sync.
282     *
283     * If we don't perform a DELETE REST API request to keep them in sync then the
284     * API will respond with a "source_id is already in use" error message whenver we
285     * attempt to regenerate audio for a post that has audio content "Removed" in
286     * WordPress but still exists in the REST API.
287     *
288     * @since 4.0.7
289     *
290     * @param int $postId The ID of the post being saved.
291     */
292    public function save($postId)
293    {
294        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
295            return $postId;
296        }
297
298        // "save_post" can be triggered at other times, so verify this request came from the our component
299        if (
300            ! isset($_POST['beyondwords_delete_content_nonce']) ||
301            ! wp_verify_nonce(
302                sanitize_key($_POST['beyondwords_delete_content_nonce']),
303                'beyondwords_delete_content'
304            )
305        ) {
306            return $postId;
307        }
308
309        if (isset($_POST['beyondwords_delete_content'])) {
310            // Set the flag - the DELETE request is performed at a later priority
311            update_post_meta($postId, 'beyondwords_delete_content', '1');
312        }
313
314        return $postId;
315    }
316
317    /**
318     * REST API init.
319     *
320     * Register REST API routes.
321     **/
322    public function restApiInit()
323    {
324        register_rest_route('beyondwords/v1', '/projects/(?P<projectId>[0-9]+)/content/(?P<contentId>[a-zA-Z0-9\-]+)', array( // phpcs:ignore Generic.Files.LineLength.TooLong
325            'methods'  => \WP_REST_Server::READABLE,
326            'callback' => array($this, 'restApiResponse'),
327            'permission_callback' => function () {
328                return current_user_can('edit_posts');
329            },
330        ));
331    }
332
333    /**
334     * REST API response.
335     *
336     * Fetches a content object from the BeyondWords REST API.
337     *
338     * @param \WP_REST_Request $request The REST request object.
339     *
340     * @return \WP_REST_Response
341     **/
342    public function restApiResponse(\WP_REST_Request $request)
343    {
344        $projectId = $request['projectId'] ?? '';
345        $contentId = $request['contentId'] ?? '';
346
347        if (! is_numeric($projectId)) {
348            return rest_ensure_response(
349                new \WP_Error(
350                    400,
351                    __('Invalid Project ID', 'speechkit'),
352                    ['projectId' => $projectId]
353                )
354            );
355        }
356
357        if (empty($contentId)) {
358            return rest_ensure_response(
359                new \WP_Error(
360                    400,
361                    __('Invalid Content ID', 'speechkit'),
362                    ['contentId' => $contentId]
363                )
364            );
365        }
366
367        $response = ApiClient::getContent($contentId, $projectId);
368
369        // Check for REST API connection errors.
370        if (is_wp_error($response)) {
371            return rest_ensure_response(
372                new \WP_Error(
373                    500,
374                    __('Could not connect to BeyondWords API', 'speechkit'),
375                    $response->get_error_data()
376                )
377            );
378        }
379
380        $code = wp_remote_retrieve_response_code($response);
381        $body = wp_remote_retrieve_body($response);
382
383        // Check for REST API response errors.
384        if ($code < 200 || $code >= 300) {
385            return rest_ensure_response(
386                new \WP_Error(
387                    $code,
388                    /* translators: %d is replaced with the error code. */
389                    sprintf(__('BeyondWords REST API returned error code %d', 'speechkit'), $code),
390                    [
391                        'body' => $body,
392                    ]
393                )
394            );
395        }
396
397        $data = json_decode($body, true);
398
399        // Check for REST API JSON response.
400        if (! is_array($data)) {
401            return rest_ensure_response(
402                new \WP_Error(
403                    500,
404                    __('Invalid response from BeyondWords API', 'speechkit')
405                )
406            );
407        }
408
409        // Return the project ID in the response.
410        $data['project_id'] = $projectId;
411
412        return rest_ensure_response($data);
413    }
414}