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