diff --git a/includes/Classifai/Blocks/key-takeaways/render.php b/includes/Classifai/Blocks/key-takeaways/render.php index a755c864b..5614a363a 100644 --- a/includes/Classifai/Blocks/key-takeaways/render.php +++ b/includes/Classifai/Blocks/key-takeaways/render.php @@ -30,23 +30,7 @@
'; - foreach ( (array) $takeaways as $takeaway ) { - printf( - '
  • %s
  • ', - esc_html( $takeaway ) - ); - } - echo ''; - } else { - foreach ( (array) $takeaways as $takeaway ) { - printf( - '

    %s

    ', - esc_html( $takeaway ) - ); - } - } + echo wp_kses_post( ( new \Classifai\Features\KeyTakeaways() )->render_takeaways_html( (array) $takeaways, $layout ) ); ?>
    diff --git a/includes/Classifai/Features/KeyTakeaways.php b/includes/Classifai/Features/KeyTakeaways.php index 6ac6c1e54..fb0362cb0 100644 --- a/includes/Classifai/Features/KeyTakeaways.php +++ b/includes/Classifai/Features/KeyTakeaways.php @@ -29,6 +29,22 @@ class KeyTakeaways extends Feature { */ const ID = 'feature_key_takeaways'; + /** + * Meta key storing the generated takeaways. + * + * @var string + */ + const TAKEAWAYS_META_KEY = '_classifai_key_takeaways'; + + /** + * Meta key storing the content hash at the time takeaways were generated. + * + * Used to invalidate stored takeaways when a post is edited. + * + * @var string + */ + const TAKEAWAYS_HASH_KEY = '_classifai_key_takeaways_hash'; + /** * Constructor. */ @@ -60,6 +76,11 @@ public function setup() { if ( $this->is_configured() && $this->is_enabled() ) { add_action( 'enqueue_block_assets', array( $this, 'enqueue_editor_assets' ) ); $this->register_block(); + + if ( 'on_demand' === $this->get_generation_timing() ) { + add_filter( 'the_content', array( $this, 'render_takeaways_button' ) ); + add_action( 'save_post', array( $this, 'maybe_invalidate_on_demand_takeaways' ), 20 ); + } } } @@ -165,6 +186,358 @@ public function register_endpoints() { ), ) ); + + // Public, front-end on-demand generation route. + register_rest_route( + 'classifai/v1', + 'key-takeaways-on-demand/(?P\d+)', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'generate_key_takeaways_on_demand' ), + 'args' => array( + 'id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'ID of the published post to generate key takeaways for.', 'classifai' ), + ), + ), + 'permission_callback' => array( $this, 'on_demand_takeaways_permissions_check' ), + ), + ) + ); + } + + /** + * Permission check for the public, front-end on-demand generation route. + * + * Unlike {@see generate_key_takeaways_permissions_check()}, this route is + * reachable by anonymous visitors, so it is gated on the feature being + * enabled and in on-demand mode, the target being a published supported + * post, and a valid REST nonce. + * + * Because takeaways are generated at most once per post, the cost ceiling is + * one generation per published post. Site owners can tighten or loosen this + * via the `classifai_key_takeaways_on_demand_permission` filter. + * + * @param WP_REST_Request $request Full data about the request. + * @return bool|WP_Error + */ + public function on_demand_takeaways_permissions_check( WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'id' ); + $post = $post_id ? get_post( $post_id ) : null; + + $allowed = ( + $post instanceof \WP_Post && + 'publish' === $post->post_status && + in_array( $post->post_type, $this->get_supported_post_types(), true ) && + $this->is_enabled() && + 'on_demand' === $this->get_generation_timing() && + false !== wp_verify_nonce( (string) $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) + ); + + /** + * Filter the permission check for on-demand front-end key takeaways generation. + * + * Return true to allow, or false / a WP_Error to deny. Use this to, for + * example, restrict generation to logged-in users only. + * + * @since x.x.x + * @hook classifai_key_takeaways_on_demand_permission + * + * @param bool $allowed Whether the request is allowed. + * @param int $post_id The post ID takeaways are being generated for. + * @param WP_REST_Request $request The REST request. + * + * @return bool|WP_Error Whether the request is allowed. + */ + return apply_filters( 'classifai_key_takeaways_on_demand_permission', $allowed, $post_id, $request ); + } + + /** + * Handle a front-end on-demand key takeaways generation request. + * + * @param WP_REST_Request $request Full data about the request. + * @return \WP_REST_Response + */ + public function generate_key_takeaways_on_demand( WP_REST_Request $request ) { + $post_id = (int) $request->get_param( 'id' ); + $render = $this->get_settings( 'render' ); + $render = in_array( $render, array( 'list', 'paragraph' ), true ) ? $render : 'list'; + + // Takeaways may already exist, serve them without regenerating. + $stored = get_post_meta( $post_id, self::TAKEAWAYS_META_KEY, true ); + if ( ! empty( $stored ) && is_array( $stored ) ) { + return rest_ensure_response( + array( + 'success' => true, + 'takeaways' => $stored, + 'html' => $this->render_takeaways_html( $stored, $render ), + ) + ); + } + + $lock_key = 'classifai_key_takeaways_on_demand_lock_' . $post_id; + + // Only allow one request to generate at a time. + if ( get_transient( $lock_key ) ) { + return rest_ensure_response( + array( + 'success' => false, + 'inProgress' => true, + 'code' => 'generation_in_progress', + 'message' => esc_html__( 'Key takeaways are already being generated. Please try again in a moment.', 'classifai' ), + ) + ); + } + + set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS ); + + // Generate as the post author so the feature's per-user access + // check passes for anonymous front-end visitors. + $post = get_post( $post_id ); + $original_user_id = get_current_user_id(); + wp_set_current_user( $post ? (int) $post->post_author : $original_user_id ); + + $result = $this->run( + $post_id, + 'key_takeaways', + array( + 'render' => $render, + 'run' => 'manual', + ) + ); + + wp_set_current_user( $original_user_id ); + + delete_transient( $lock_key ); + + if ( is_wp_error( $result ) ) { + return rest_ensure_response( + array( + 'success' => false, + 'code' => 'generation_failed', + 'message' => $result->get_error_message(), + ) + ); + } + + $takeaways = (array) $result; + + if ( empty( $takeaways ) ) { + return rest_ensure_response( + array( + 'success' => false, + 'code' => 'generation_failed', + 'message' => esc_html__( 'Key takeaways could not be generated.', 'classifai' ), + ) + ); + } + + $this->store_takeaways( $post_id, $takeaways ); + + return rest_ensure_response( + array( + 'success' => true, + 'takeaways' => $takeaways, + 'html' => $this->render_takeaways_html( $takeaways, $render ), + ) + ); + } + + /** + * Clear stored takeaways when an on-demand post's content changes. + * + * @param int $post_id The post ID being saved. + */ + public function maybe_invalidate_on_demand_takeaways( int $post_id ) { + if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || 'revision' === get_post_type( $post_id ) ) { + return; + } + + if ( + 'on_demand' !== $this->get_generation_timing() || + ! in_array( get_post_type( $post_id ), $this->get_supported_post_types(), true ) || + ! $this->is_enabled() + ) { + return; + } + + $stored = get_post_meta( $post_id, self::TAKEAWAYS_META_KEY, true ); + + if ( empty( $stored ) ) { + return; + } + + $stored_hash = get_post_meta( $post_id, self::TAKEAWAYS_HASH_KEY, true ); + $current_hash = $this->get_content_hash( $post_id ); + + // Content unchanged, keep the existing takeaways. + if ( ! empty( $stored_hash ) && $stored_hash === $current_hash ) { + return; + } + + delete_post_meta( $post_id, self::TAKEAWAYS_META_KEY ); + delete_post_meta( $post_id, self::TAKEAWAYS_HASH_KEY ); + } + + /** + * Returns a hash of the post content and title, used to detect edits. + * + * @param int $post_id The post ID. + * @return string + */ + public function get_content_hash( int $post_id ): string { + $post = get_post( $post_id ); + + if ( ! $post instanceof \WP_Post ) { + return ''; + } + + return md5( $post->post_content . '|' . $post->post_title ); + } + + /** + * Persist generated takeaways (and the content hash) to post meta. + * + * @param int $post_id The post ID. + * @param array $takeaways Array of takeaway strings. + */ + public function store_takeaways( int $post_id, array $takeaways ) { + update_post_meta( $post_id, self::TAKEAWAYS_META_KEY, $takeaways ); + update_post_meta( $post_id, self::TAKEAWAYS_HASH_KEY, $this->get_content_hash( $post_id ) ); + } + + /** + * Render the inner markup for a set of takeaways. + * + * @param array $takeaways Array of takeaway strings. + * @param string $render Either `list` or `paragraph`. + * @return string + */ + public function render_takeaways_html( array $takeaways, string $render = 'list' ): string { + if ( empty( $takeaways ) ) { + return ''; + } + + ob_start(); + + if ( 'list' === $render ) { + echo ''; + } else { + foreach ( $takeaways as $takeaway ) { + printf( '

    %s

    ', esc_html( $takeaway ) ); + } + } + + return (string) ob_get_clean(); + } + + /** + * Render the on-demand "Key Takeaways" button on the front-end of singular posts. + * + * @param string $content The post content. + * @return string + */ + public function render_takeaways_button( string $content ): string { + $post = get_post(); + + if ( + ! $post instanceof \WP_Post || + ! is_singular( $post->post_type ) || + ! in_array( $post->post_type, $this->get_supported_post_types(), true ) || + ! in_the_loop() || + ! is_main_query() + ) { + return $content; + } + + // If the post already includes the Key Takeaways block, it renders its + // own takeaways inline, so the on-demand button would be redundant. + if ( has_block( 'classifai/key-takeaways', $post ) ) { + return $content; + } + + $render = $this->get_settings( 'render' ); + $render = in_array( $render, array( 'list', 'paragraph' ), true ) ? $render : 'list'; + + $label = $this->get_settings( 'button_label' ); + $label = is_string( $label ) && '' !== $label ? $label : esc_html__( 'Key Takeaways', 'classifai' ); + + $stored = get_post_meta( $post->ID, self::TAKEAWAYS_META_KEY, true ); + $has_results = ! empty( $stored ) && is_array( $stored ); + $panel_html = $has_results ? $this->render_takeaways_html( $stored, $render ) : ''; + + $this->enqueue_frontend_assets(); + + $panel_id = 'classifai-key-takeaways-panel-' . $post->ID; + + ob_start(); + ?> +
    + + +
    + ID ); + + return 'bottom' === $position ? $content . $button : $button . $content; + } + + /** + * Enqueue the front-end script and style for the on-demand button. + */ + public function enqueue_frontend_assets() { + wp_enqueue_script( + 'classifai-plugin-key-takeaways-frontend-js', + CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-key-takeaways-frontend.js', + get_asset_info( 'classifai-plugin-key-takeaways-frontend', 'dependencies' ), + get_asset_info( 'classifai-plugin-key-takeaways-frontend', 'version' ), + true + ); + + wp_enqueue_style( + 'classifai-plugin-key-takeaways-frontend-css', + CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-key-takeaways-frontend.css', + array(), + get_asset_info( 'classifai-plugin-key-takeaways-frontend', 'version' ), + 'all' + ); } /** @@ -211,18 +584,34 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { $route = $request->get_route(); if ( strpos( $route, '/classifai/v1/key-takeaways' ) === 0 ) { - return rest_ensure_response( - $this->run( - $request->get_param( 'id' ), - 'key_takeaways', - array( - 'content' => $request->get_param( 'content' ), - 'title' => $request->get_param( 'title' ), - 'render' => $request->get_param( 'render' ), - 'run' => $request->get_param( 'run' ), - ) + $post_id = $request->get_param( 'id' ); + $run = $request->get_param( 'run' ); + + // Reuse previously generated takeaways unless manually requested. + if ( 'manual' !== $run ) { + $stored = get_post_meta( $post_id, self::TAKEAWAYS_META_KEY, true ); + + if ( ! empty( $stored ) && is_array( $stored ) ) { + return rest_ensure_response( $stored ); + } + } + + $result = $this->run( + $post_id, + 'key_takeaways', + array( + 'content' => $request->get_param( 'content' ), + 'title' => $request->get_param( 'title' ), + 'render' => $request->get_param( 'render' ), + 'run' => $run, ) ); + + if ( ! is_wp_error( $result ) && is_array( $result ) && ! empty( $result ) ) { + $this->store_takeaways( $post_id, $result ); + } + + return rest_ensure_response( $result ); } return parent::rest_endpoint_callback( $request ); @@ -270,10 +659,43 @@ public function get_feature_default_settings(): array { 'original' => 1, ), ), + 'generation_timing' => 'manual', + 'post_types' => array( + 'post' => 'post', + ), + 'render' => 'list', + 'button_label' => esc_html__( 'Key Takeaways', 'classifai' ), 'provider' => ChatGPT::ID, ); } + /** + * Returns the configured generation timing mode. + * + * @return string One of `manual`, `on_demand`. + */ + public function get_generation_timing(): string { + $timing = $this->get_settings( 'generation_timing' ); + + return array_key_exists( $timing, $this->get_generation_timing_options() ) ? $timing : 'manual'; + } + + /** + * Returns the supported generation timing modes. + * + * - `manual`: editors add the Key Takeaways block to generate takeaways. + * - `on_demand`: a front-end button generates takeaways the first time a + * visitor requests them, then stores the result for reuse. + * + * @return array + */ + public function get_generation_timing_options(): array { + return array( + 'manual' => __( 'Manual (add the Key Takeaways block to a post)', 'classifai' ), + 'on_demand' => __( 'On demand (generate on first front-end request)', 'classifai' ), + ); + } + /** * Returns the settings for the feature. * @@ -305,6 +727,24 @@ public function get_settings( $index = false ) { public function sanitize_default_feature_settings( array $new_settings ): array { $new_settings['key_takeaways_prompt'] = sanitize_prompts( 'key_takeaways_prompt', $new_settings ); + $timing = $new_settings['generation_timing'] ?? 'manual'; + $new_settings['generation_timing'] = array_key_exists( $timing, $this->get_generation_timing_options() ) ? $timing : 'manual'; + + $render = $new_settings['render'] ?? 'list'; + $new_settings['render'] = in_array( $render, array( 'list', 'paragraph' ), true ) ? $render : 'list'; + + $label = isset( $new_settings['button_label'] ) ? sanitize_text_field( $new_settings['button_label'] ) : ''; + $new_settings['button_label'] = '' !== $label ? $label : esc_html__( 'Key Takeaways', 'classifai' ); + + $post_types = \Classifai\get_post_types_for_language_settings(); + foreach ( $post_types as $post_type ) { + if ( ! isset( $new_settings['post_types'][ $post_type->name ] ) ) { + $new_settings['post_types'][ $post_type->name ] = ''; + } else { + $new_settings['post_types'][ $post_type->name ] = sanitize_text_field( $new_settings['post_types'][ $post_type->name ] ); + } + } + return $new_settings; } } diff --git a/package.json b/package.json index 2a9c9da8e..3354467b4 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "scripts": { "start": "wp-scripts start", "build": "wp-scripts build", - "lint:js": "wp-scripts lint-js ./src ./includes/Classifai/Blocks", - "lint:js:fix": "wp-scripts lint-js ./src ./includes/Classifai/Blocks --fix", + "lint:js": "wp-scripts lint-js ./src ./includes/Classifai/Blocks ./webpack.config.js", + "lint:js:fix": "wp-scripts lint-js ./src ./includes/Classifai/Blocks ./webpack.config.js --fix", "typecheck": "tsc --noEmit", "install_tests": "./bin/install-wp-tests.sh classifai_unit_tests root password 127.0.0.1", "test:php": "wp-env run tests-cli --env-cwd=wp-content/plugins/classifai vendor/bin/phpunit -c phpunit.xml.dist", diff --git a/src/js/features/key-takeaways/frontend/index.js b/src/js/features/key-takeaways/frontend/index.js new file mode 100644 index 000000000..b2d6e923b --- /dev/null +++ b/src/js/features/key-takeaways/frontend/index.js @@ -0,0 +1,122 @@ +/** + * Internal dependencies + */ +import './index.scss'; + +/** + * Wires up the on-demand "Key Takeaways" buttons rendered on the front-end. + * + * Each button toggles a disclosure panel. The first time it is activated and + * no takeaways exist yet, it requests generation from the REST endpoint, injects + * the returned markup, then reveals the panel. Subsequent activations simply + * toggle the (already populated) panel. + */ +document + .querySelectorAll( '.classifai-key-takeaways-toggle' ) + .forEach( ( toggleEl ) => { + const panelEl = document.getElementById( + toggleEl.getAttribute( 'aria-controls' ) + ); + + if ( ! panelEl ) { + return; + } + + let isGenerating = false; + + /** + * Shows or hides the takeaways panel and syncs ARIA state. + * + * @param {boolean} expanded Whether the panel should be expanded. + */ + function setExpanded( expanded ) { + toggleEl.setAttribute( + 'aria-expanded', + expanded ? 'true' : 'false' + ); + panelEl.hidden = ! expanded; + } + + /** + * Generates takeaways on demand, then reveals them. + */ + async function generateAndShow() { + if ( isGenerating ) { + return; + } + + isGenerating = true; + toggleEl.classList.remove( 'has-error' ); + toggleEl.classList.add( 'is-generating' ); + + const labelEl = toggleEl.querySelector( + '.classifai-key-takeaways-toggle__label' + ); + const originalLabel = labelEl ? labelEl.textContent : ''; + + if ( labelEl && toggleEl.dataset.generatingLabel ) { + labelEl.textContent = toggleEl.dataset.generatingLabel; + } + + try { + // Send the REST (`wp_rest`) nonce so logged-in users' requests + // authenticate; without it WordPress rejects the cookie with a + // 403 before our permission callback runs. + const response = await fetch( toggleEl.dataset.restUrl, { + method: 'POST', + headers: { 'X-WP-Nonce': toggleEl.dataset.nonce }, + } ); + const data = await response.json(); + + if ( data && data.success && data.html ) { + panelEl.innerHTML = data.html; + toggleEl.dataset.hasTakeaways = '1'; + + if ( labelEl ) { + labelEl.textContent = originalLabel; + } + + setExpanded( true ); + } else { + throw new Error( + data && data.message + ? data.message + : 'generation_failed' + ); + } + } catch { + toggleEl.classList.add( 'has-error' ); + + if ( labelEl ) { + labelEl.textContent = + toggleEl.dataset.errorLabel || originalLabel; + } + } finally { + isGenerating = false; + toggleEl.classList.remove( 'is-generating' ); + } + } + + /** + * Handles activation of the toggle. + */ + function handleActivate() { + if ( isGenerating ) { + return; + } + + // Generate on demand the first time, otherwise just toggle the panel. + if ( + '1' !== toggleEl.dataset.hasTakeaways && + toggleEl.dataset.restUrl + ) { + generateAndShow(); + } else { + setExpanded( + 'true' !== toggleEl.getAttribute( 'aria-expanded' ) + ); + } + } + + toggleEl.addEventListener( 'click', handleActivate ); + } ); diff --git a/src/js/features/key-takeaways/frontend/index.scss b/src/js/features/key-takeaways/frontend/index.scss new file mode 100644 index 000000000..2094c4464 --- /dev/null +++ b/src/js/features/key-takeaways/frontend/index.scss @@ -0,0 +1,109 @@ +.classifai-key-takeaways-wrapper { + margin: 0 0 1.5em; + + .classifai-key-takeaways-toggle { + display: inline-flex; + align-items: center; + gap: 0.5em; + margin: 0; + padding: 0.5em 1em; + font: inherit; + font-weight: 600; + line-height: 1.2; + color: inherit; + background: transparent; + border: 1px solid currentColor; + cursor: pointer; + transition: + background-color 0.15s ease, + box-shadow 0.15s ease, + opacity 0.15s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } + + &:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; + } + + // Chevron indicates the control expands/collapses a panel. + .classifai-key-takeaways-toggle__chevron { + width: 0.5em; + height: 0.5em; + margin-top: -0.18em; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + transition: transform 0.2s ease; + } + + &[aria-expanded='true'] .classifai-key-takeaways-toggle__chevron { + margin-top: 0.12em; + transform: rotate(-135deg); + } + + // While generating, swap the chevron for a spinner. + &.is-generating { + cursor: progress; + + .classifai-key-takeaways-toggle__chevron { + display: none; + } + + .classifai-key-takeaways-spinner { + display: inline-block; + } + } + + &.has-error { + border-color: #cc1818; + + .classifai-key-takeaways-toggle__label { + color: #cc1818; + } + } + } + + .classifai-key-takeaways-spinner { + display: none; + width: 1em; + height: 1em; + border: 2px solid rgba(0, 0, 0, 0.2); + border-top-color: currentColor; + border-radius: 50%; + animation: classifai-key-takeaways-spin 0.8s linear infinite; + } + + .classifai-key-takeaways-panel { + margin-top: 1em; + padding: 0.25em 0 0.25em 1.25em; + border-left: 3px solid rgba(0, 0, 0, 0.15); + + > ul, + > ol { + margin: 0; + padding-left: 1.25em; + } + + li + li, + p + p { + margin-top: 0.5em; + } + + p:first-child { + margin-top: 0; + } + + p:last-child { + margin-bottom: 0; + } + } +} + +@keyframes classifai-key-takeaways-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/js/settings/components/feature-additional-settings/key-takeaways.js b/src/js/settings/components/feature-additional-settings/key-takeaways.js index f8fa72eaf..5bfbe6416 100644 --- a/src/js/settings/components/feature-additional-settings/key-takeaways.js +++ b/src/js/settings/components/feature-additional-settings/key-takeaways.js @@ -2,6 +2,11 @@ * WordPress dependencies */ import { useSelect, useDispatch } from '@wordpress/data'; +import { + CheckboxControl, + SelectControl, + TextControl, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; /** @@ -24,14 +29,140 @@ export const KeyTakeawaysSettings = () => { select( STORE_NAME ).getFeatureSettings() ); const { setFeatureSettings } = useDispatch( STORE_NAME ); + const { postTypes } = window.classifAISettings; const setPrompts = ( prompts ) => { setFeatureSettings( { key_takeaways_prompt: prompts, } ); }; + const generationTiming = featureSettings.generation_timing || 'manual'; + return ( <> + + { + setFeatureSettings( { + generation_timing: value, + } ); + } } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + { 'on_demand' === generationTiming && ( + <> + + { Object.keys( postTypes || {} ).map( ( key ) => { + return ( + { + setFeatureSettings( { + post_types: { + ...featureSettings.post_types, + [ key ]: value ? key : '0', + }, + } ); + } } + __nextHasNoMarginBottom + /> + ); + } ) } + + + + { + setFeatureSettings( { + button_label: value, + } ); + } } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + + { + setFeatureSettings( { + render: value, + } ); + } } + __nextHasNoMarginBottom + __next40pxDefaultSize + /> + + + ) } + http_calls = 0; + + parent::tear_down(); + } + + /** + * Mock the Key Takeaways feature settings. + * + * @param string $generation_timing Generation timing mode. + * @param array $post_types Allowed post types map. + */ + private function mock_settings( string $generation_timing = 'on_demand', array $post_types = array( 'post' => 'post' ) ): void { + add_filter( + 'pre_option_classifai_feature_key_takeaways', + function () use ( $generation_timing, $post_types ) { + return array( + 'status' => '1', + 'roles' => array( 'administrator' ), + 'users' => array(), + 'user_based_opt_out' => '0', + 'provider' => ChatGPT::ID, + 'generation_timing' => $generation_timing, + 'post_types' => $post_types, + 'render' => 'list', + 'button_label' => 'Key Takeaways', + 'key_takeaways_prompt' => array(), + ChatGPT::ID => array( + 'authenticated' => true, + ), + ); + } + ); + } + + /** + * Mock the OpenAI chat completions endpoint with a takeaways response. + */ + private function mock_openai_response(): void { + add_filter( + 'pre_http_request', + function ( $preempt, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.openai.com/v1/chat/completions' ) ) { + return $preempt; + } + + ++$this->http_calls; + + return array( + 'headers' => array( 'content-type' => 'application/json' ), + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'body' => wp_json_encode( + array( + 'choices' => array( + array( + 'message' => array( + 'content' => wp_json_encode( + array( + 'takeaways' => array( + 'First takeaway.', + 'Second takeaway.', + ), + ) + ), + ), + ), + ), + ) + ), + ); + }, + 10, + 3 + ); + } + + /** + * Build an on-demand REST request. + * + * @param int $post_id Post ID. + * @param string|null $nonce Optional nonce to attach. + * @return WP_REST_Request + */ + private function on_demand_request( int $post_id, $nonce = null ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/classifai/v1/key-takeaways-on-demand/' . $post_id ); + $request->set_param( 'id', $post_id ); + + if ( null !== $nonce ) { + $request->set_header( 'X-WP-Nonce', $nonce ); + } + + return $request; + } + + /** + * Permission check passes for an anonymous visitor with a valid nonce. + */ + public function test_permission_allows_anonymous_with_valid_nonce() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings(); + wp_set_current_user( 0 ); + + $feature = new KeyTakeaways(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertTrue( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * Permission check fails without a valid nonce. + */ + public function test_permission_denies_missing_nonce() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings(); + + $feature = new KeyTakeaways(); + + $this->assertFalse( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id ) ) ); + } + + /** + * Permission check fails when the feature is not in on-demand mode. + */ + public function test_permission_denies_wrong_mode() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings( 'manual' ); + + $feature = new KeyTakeaways(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * Permission check fails for an unpublished post. + */ + public function test_permission_denies_unpublished_post() { + $post_id = $this->factory->post->create( array( 'post_status' => 'draft' ) ); + $this->mock_settings(); + + $feature = new KeyTakeaways(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * Permission check fails for an unsupported post type. + */ + public function test_permission_denies_unsupported_post_type() { + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); + $this->mock_settings( 'on_demand', array( 'post' => 'post' ) ); + + $feature = new KeyTakeaways(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * The permission filter can override the default decision. + */ + public function test_permission_filter_override() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings(); + add_filter( 'classifai_key_takeaways_on_demand_permission', '__return_false' ); + + $feature = new KeyTakeaways(); + $nonce = wp_create_nonce( 'wp_rest' ); + + $this->assertFalse( $feature->on_demand_takeaways_permissions_check( $this->on_demand_request( $post_id, $nonce ) ) ); + } + + /** + * The handler generates, stores and returns takeaways. + */ + public function test_handler_generates_and_stores() { + $author_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_author' => $author_id, + 'post_content' => 'This article explains structured outputs and why they help parsing.', + ) + ); + $this->mock_settings(); + $this->mock_openai_response(); + + $feature = new KeyTakeaways(); + $response = $feature->generate_key_takeaways_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), $data['takeaways'] ); + $this->assertStringContainsString( 'First takeaway.', $data['html'] ); + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, true ) ); + $this->assertNotEmpty( get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_HASH_KEY, true ) ); + $this->assertSame( 1, $this->http_calls ); + } + + /** + * A second request serves stored takeaways without regenerating. + */ + public function test_handler_returns_cached_without_regenerating() { + $author_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_author' => $author_id, + 'post_content' => 'This article explains structured outputs and why they help parsing.', + ) + ); + $this->mock_settings(); + $this->mock_openai_response(); + + $feature = new KeyTakeaways(); + $feature->generate_key_takeaways_on_demand( $this->on_demand_request( $post_id ) ); + $response = $feature->generate_key_takeaways_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertTrue( $data['success'] ); + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), $data['takeaways'] ); + $this->assertSame( 1, $this->http_calls, 'Provider should only be hit once.' ); + } + + /** + * Render the on-demand button against the main loop for a post. + * + * @param int $post_id Post ID to view. + * @return string The filtered content. + */ + private function render_button_in_loop( int $post_id ): string { + $feature = new KeyTakeaways(); + $output = ''; + + $this->go_to( get_permalink( $post_id ) ); + + while ( have_posts() ) { + the_post(); + $output = $feature->render_takeaways_button( 'POST_CONTENT' ); + } + + wp_reset_postdata(); + + return $output; + } + + /** + * The button renders on a supported post without the block. + */ + public function test_button_renders_without_block() { + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Just some plain content.', + ) + ); + $this->mock_settings(); + + $output = $this->render_button_in_loop( $post_id ); + + $this->assertStringContainsString( 'classifai-key-takeaways-toggle', $output ); + } + + /** + * The button is suppressed when the post already contains the block. + */ + public function test_button_suppressed_when_block_present() { + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => '', + ) + ); + $this->mock_settings(); + + $output = $this->render_button_in_loop( $post_id ); + + $this->assertStringNotContainsString( 'classifai-key-takeaways-toggle', $output ); + $this->assertSame( 'POST_CONTENT', $output ); + } + + /** + * A concurrent request is collapsed by the per-post lock. + */ + public function test_handler_locks_concurrent_requests() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings(); + $this->mock_openai_response(); + + set_transient( 'classifai_key_takeaways_on_demand_lock_' . $post_id, 1, 5 * MINUTE_IN_SECONDS ); + + $feature = new KeyTakeaways(); + $response = $feature->generate_key_takeaways_on_demand( $this->on_demand_request( $post_id ) ); + $data = $response->get_data(); + + $this->assertFalse( $data['success'] ); + $this->assertTrue( $data['inProgress'] ); + $this->assertSame( 0, $this->http_calls ); + } + + /** + * Editing the post content invalidates stored takeaways. + */ + public function test_content_change_invalidates_takeaways() { + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_content' => 'Original content.', + ) + ); + $this->mock_settings(); + + $feature = new KeyTakeaways(); + update_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, array( 'Stored takeaway.' ) ); + update_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_HASH_KEY, $feature->get_content_hash( $post_id ) ); + + // Unchanged content keeps the stored takeaways. + $feature->maybe_invalidate_on_demand_takeaways( $post_id ); + $this->assertNotEmpty( get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, true ) ); + + // Editing the content clears them. + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => 'Brand new content.', + ) + ); + $feature->maybe_invalidate_on_demand_takeaways( $post_id ); + + $this->assertEmpty( get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, true ) ); + $this->assertEmpty( get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_HASH_KEY, true ) ); + } + + /** + * Build an editor (block) generation request. + * + * @param int $post_id Post ID. + * @param string $run Either `auto` or `manual`. + * @return WP_REST_Request + */ + private function block_request( int $post_id, string $run = 'auto' ): WP_REST_Request { + $request = new WP_REST_Request( 'POST', '/classifai/v1/key-takeaways/' ); + $request->set_param( 'id', $post_id ); + $request->set_param( 'content', 'This article explains structured outputs and why they help parsing.' ); + $request->set_param( 'title', 'Structured Outputs' ); + $request->set_param( 'render', 'list' ); + $request->set_param( 'run', $run ); + + return $request; + } + + /** + * The block reuses stored takeaways on auto load instead of regenerating. + */ + public function test_block_auto_load_reuses_stored_meta() { + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $this->mock_settings(); + $this->mock_openai_response(); + + update_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, array( 'Stored takeaway.' ) ); + + $feature = new KeyTakeaways(); + $response = $feature->rest_endpoint_callback( $this->block_request( $post_id, 'auto' ) ); + + $this->assertSame( array( 'Stored takeaway.' ), $response->get_data() ); + $this->assertSame( 0, $this->http_calls, 'Provider should not be hit when meta exists.' ); + } + + /** + * Block generation persists takeaways to the shared post meta. + */ + public function test_block_generation_persists_meta() { + $author_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_author' => $author_id, + ) + ); + $this->mock_settings(); + $this->mock_openai_response(); + wp_set_current_user( $author_id ); + + $feature = new KeyTakeaways(); + $response = $feature->rest_endpoint_callback( $this->block_request( $post_id, 'manual' ) ); + + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), $response->get_data() ); + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, true ) ); + $this->assertNotEmpty( get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_HASH_KEY, true ) ); + } + + /** + * An explicit manual refresh regenerates even when takeaways are stored. + */ + public function test_block_manual_run_regenerates_despite_stored_meta() { + $author_id = $this->factory->user->create( array( 'role' => 'administrator' ) ); + $post_id = $this->factory->post->create( + array( + 'post_status' => 'publish', + 'post_author' => $author_id, + ) + ); + $this->mock_settings(); + $this->mock_openai_response(); + wp_set_current_user( $author_id ); + + update_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, array( 'Old takeaway.' ) ); + + $feature = new KeyTakeaways(); + $response = $feature->rest_endpoint_callback( $this->block_request( $post_id, 'manual' ) ); + + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), $response->get_data() ); + $this->assertSame( 1, $this->http_calls ); + $this->assertSame( array( 'First takeaway.', 'Second takeaway.' ), get_post_meta( $post_id, KeyTakeaways::TAKEAWAYS_META_KEY, true ) ); + } +} diff --git a/tests/e2e/specs/language-processing/key-takeaways-on-demand.spec.ts b/tests/e2e/specs/language-processing/key-takeaways-on-demand.spec.ts new file mode 100644 index 000000000..f29aaa21e --- /dev/null +++ b/tests/e2e/specs/language-processing/key-takeaways-on-demand.spec.ts @@ -0,0 +1,218 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../../fixtures/test'; + +/** + * Covers the Key Takeaways `generation_timing` setting (Manual / On demand) and + * the on-demand front-end "generate on first request" flow. Uses the OpenAI + * ChatGPT provider since the test plugin mocks its chat completions endpoint, + * so on-demand generation completes synchronously without a real API call. + */ +test.describe( '[Language Processing] Key Takeaways On Demand mode', () => { + test.beforeAll( async ( { browser, requestUtils } ) => { + try { + await requestUtils.deactivatePlugin( 'classic-editor' ); + } catch { + // noop + } + + const page = await browser.newPage(); + + // Opt in to all features for the admin user. + await page.goto( '/wp-admin/profile.php' ); + const optOuts = page.locator( + 'input[name="classifai_opted_out_features[]"]' + ); + const count = await optOuts.count(); + let anyChecked = false; + for ( let i = 0; i < count; i++ ) { + const cb = optOuts.nth( i ); + if ( await cb.isChecked() ) { + await cb.uncheck(); + anyChecked = true; + } + } + if ( anyChecked ) { + await page.locator( '#submit' ).click(); + } + await page.close(); + } ); + + test( 'Processing mode setting offers both modes and reveals On Demand fields', async ( { + classifaiUtils, + page, + } ) => { + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_key_takeaways' + ); + await expect( page.locator( '#classifai-logo' ) ).toBeVisible(); + + // Configure and enable the feature so it is fully set up. + await classifaiUtils.selectProvider( 'openai_chatgpt' ); + await page.locator( '#openai_chatgpt_api_key' ).fill( 'password' ); + await classifaiUtils.enableFeature(); + await classifaiUtils.allowFeatureToAdmin(); + + // The processing mode select offers Manual and On demand. + const select = page.locator( + '.settings-key-takeaways-generation-timing select' + ); + await expect( select ).toBeVisible(); + await expect( select.locator( 'option[value="manual"]' ) ).toHaveCount( + 1 + ); + await expect( + select.locator( 'option[value="on_demand"]' ) + ).toHaveCount( 1 ); + + // On-demand-only fields are hidden in Manual mode. + await select.selectOption( 'manual' ); + await expect( + page.locator( '.settings-key-takeaways-button-label' ) + ).toHaveCount( 0 ); + + // Switching to On demand reveals the post types, button label and + // display format controls. + await select.selectOption( 'on_demand' ); + await expect( + page.locator( '.settings-allowed-post-types' ) + ).toBeVisible(); + await expect( + page.locator( '.settings-key-takeaways-button-label input' ) + ).toBeVisible(); + await expect( + page.locator( '.settings-key-takeaways-render select' ) + ).toBeVisible(); + + // Set a custom button label and make sure Posts are enabled. + await page + .locator( '.settings-key-takeaways-button-label input' ) + .fill( 'Summarize' ); + const postType = page.locator( + '.settings-allowed-post-types input#post' + ); + if ( ! ( await postType.isChecked() ) ) { + await postType.check(); + } + + await classifaiUtils.saveFeatureSettings(); + } ); + + test( 'On Demand settings persist after reload', async ( { + classifaiUtils, + page, + } ) => { + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_key_takeaways' + ); + + await expect( + page.locator( '.settings-key-takeaways-generation-timing select' ) + ).toHaveValue( 'on_demand' ); + await expect( + page.locator( '.settings-key-takeaways-button-label input' ) + ).toHaveValue( 'Summarize' ); + } ); + + test( 'Button generates takeaways on the front-end and toggles open/closed', async ( { + classifaiUtils, + page, + } ) => { + await classifaiUtils.createPost( { + title: 'KT On Demand Front End', + content: + 'This article should produce key takeaways on the first request.', + } ); + + await page.goto( '/kt-on-demand-front-end/' ); + + const toggle = page.locator( '.classifai-key-takeaways-toggle' ); + await expect( toggle ).toBeVisible(); + await expect( + toggle.locator( '.classifai-key-takeaways-toggle__label' ) + ).toHaveText( 'Summarize' ); + + // Nothing generated yet: collapsed and flagged as having no takeaways. + await expect( toggle ).toHaveAttribute( 'aria-expanded', 'false' ); + await expect( toggle ).toHaveAttribute( 'data-has-takeaways', '0' ); + + // Clicking triggers synchronous generation against the mocked provider. + const generation = page.waitForResponse( + ( res ) => + res + .url() + .includes( '/classifai/v1/key-takeaways-on-demand/' ) && + res.request().method() === 'POST' + ); + await toggle.click(); + const response = await generation; + expect( response.ok() ).toBeTruthy(); + + // Takeaways render in the panel and the control reflects the open state. + const items = page.locator( '.classifai-key-takeaways-panel ul li' ); + await expect( items ).toHaveCount( 4 ); + await expect( items.first() ).toContainText( + 'Spring symbolizes renewal and beauty, inspiring creativity and reflection.' + ); + await expect( toggle ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( toggle ).toHaveAttribute( 'data-has-takeaways', '1' ); + + // Clicking again collapses the panel. + await toggle.click(); + await expect( toggle ).toHaveAttribute( 'aria-expanded', 'false' ); + } ); + + test( 'Generated takeaways are reused (cached) and work for logged-out visitors', async ( { + page, + context, + } ) => { + await context.clearCookies(); + await page.goto( '/kt-on-demand-front-end/' ); + + const toggle = page.locator( '.classifai-key-takeaways-toggle' ); + await expect( toggle ).toBeVisible(); + // Takeaways already exist, so the panel is pre-populated server-side. + await expect( toggle ).toHaveAttribute( 'data-has-takeaways', '1' ); + + await toggle.click(); + const items = page.locator( '.classifai-key-takeaways-panel ul li' ); + await expect( items ).toHaveCount( 4 ); + } ); + + test( 'Button is suppressed when the post already contains the block', async ( { + classifaiUtils, + editor, + page, + } ) => { + await classifaiUtils.createPost( { + title: 'KT On Demand With Block', + content: 'This post already includes the Key Takeaways block.', + publish: false, + } ); + await editor.insertBlock( { name: 'classifai/key-takeaways' } ); + await editor.publishPost(); + await classifaiUtils.closePublishPanel(); + + await page.goto( '/kt-on-demand-with-block/' ); + + // The block renders its own takeaways; the on-demand button should not + // also appear. + await expect( + page.locator( '.wp-block-classifai-key-takeaways' ) + ).toBeVisible(); + await expect( + page.locator( '.classifai-key-takeaways-toggle' ) + ).toHaveCount( 0 ); + } ); + + test( 'Reset to Manual mode', async ( { classifaiUtils, page } ) => { + await classifaiUtils.visitFeatureSettings( + 'language_processing/feature_key_takeaways' + ); + await page + .locator( '.settings-key-takeaways-generation-timing select' ) + .selectOption( 'manual' ); + await classifaiUtils.saveFeatureSettings(); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index d813e4e6a..27de67049 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,11 @@ +/** + * WordPress dependencies + */ const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); + +/** + * External dependencies + */ const path = require( 'path' ); module.exports = { @@ -16,32 +23,54 @@ module.exports = { './src/js/features/recommended-content/variation.js', ], - 'classifai-plugin-media-processing': './src/js/features/media-processing/media-upload.js', - 'classifai-plugin-editor-ocr': './src/js/features/media-processing/editor-ocr.js', + 'classifai-plugin-media-processing': + './src/js/features/media-processing/media-upload.js', + 'classifai-plugin-editor-ocr': + './src/js/features/media-processing/editor-ocr.js', 'classifai-plugin-commands': './src/js/features/commands.js', - 'classifai-plugin-classification': './src/js/features/classification/index.js', - 'classifai-plugin-classification-previewer': './src/js/features/classification/previewer.js', - 'classifai-plugin-classification-ibm-watson': './src/js/features/classification/ibm-watson.js', - 'classifai-plugin-classification-pre-publish': './src/js/features/classification/pre-publish-panel.js', + 'classifai-plugin-classification': + './src/js/features/classification/index.js', + 'classifai-plugin-classification-previewer': + './src/js/features/classification/previewer.js', + 'classifai-plugin-classification-ibm-watson': + './src/js/features/classification/ibm-watson.js', + 'classifai-plugin-classification-pre-publish': + './src/js/features/classification/pre-publish-panel.js', 'classifai-plugin-fill': './src/js/features/slot-fill/index.js', - 'classifai-plugin-text-to-speech': './src/js/features/text-to-speech/index.js', - 'classifai-plugin-classic-text-to-speech': './src/js/features/text-to-speech/classic/index.js', - 'classifai-plugin-text-to-speech-frontend': './src/js/features/text-to-speech/frontend/index.js', - 'classifai-plugin-content-resizing': './src/js/features/content-resizing/index.js', - 'classifai-plugin-title-generation': './src/js/features/title-generation/index.js', - 'classifai-plugin-classic-title-generation': './src/js/features/title-generation/classic/index.js', - 'classifai-plugin-excerpt-generation': './src/js/features/excerpt-generation/index.js', - 'classifai-plugin-classic-excerpt-generation': './src/js/features/excerpt-generation/classic/index.js', - 'classifai-plugin-content-generation': './src/js/features/content-generation/index.js', - 'classifai-quick-draft': './src/js/features/content-generation/quick-draft/index.js', - 'classifai-plugin-inserter-media-category': './src/js/features/image-generation/inserter-media-category.js', + 'classifai-plugin-text-to-speech': + './src/js/features/text-to-speech/index.js', + 'classifai-plugin-classic-text-to-speech': + './src/js/features/text-to-speech/classic/index.js', + 'classifai-plugin-text-to-speech-frontend': + './src/js/features/text-to-speech/frontend/index.js', + 'classifai-plugin-key-takeaways-frontend': + './src/js/features/key-takeaways/frontend/index.js', + 'classifai-plugin-content-resizing': + './src/js/features/content-resizing/index.js', + 'classifai-plugin-title-generation': + './src/js/features/title-generation/index.js', + 'classifai-plugin-classic-title-generation': + './src/js/features/title-generation/classic/index.js', + 'classifai-plugin-excerpt-generation': + './src/js/features/excerpt-generation/index.js', + 'classifai-plugin-classic-excerpt-generation': + './src/js/features/excerpt-generation/classic/index.js', + 'classifai-plugin-content-generation': + './src/js/features/content-generation/index.js', + 'classifai-quick-draft': + './src/js/features/content-generation/quick-draft/index.js', + 'classifai-plugin-inserter-media-category': + './src/js/features/image-generation/inserter-media-category.js', 'classifai-plugin-image-generation-media-modal': [ './src/js/features/image-generation/media-modal/index.js', - './src/js/features/image-generation/extend-image-block-generate-image.js' + './src/js/features/image-generation/extend-image-block-generate-image.js', ], - 'classifai-plugin-image-generation-generate-image-media-upload': './src/js/features/image-generation/media-modal/views/generate-image-media-upload.js', - 'classifai-plugin-recommended-content-feature-fields': './src/js/features/recommended-content/feature-fields-plugin.js', - 'classifai-plugin-api-usage-tracking': './src/js/features/ai-usage-tracking/index.js', + 'classifai-plugin-image-generation-generate-image-media-upload': + './src/js/features/image-generation/media-modal/views/generate-image-media-upload.js', + 'classifai-plugin-recommended-content-feature-fields': + './src/js/features/recommended-content/feature-fields-plugin.js', + 'classifai-plugin-api-usage-tracking': + './src/js/features/ai-usage-tracking/index.js', settings: './src/js/settings/index.js', }, externals: {