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