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