Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
45.19% covered (danger)
45.19%
141 / 312
33.33% covered (danger)
33.33%
6 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatEditor
45.19% covered (danger)
45.19%
141 / 312
33.33% covered (danger)
33.33%
6 / 18
806.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_routes
90.91% covered (success)
90.91%
30 / 33
0.00% covered (danger)
0.00%
0 / 1
1.00
 get_config
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
72
 get_brand_id
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_site_editor_assets
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 is_site_editor_chat_screen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 is_post_editor_chat_screen
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
13.12
 register_assets
4.44% covered (danger)
4.44%
2 / 45
0.00% covered (danger)
0.00%
0 / 1
49.75
 get_plan_upgrade_banner_data
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
17.71
 load_script_translation_file
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 add_admin_body_class
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 add_editor_canvas_styles
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 get_site_context
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 load_text_domain
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 admin_bar_menu
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 enqueue_admin_bar_assets
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 upload_temp_file
76.00% covered (warning)
76.00%
38 / 50
0.00% covered (danger)
0.00%
0 / 1
6.50
 delete_temp_file
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace NewfoldLabs\WP\Module\EditorChat;
4
5/**
6 * ChatEditor main class
7 *
8 * Handles the registration and loading of the AI chat editor assets
9 * in the WordPress block editor, and provides a config REST endpoint
10 * for the CF AI Gateway Worker handshake.
11 */
12final class ChatEditor {
13    /**
14     * Array of allowed referrers for site editor access
15     *
16     * @var array
17     */
18    protected static $allowed_referrers = array(
19        'nfd-editor-chat',
20    );
21
22    /**
23     * Constructor.
24     */
25    public function __construct() {
26        \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_site_editor_assets' ) );
27        \add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
28        \add_action( 'init', array( __CLASS__, 'load_text_domain' ), 100 );
29        \add_filter( 'load_script_translation_file', array( __CLASS__, 'load_script_translation_file' ), 10, 3 );
30        \add_action( 'admin_bar_menu', array( __CLASS__, 'admin_bar_menu' ), 99 );
31        \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_bar_assets' ) );
32        \add_action( 'wp_enqueue_scripts', array( __CLASS__, 'enqueue_admin_bar_assets' ) );
33        // Editor settings build before admin_enqueue_scripts, so register here.
34        \add_filter( 'block_editor_settings_all', array( __CLASS__, 'add_editor_canvas_styles' ), 10, 2 );
35    }
36
37    /**
38     * Register REST API routes.
39     */
40    public static function register_rest_routes() {
41        \register_rest_route(
42            'nfd-editor-chat/v1',
43            '/config',
44            array(
45                'methods'             => \WP_REST_Server::READABLE,
46                'callback'            => array( __CLASS__, 'get_config' ),
47                'permission_callback' => function () {
48                    return Permissions::is_editor();
49                },
50            )
51        );
52        \register_rest_route(
53            'nfd-editor-chat/v1',
54            '/upload',
55            array(
56                'methods'             => \WP_REST_Server::CREATABLE,
57                'callback'            => array( __CLASS__, 'upload_temp_file' ),
58                'permission_callback' => function () {
59                    return Permissions::is_editor();
60                },
61            )
62        );
63
64        \register_rest_route(
65            'nfd-editor-chat/v1',
66            '/upload/(?P<filename>[a-zA-Z0-9_\-\.]+)',
67            array(
68                'methods'             => \WP_REST_Server::DELETABLE,
69                'callback'            => array( __CLASS__, 'delete_temp_file' ),
70                'permission_callback' => function () {
71                    return Permissions::is_editor();
72                },
73            )
74        );
75    }
76
77    /**
78     * Get configuration for the editor chat frontend.
79     *
80     * Performs a server-to-server handshake with the CF Worker to exchange
81     * the Hiive auth token for a short-lived session JWT. The Hiive token
82     * never reaches the browser.
83     *
84     * @return \WP_REST_Response|\WP_Error
85     */
86    public static function get_config() {
87        $worker_url = defined( 'NFD_EDITOR_CHAT_WORKER_URL' )
88            ? \NFD_EDITOR_CHAT_WORKER_URL
89            : 'https://cf-worker-ai-chat.bluehost.workers.dev';
90
91        if ( empty( $worker_url ) ) {
92            return new \WP_Error(
93                'worker_url_not_configured',
94                __( 'Editor chat Worker URL is not configured. Set NFD_EDITOR_CHAT_WORKER_URL in wp-config.php.', 'nfd-editor-chat' ),
95                array( 'status' => 500 )
96            );
97        }
98
99        $worker_url = \untrailingslashit( $worker_url );
100
101        // Get Hiive auth token for server-to-server handshake
102        $hiive_token = '';
103        if ( class_exists( '\NewfoldLabs\WP\Module\Data\HiiveConnection' ) ) {
104            $hiive_token = \NewfoldLabs\WP\Module\Data\HiiveConnection::get_auth_token();
105        }
106
107        if ( empty( $hiive_token ) ) {
108            return new \WP_Error(
109                'hiive_token_unavailable',
110                __( 'Unable to retrieve Hiive authentication token.', 'nfd-editor-chat' ),
111                array( 'status' => 500 )
112            );
113        }
114
115        // Server-to-server handshake with Worker
116        $handshake_response = \wp_remote_post(
117            $worker_url . '/handshake',
118            array(
119                'headers' => array(
120                    'X-Hiive-Token' => $hiive_token,
121                    'Content-Type'  => 'application/json',
122                ),
123                'body'    => \wp_json_encode(
124                    array(
125                        'site_url' => \get_site_url(),
126                        'brand_id' => self::get_brand_id(),
127                    )
128                ),
129                'timeout' => 10,
130            )
131        );
132
133        if ( \is_wp_error( $handshake_response ) ) {
134            return new \WP_Error(
135                'handshake_failed',
136                $handshake_response->get_error_message(),
137                array( 'status' => 502 )
138            );
139        }
140
141        $status_code = \wp_remote_retrieve_response_code( $handshake_response );
142        if ( 200 !== $status_code ) {
143            return new \WP_Error(
144                'handshake_failed',
145                /* translators: %d: HTTP status code from the Worker handshake. */
146                \sprintf( __( 'Worker handshake returned HTTP %d.', 'nfd-editor-chat' ), $status_code ),
147                array( 'status' => 502 )
148            );
149        }
150
151        $data = json_decode( \wp_remote_retrieve_body( $handshake_response ), true );
152
153        if ( empty( $data['session_token'] ) ) {
154            return new \WP_Error(
155                'handshake_failed',
156                __( 'Worker handshake did not return a session token.', 'nfd-editor-chat' ),
157                array( 'status' => 502 )
158            );
159        }
160
161        return new \WP_REST_Response(
162            array(
163                'worker_url'    => $worker_url,
164                'session_token' => $data['session_token'],
165                'expires_in'    => $data['expires_in'] ?? 3600,
166            )
167        );
168    }
169
170    /**
171     * Get the brand identifier for the current plugin.
172     *
173     * @return string
174     */
175    private static function get_brand_id() {
176        if ( defined( 'STARTER_PLUGIN_BRAND' ) ) {
177            return \STARTER_PLUGIN_BRAND;
178        }
179        // Fallback: derive from plugin directory name
180        $plugin_dir = \basename( \dirname( __DIR__, 3 ) );
181        $brand_map  = array(
182            'wp-plugin-bluehost'      => 'bluehost',
183            'wp-plugin-hostgator'     => 'hostgator',
184            'wp-plugin-web'           => 'web',
185            'wp-plugin-crazy-domains' => 'crazydomains',
186        );
187
188        return $brand_map[ $plugin_dir ] ?? 'bluehost';
189    }
190
191    /**
192     * Enqueue editor chat assets on Site Editor (referrer) or post block editor screens.
193     *
194     * @return void
195     */
196    public static function enqueue_site_editor_assets() {
197        global $pagenow;
198
199        if ( self::is_site_editor_chat_screen( $pagenow ) ) {
200            self::register_assets( 'site' );
201            \add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_class' ) );
202            return;
203        }
204
205        if ( self::is_post_editor_chat_screen( $pagenow ) ) {
206            self::register_assets( 'post' );
207            \add_filter( 'admin_body_class', array( __CLASS__, 'add_admin_body_class' ) );
208        }
209    }
210
211    /**
212     * Whether the current request is the Site Editor with an allowed referrer.
213     *
214     * @param string $pagenow Current admin page.
215     * @return bool
216     */
217    private static function is_site_editor_chat_screen( $pagenow ) {
218        if ( 'site-editor.php' !== $pagenow ) {
219            return false;
220        }
221
222        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
223        return isset( $_GET['referrer'] ) && \in_array( $_GET['referrer'], self::$allowed_referrers, true );
224    }
225
226    /**
227     * Whether the current request is a block post editor screen (post.php / post-new.php).
228     *
229     * @param string $pagenow Current admin page.
230     * @return bool
231     */
232    private static function is_post_editor_chat_screen( $pagenow ) {
233        if ( ! \in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
234            return false;
235        }
236
237        if ( ! Permissions::is_editor() ) {
238            return false;
239        }
240
241        $screen = \get_current_screen();
242
243        if ($screen && \method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor()){
244            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
245            return isset( $_GET['referrer'] ) && \in_array( $_GET['referrer'], self::$allowed_referrers, true );
246        }
247
248        return false;
249    }
250
251    /**
252     * Register and enqueue chat editor assets.
253     *
254     * @param string $editor_type `site` or `post`.
255     */
256    private static function register_assets( $editor_type = 'site' ) {
257
258        $asset_file = NFD_EDITOR_CHAT_BUILD_DIR . '/chat-editor.asset.php';
259
260        if ( \is_readable( $asset_file ) ) {
261            $asset = include_once $asset_file;
262
263            \wp_register_script(
264                'nfd-editor-chat',
265                NFD_EDITOR_CHAT_BUILD_URL . '/chat-editor.js',
266                array_merge( $asset['dependencies'], array() ),
267                $asset['version'],
268                true
269            );
270
271            // In dev, version by file mtime so CSS-only rebuilds bust the cache
272            // ($asset['version'] only changes with the JS bundle).
273            $style_version = $asset['version'];
274
275            if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) {
276                $style_path = NFD_EDITOR_CHAT_BUILD_DIR . '/chat-editor.css';
277
278                if ( \is_readable( $style_path ) ) {
279                    $style_version = (string) \filemtime( $style_path );
280                }
281            }
282
283            \wp_register_style(
284                'nfd-editor-chat',
285                NFD_EDITOR_CHAT_BUILD_URL . '/chat-editor.css',
286                array(),
287                $style_version
288            );
289
290            $args = array(
291                'nonce'          => \wp_create_nonce( 'wp_rest' ),
292                'nfdRestURL'     => \get_home_url() . '/index.php?rest_route=/nfd-editor-chat/v1',
293                'mcpUrl'         => \esc_url_raw( \rest_url( 'blu/mcp' ) ),
294                'configEndpoint' => \esc_url_raw( \rest_url( 'nfd-editor-chat/v1/config' ) ),
295                'homeUrl'        => \esc_url( \get_home_url() ),
296                'wpVer'          => \esc_html( \get_bloginfo( 'version' ) ),
297                'nfdChatVersion' => \esc_html( NFD_EDITOR_CHAT_VERSION ),
298                'model'          => defined( 'NFD_EDITOR_CHAT_MODEL' ) ? \NFD_EDITOR_CHAT_MODEL : '',
299                'site'           => self::get_site_context(),
300                'pagesCount'     => \array_sum( (array) \wp_count_posts( 'page' ) ),
301                'editorType'     => $editor_type,
302            );
303
304            $upgrade_banner_data = self::get_plan_upgrade_banner_data();
305            if ( $upgrade_banner_data ) {
306                $args['planUpgradeBanner'] = $upgrade_banner_data;
307            }
308
309            \wp_localize_script( 'nfd-editor-chat', 'nfdEditorChat', $args );
310
311            \wp_set_script_translations(
312                'nfd-editor-chat',
313                'nfd-editor-chat',
314                NFD_EDITOR_CHAT_DIR . '/languages'
315            );
316
317            \wp_enqueue_script( 'nfd-editor-chat' );
318            \wp_enqueue_style( 'nfd-editor-chat' );
319        }
320    }
321
322    /**
323     * Retrieve the upgrade banner data.
324     *
325     * @return array
326     */
327    private static function get_plan_upgrade_banner_data() {
328        static $data = null;
329
330        if ( is_null( $data ) ) {
331            $data      = array();
332            $plan_data = \get_option( 'wvc_plan_data', '{}' );
333            $plan_data = (bool) $plan_data && \is_string( $plan_data ) ? \json_decode( $plan_data, true ) : array();
334
335            if ( $plan_data ) {
336                $plan_data   = \is_array( $plan_data ) ? $plan_data : array();
337                $message     = \sanitize_text_field( $plan_data['infoBannerText'] ?? '' );
338                $upgrade_url = \esc_url_raw( $plan_data['upgrade_url'] ?? '' );
339
340                if ( $message && $upgrade_url ) {
341                    $data = array(
342                        'message'    => $message,
343                        'upgradeUrl' => $upgrade_url,
344                    );
345                }
346            }
347        }
348
349        return $data;
350    }
351
352    /**
353     * Filter default WP script translations file to load the correct one
354     *
355     * @param string $file   The translations file.
356     * @param string $handle Script handle.
357     * @param string $domain The strings textdomain.
358     *
359     * @return string
360     */
361    public static function load_script_translation_file( $file, $handle, $domain ) {
362
363        if ( 'nfd-editor-chat' === $handle ) {
364            $locale = \determine_locale();
365            $key    = \md5( 'build/' . NFD_EDITOR_CHAT_VERSION . '/chat-editor.js' );
366            $file   = NFD_EDITOR_CHAT_DIR . "/languages/{$domain}-{$locale}-{$key}.json";
367        }
368
369        return $file;
370    }
371
372    /**
373     * Add custom admin class on block editor pages.
374     *
375     * @param string $classes Body classes.
376     *
377     * @return string
378     */
379    public static function add_admin_body_class( $classes ) {
380        global $pagenow;
381        $current_screen = \get_current_screen();
382
383        if ( $current_screen && \method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) {
384            $classes .= ' nfd-editor-chat-enabled';
385
386            if ( \in_array( $pagenow, array( 'post.php', 'post-new.php' ), true ) ) {
387                $classes .= ' nfd-editor-chat--post-editor';
388            }
389
390            if ( self::get_plan_upgrade_banner_data() ) {
391                $classes .= ' nfd-editor-chat--has-plan-upgrade-banner';
392            }
393        }
394
395        return $classes;
396    }
397
398    /**
399     * Round the top block's selection outline to match the framed canvas corner
400     * (the outline is drawn inside the iframe, out of reach of our stylesheet).
401     *
402     * @param array                    $settings Block editor settings.
403     * @param \WP_Block_Editor_Context $context  Editor context.
404     *
405     * @return array
406     */
407    public static function add_editor_canvas_styles( $settings, $context ) {
408        if ( ! isset( $context->name ) || 'core/edit-site' !== $context->name ) {
409            return $settings;
410        }
411
412        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Referrer parameter is validated against allowed list, no data modification.
413        if ( ! isset( $_GET['referrer'] ) || ! \in_array( $_GET['referrer'], self::$allowed_referrers, true ) ) {
414            return $settings;
415        }
416
417        // 12px matches $radius in _main.scss.
418        $css = '.is-root-container > .block-editor-block-list__block:first-child,'
419            . '.is-root-container > .block-editor-block-list__block:first-child::before,'
420            . '.is-root-container > .block-editor-block-list__block:first-child::after'
421            . '{border-start-start-radius:12px;}';
422
423        $settings['styles'][] = array( 'css' => $css );
424
425        return $settings;
426    }
427
428    /**
429     * Get site context data for the AI assistant.
430     *
431     * @return array
432     */
433    private static function get_site_context() {
434        $onboarding = \get_option( 'nfd_module_onboarding_state_input', array() );
435
436        return array(
437            'title'          => \get_bloginfo( 'name' ),
438            'description'    => ! empty( $onboarding['prompt'] ) ? $onboarding['prompt'] : \get_bloginfo( 'description' ),
439            'siteType'       => $onboarding['siteType'] ?? '',
440            'locale'         => \get_locale(),
441            'classification' => \get_option( 'nfd-ai-site-gen-siteclassification', '' ),
442        );
443    }
444
445    /**
446     * Load text domain for Module
447     *
448     * @return void
449     */
450    public static function load_text_domain() {
451
452        \load_plugin_textdomain(
453            'nfd-editor-chat',
454            false,
455            NFD_EDITOR_CHAT_DIR . '/languages'
456        );
457
458        \load_script_textdomain(
459            'nfd-editor-chat',
460            'nfd-editor-chat',
461            NFD_EDITOR_CHAT_DIR . '/languages'
462        );
463    }
464
465    /**
466     * Add menu in admin bar.
467     *
468     * @param \WP_Admin_Bar $wp_admin_bar The admin bar.
469     */
470    public static function admin_bar_menu( $wp_admin_bar ) {
471        $icon = '<svg width="14" height="13" viewBox="0 0 14 13" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M4.99937 1.85729C5.108 1.85731 5.21368 1.8902 5.3004 1.95096C5.38712 2.01173 5.45017 2.09707 5.48001 2.19408L6.02198 3.95604C6.13867 4.33549 6.35759 4.68106 6.65806 4.9601C6.95853 5.23915 7.33063 5.44246 7.73922 5.55083L9.63645 6.05416C9.74084 6.08193 9.83266 6.14051 9.89802 6.22104C9.96338 6.30157 9.99874 6.39968 9.99874 6.50053C9.99874 6.60137 9.96338 6.69948 9.89802 6.78001C9.83266 6.86054 9.74084 6.91912 9.63645 6.9469L7.73922 7.45022C7.33063 7.5586 6.95853 7.76191 6.65806 8.04095C6.35759 8.31999 6.13867 8.66556 6.02198 9.04502L5.48001 10.807C5.4501 10.9039 5.38703 10.9892 5.30031 11.0499C5.2136 11.1106 5.10796 11.1434 4.99937 11.1434C4.89078 11.1434 4.78514 11.1106 4.69843 11.0499C4.61171 10.9892 4.54863 10.9039 4.51873 10.807L3.97676 9.04502C3.86006 8.66556 3.64115 8.31999 3.34068 8.04095C3.04021 7.76191 2.66811 7.5586 2.25952 7.45022L0.362287 6.9469C0.257896 6.91912 0.166078 6.86054 0.100716 6.78001C0.0353537 6.69948 0 6.60137 0 6.50053C0 6.39968 0.0353537 6.30157 0.100716 6.22104C0.166078 6.14051 0.257896 6.08193 0.362287 6.05416L2.25952 5.55083C2.66811 5.44246 3.04021 5.23915 3.34068 4.9601C3.64115 4.68106 3.86006 4.33549 3.97676 3.95604L4.51873 2.19408C4.54857 2.09707 4.61162 2.01173 4.69834 1.95096C4.78506 1.8902 4.89073 1.85731 4.99937 1.85729ZM10.999 7.20546e-08C11.1106 -5.7614e-05 11.2189 0.0345233 11.3069 0.0982423C11.3948 0.161961 11.4573 0.251159 11.4844 0.351648L11.6563 0.993033C11.8137 1.57499 12.303 2.0294 12.9296 2.17551L13.6202 2.33524C13.7286 2.36018 13.8249 2.41811 13.8938 2.49979C13.9626 2.58148 14 2.68222 14 2.78594C14 2.88966 13.9626 2.9904 13.8938 3.07209C13.8249 3.15377 13.7286 3.21171 13.6202 3.23664L12.9296 3.39637C12.303 3.54248 11.8137 3.9969 11.6563 4.57885L11.4844 5.22023C11.4575 5.32091 11.3951 5.41035 11.3072 5.47427C11.2192 5.53819 11.1107 5.57292 10.999 5.57292C10.8874 5.57292 10.7789 5.53819 10.6909 5.47427C10.603 5.41035 10.5406 5.32091 10.5137 5.22023L10.3417 4.57885C10.2648 4.29309 10.1057 4.03212 9.88145 3.82384C9.65718 3.61556 9.37618 3.46781 9.06848 3.39637L8.37785 3.23664C8.26944 3.21171 8.17314 3.15377 8.10431 3.07209C8.03548 2.9904 7.99809 2.88966 7.99809 2.78594C7.99809 2.68222 8.03548 2.58148 8.10431 2.49979C8.17314 2.41811 8.26944 2.36018 8.37785 2.33524L9.06848 2.17551C9.37618 2.10407 9.65718 1.95632 9.88145 1.74804C10.1057 1.53976 10.2648 1.27879 10.3417 0.993033L10.5137 0.351648C10.5408 0.251159 10.6033 0.161961 10.6912 0.0982423C10.7792 0.0345233 10.8875 -5.7614e-05 10.999 7.20546e-08ZM9.9991 8.35782C10.1041 8.35776 10.2065 8.38841 10.2917 8.44542C10.3769 8.50243 10.4406 8.5829 10.4737 8.67542L10.7364 9.40781C10.8364 9.68455 11.0697 9.90247 11.3684 9.99471L12.157 10.2393C12.2563 10.2702 12.3426 10.3294 12.4038 10.4083C12.4649 10.4873 12.4978 10.5822 12.4978 10.6794C12.4978 10.7767 12.4649 10.8715 12.4038 10.9505C12.3426 11.0295 12.2563 11.0887 12.157 11.1196L11.3684 11.3642C11.0704 11.457 10.8357 11.6737 10.7364 11.9511L10.4731 12.6835C10.4397 12.7757 10.376 12.8559 10.291 12.9126C10.206 12.9694 10.1038 13 9.9991 13C9.89435 13 9.79224 12.9694 9.70719 12.9126C9.62215 12.8559 9.55846 12.7757 9.52512 12.6835L9.2618 11.9511C9.2127 11.8144 9.13004 11.6903 9.02035 11.5884C8.91067 11.4865 8.77698 11.4098 8.62984 11.3642L7.84121 11.1196C7.74191 11.0887 7.65558 11.0295 7.59443 10.9505C7.53327 10.8715 7.50037 10.7767 7.50037 10.6794C7.50037 10.5822 7.53327 10.4873 7.59443 10.4083C7.65558 10.3294 7.74191 10.2702 7.84121 10.2393L8.62984 9.99471C8.92782 9.90185 9.16248 9.68517 9.2618 9.40781L9.52512 8.67542C9.55827 8.583 9.62187 8.5026 9.70693 8.4456C9.792 8.3886 9.89421 8.35789 9.9991 8.35782Z" fill="white"/></svg>';
472        // translators: %s is the "Bluehost" brand name and should not be translated. Example: "Bluehost AI Editor".
473        $title = \sprintf( __( '%s AI Editor', 'nfd-editor-chat' ), 'Bluehost' );
474
475        $editor_args = array(
476            'canvas'   => 'edit',
477            'referrer' => 'nfd-editor-chat',
478        );
479        $url         = \add_query_arg( $editor_args, \admin_url( 'site-editor.php' ) );
480
481        $args = array(
482            'id'     => 'nfd-editor-chat',
483            'parent' => 'top-secondary',
484            'title'  => $icon . $title,
485            'href'   => $url,
486        );
487
488        $wp_admin_bar->add_node( $args );
489    }
490
491    /**
492     * Enqueue styles for admin-bar.
493     */
494    public static function enqueue_admin_bar_assets() {
495        if ( is_admin_bar_showing() ) {
496            \wp_enqueue_style( 'nfd-editor-chat-admin-bar', \NFD_EDITOR_CHAT_ASSETS_URL . 'css/admin-bar.css', array(), NFD_EDITOR_CHAT_VERSION );
497        }
498    }
499
500    /**
501     * Upload a file to temporary storage (not Media Library).
502     *
503     * @param \WP_REST_Request $request The REST request.
504     * @return \WP_REST_Response|\WP_Error
505     */
506    public static function upload_temp_file( \WP_REST_Request $request ) {
507        $files = $request->get_file_params();
508
509        if ( empty( $files['file'] ) || ! empty( $files['file']['error'] ) ) {
510            return new \WP_Error(
511                'no_file',
512                __( 'No file provided or upload error.', 'nfd-editor-chat' ),
513                array( 'status' => 400 )
514            );
515        }
516
517        $file = $files['file'];
518
519        $allowed_mime_types = array(
520            'image/png',
521            'image/jpeg',
522            'image/webp',
523            'image/gif',
524            'application/pdf',
525            'text/plain',
526            'text/csv',
527            'text/markdown',
528        );
529
530        if ( ! in_array( $file['type'], $allowed_mime_types, true ) ) {
531            return new \WP_Error(
532                'invalid_file_type',
533                __( 'File type not allowed.', 'nfd-editor-chat' ),
534                array( 'status' => 400 )
535            );
536        }
537
538        $upload_dir = \wp_upload_dir();
539        $temp_dir   = $upload_dir['basedir'] . '/nfd-chat-temp/';
540        $temp_url   = $upload_dir['baseurl'] . '/nfd-chat-temp/';
541
542        if ( ! \file_exists( $temp_dir ) ) {
543            \wp_mkdir_p( $temp_dir );
544            // Prevent directory listing and PHP execution.
545            \file_put_contents( $temp_dir . 'index.php', '<?php // Silence is golden.' );
546        }
547
548        $filename = \wp_unique_filename( $temp_dir, \sanitize_file_name( $file['name'] ) );
549        $filepath = $temp_dir . $filename;
550
551        if ( ! \move_uploaded_file( $file['tmp_name'], $filepath ) ) {
552            return new \WP_Error(
553                'upload_failed',
554                __( 'Failed to save uploaded file.', 'nfd-editor-chat' ),
555                array( 'status' => 500 )
556            );
557        }
558
559        $file_url = $temp_url . $filename;
560
561        // Track file for cleanup after 24h.
562        \set_transient( 'nfd_chat_temp_' . \md5( $filename ), $filepath, \DAY_IN_SECONDS * 3 );
563
564        return new \WP_REST_Response(
565            array(
566                'url'      => $file_url,
567                'filename' => $filename,
568                'name'     => $file['name'],
569                'type'     => $file['type'],
570                'size'     => $file['size'],
571            ),
572            201
573        );
574    }
575
576    /**
577     * Delete a temporary uploaded file.
578     *
579     * @param \WP_REST_Request $request The REST request.
580     * @return \WP_REST_Response|\WP_Error
581     */
582    public static function delete_temp_file( \WP_REST_Request $request ) {
583        $filename = $request->get_param( 'filename' );
584
585        $upload_dir = \wp_upload_dir();
586        $filepath   = $upload_dir['basedir'] . '/nfd-chat-temp/' . \sanitize_file_name( $filename );
587
588        if ( ! \file_exists( $filepath ) ) {
589            return new \WP_Error(
590                'file_not_found',
591                __( 'File not found.', 'nfd-editor-chat' ),
592                array( 'status' => 404 )
593            );
594        }
595
596        \wp_delete_file( $filepath );
597        \delete_transient( 'nfd_chat_temp_' . \md5( $filename ) );
598
599        return new \WP_REST_Response( null, 204 );
600    }
601}