diff --git a/.eslintrc.json b/.eslintrc.json
index 85f09bd41..0c01bb828 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -21,7 +21,8 @@
"Headers": "readonly",
"requestAnimationFrame": "readonly",
"React": "readonly",
- "Block": "readonly"
+ "Block": "readonly",
+ "DOMParser": "readonly"
},
"extends": ["plugin:@wordpress/eslint-plugin/recommended"],
"ignorePatterns": ["*.json", "webpack.config.js"]
diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php
new file mode 100644
index 000000000..9cbc733a6
--- /dev/null
+++ b/includes/Classifai/Features/RewriteTone.php
@@ -0,0 +1,255 @@
+label = __( 'Rewrite Tone', 'classifai' );
+
+ // Contains all providers that are registered to the service.
+ $this->provider_instances = $this->get_provider_instances( LanguageProcessing::get_service_providers() );
+
+ // Contains just the providers this feature supports.
+ $this->supported_providers = [
+ ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ),
+ ];
+ }
+
+ /**
+ * Set up necessary hooks.
+ *
+ * We utilize this so we can register the REST route.
+ */
+ public function setup() {
+ parent::setup();
+ add_action( 'rest_api_init', [ $this, 'register_endpoints' ] );
+ }
+
+ /**
+ * Set up necessary hooks.
+ */
+ public function feature_setup() {
+ add_action( 'enqueue_block_assets', [ $this, 'enqueue_editor_assets' ] );
+ }
+
+ /**
+ * Register any needed endpoints.
+ */
+ public function register_endpoints() {
+ register_rest_route(
+ 'classifai/v1',
+ 'rewrite-tone',
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [ $this, 'rest_endpoint_callback' ],
+ 'permission_callback' => '__return_true',
+ 'args' => [
+ 'id' => [
+ 'required' => true,
+ 'type' => 'integer',
+ 'sanitize_callback' => 'absint',
+ 'description' => esc_html__( 'Post ID to resize the content for.', 'classifai' ),
+ ],
+ 'content' => [
+ 'type' => 'array',
+ 'sanitize_callback' => function ( $content_array ) {
+ if ( is_array( $content_array ) ) {
+ return array_map(
+ function ( $item ) {
+ $item['clientId'] = sanitize_text_field( $item['clientId'] );
+ $item['content'] = wp_kses_post( $item['content'] );
+ return $item;
+ },
+ $content_array
+ );
+ }
+
+ return [];
+ },
+ 'validate_callback' => function ( $content_array ) {
+ if ( is_array( $content_array ) ) {
+ foreach ( $content_array as $item ) {
+ if ( ! isset( $item['clientId'] ) || ! is_string( $item['clientId'] ) ) {
+ return new WP_Error( 'rewrite_tone_invalid_client_id', __( 'Each item must have a valid clientId string.', 'classifai' ), [ 'status' => 400 ] );
+ }
+
+ if ( ! isset( $item['content'] ) || ! is_string( $item['content'] ) ) {
+ return new WP_Error( 'rewrite_tone_invalid_content', __( 'Each item must have valid content as a string.', 'classifai' ), [ 'status' => 400 ] );
+ }
+ }
+ return true;
+ }
+ return new WP_Error( 'rewrite_tone_invalid_data_format', __( 'Content must be an array of objects.', 'classifai' ), [ 'status' => 400 ] );
+ },
+ 'description' => esc_html__( 'The content to resize.', 'classifai' ),
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Check if a given request has access to resize content.
+ *
+ * @param WP_REST_Request $request Full data about the request.
+ * @return WP_Error|bool
+ */
+ public function resize_content_permissions_check( WP_REST_Request $request ) {
+ $post_id = $request->get_param( 'id' );
+
+ // Ensure we have a logged in user that can edit the item.
+ if ( empty( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
+ return false;
+ }
+
+ $post_type = get_post_type( $post_id );
+ $post_type_obj = get_post_type_object( $post_type );
+
+ // Ensure the post type is allowed in REST endpoints.
+ if ( ! $post_type || empty( $post_type_obj ) || empty( $post_type_obj->show_in_rest ) ) {
+ return false;
+ }
+
+ // Ensure the feature is enabled. Also runs a user check.
+ if ( ! $this->is_feature_enabled() ) {
+ return new WP_Error( 'not_enabled', esc_html__( 'Rewrite Tone is not currently enabled.', 'classifai' ) );
+ }
+
+ return true;
+ }
+
+ /**
+ * Generic request handler for all our custom routes.
+ *
+ * @param WP_REST_Request $request The full request object.
+ * @return \WP_REST_Response
+ */
+ public function rest_endpoint_callback( WP_REST_Request $request ) {
+ $route = $request->get_route();
+
+ if ( strpos( $route, '/classifai/v1/rewrite-tone' ) === 0 ) {
+ return rest_ensure_response(
+ $this->run(
+ $request->get_param( 'id' ),
+ 'rewrite_tone',
+ [
+ 'content' => $request->get_param( 'content' ),
+ ]
+ )
+ );
+ }
+
+ return parent::rest_endpoint_callback( $request );
+ }
+
+ /**
+ * Enqueue the editor scripts.
+ */
+ public function enqueue_editor_assets() {
+ global $post;
+
+ if ( empty( $post ) || ! is_admin() ) {
+ return;
+ }
+
+ wp_enqueue_script(
+ 'classifai-plugin-rewrite-tone-js',
+ CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-rewrite-tone.js',
+ array_merge(
+ get_asset_info( 'classifai-plugin-rewrite-tone', 'dependencies' ),
+ array( Feature::PLUGIN_AREA_SCRIPT )
+ ),
+ get_asset_info( 'classifai-plugin-rewrite-tone', 'version' ),
+ true
+ );
+ }
+
+ /**
+ * Get the description for the enable field.
+ *
+ * @return string
+ */
+ public function get_enable_description(): string {
+ return esc_html__( '"Condense this text" and "Expand this text" menu items will be added to the paragraph block\'s toolbar menu.', 'classifai' );
+ }
+
+ /**
+ * Add any needed custom fields.
+ */
+ public function add_custom_settings_fields() {
+ $settings = $this->get_settings();
+
+ add_settings_field(
+ 'rewrite_tone_prompt',
+ esc_html__( 'Prompt', 'classifai' ),
+ [ $this, 'render_prompt_repeater_field' ],
+ $this->get_option_name(),
+ $this->get_option_name() . '_section',
+ [
+ 'label_for' => 'rewrite_tone_prompt',
+ 'placeholder' => $this->prompt,
+ 'default_value' => $settings['rewrite_tone_prompt'],
+ 'description' => esc_html__( 'Add a custom prompt, if desired.', 'classifai' ),
+ ]
+ );
+ }
+
+ /**
+ * Returns the default settings for the feature.
+ *
+ * @return array
+ */
+ public function get_feature_default_settings(): array {
+ return [
+ 'rewrite_tone_prompt' => [
+ [
+ 'title' => esc_html__( 'ClassifAI default', 'classifai' ),
+ 'prompt' => $this->prompt,
+ 'original' => 1,
+ ],
+ ],
+ 'provider' => ChatGPT::ID,
+ ];
+ }
+
+ /**
+ * Sanitizes the default feature settings.
+ *
+ * @param array $new_settings Settings being saved.
+ * @return array
+ */
+ public function sanitize_default_feature_settings( array $new_settings ): array {
+ $new_settings['rewrite_tone_prompt'] = sanitize_prompts( 'rewrite_tone_prompt', $new_settings );
+
+ return $new_settings;
+ }
+}
diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php
index 6a50aab76..a2e256acb 100644
--- a/includes/Classifai/Providers/OpenAI/ChatGPT.php
+++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php
@@ -7,6 +7,7 @@
use Classifai\Features\ContentResizing;
use Classifai\Features\ExcerptGeneration;
+use Classifai\Features\RewriteTone;
use Classifai\Features\TitleGeneration;
use Classifai\Providers\Provider;
use Classifai\Normalizer;
@@ -181,7 +182,7 @@ public function sanitize_api_key( array $new_settings ): string {
*/
public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '', array $args = [] ) {
if ( ! $post_id || ! get_post( $post_id ) ) {
- return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate an excerpt.', 'classifai' ) );
+ return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required.', 'classifai' ) );
}
$route_to_call = strtolower( $route_to_call );
@@ -198,6 +199,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = ''
case 'resize_content':
$return = $this->resize_content( $post_id, $args );
break;
+ case 'rewrite_tone':
+ $return = $this->rewrite_tone( $post_id, $args );
+ break;
}
return $return;
@@ -416,6 +420,80 @@ public function generate_titles( int $post_id = 0, array $args = [] ) {
return $return;
}
+ /**
+ * Rewrite the tone of the content.
+ *
+ * @param int $post_id The Post Id we're processing
+ * @param array $args Arguments passed in.
+ */
+ public function rewrite_tone( int $post_id, array $args = [] ) {
+ $feature = new RewriteTone();
+ $settings = $feature->get_settings();
+ $request = new APIRequest( $settings[ static::ID ]['api_key'] ?? '', $feature->get_option_name() );
+ $prompt = esc_textarea( get_default_prompt( $settings['rewrite_tone_prompt'] ) ?? $feature->prompt );
+
+ /**
+ * Filter the prompt we will send to ChatGPT.
+ *
+ * @since x.x.x
+ * @hook classifai_chatgpt_rewrite_tone_prompt
+ *
+ * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content.
+ * @param {int} $post_id ID of post we are summarizing.
+ * @param {array} $args Arguments passed to endpoint.
+ *
+ * @return {string} Prompt.
+ */
+ $prompt = apply_filters( 'classifai_chatgpt_rewrite_tone_prompt', $prompt, $post_id, $args );
+
+ $body = apply_filters(
+ 'classifai_chatgpt_resize_content_request_body',
+ [
+ 'model' => $this->chatgpt_model,
+ 'messages' => [
+ [
+ 'role' => 'system',
+ 'content' => $prompt,
+ ],
+ [
+ 'role' => 'system',
+ 'content' => "Please return each modified content with its corresponding 'clientId'.",
+ ],
+ [
+ 'role' => 'system',
+ 'content' => 'The inline styles and HTML attributes should be preserved in the response.',
+ ],
+ [
+ 'role' => 'system',
+ 'content' => 'The HTML in the input should be preserved in the response.',
+ ],
+ [
+ 'role' => 'user',
+ 'content' => wp_json_encode( $args['content'] ),
+ ],
+ ],
+ ],
+ );
+
+ $response = $request->post(
+ $this->chatgpt_url,
+ [
+ 'body' => wp_json_encode( $body ),
+ ]
+ );
+
+ $return = [];
+
+ foreach ( $response['choices'] as $choice ) {
+ if ( isset( $choice['message'], $choice['message']['content'] ) ) {
+ // ChatGPT often adds quotes to strings, so remove those as well as extra spaces.
+ $return[] = trim( $choice['message']['content'], ' "\'' );
+ }
+ }
+
+ return $return;
+ }
+
/**
* Resizes content.
*
diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php
index 564c7d0e2..41525843a 100644
--- a/includes/Classifai/Services/ServicesManager.php
+++ b/includes/Classifai/Services/ServicesManager.php
@@ -73,6 +73,7 @@ public function register_language_processing_features( array $features ): array
'\Classifai\Features\TitleGeneration',
'\Classifai\Features\ExcerptGeneration',
'\Classifai\Features\ContentResizing',
+ '\Classifai\Features\RewriteTone',
'\Classifai\Features\TextToSpeech',
'\Classifai\Features\AudioTranscriptsGeneration',
'\Classifai\Features\Moderation',
diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js
new file mode 100644
index 000000000..e45d87a17
--- /dev/null
+++ b/src/js/features/rewrite-tone/index.js
@@ -0,0 +1,448 @@
+/**
+ * External dependencies.
+ */
+import {
+ store as blockEditorStore,
+ BlockEditorProvider,
+ BlockList,
+} from '@wordpress/block-editor';
+import { store as editorStore } from '@wordpress/editor';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { Button, Modal } from '@wordpress/components';
+import {
+ useState,
+ useEffect,
+ useRef,
+ createPortal,
+ render,
+} from '@wordpress/element';
+import { getBlockContent, createBlock } from '@wordpress/blocks';
+import { registerPlugin } from '@wordpress/plugins';
+import { __ } from '@wordpress/i18n';
+
+const { ClassifaiEditorSettingPanel } = window;
+
+const InjectIframeStyles = ( { children } ) => {
+ // Stores the Gutenberg editor canvas iframe.
+ const [ iframeCanvas, setIframeCanvas ] = useState( null );
+
+ // Reference to the iframe in which we show blocks for preview.
+ const iframeRef = useRef( null );
+
+ useEffect( () => {
+ // We wait for the editor canvas to load.
+ ( async () => {
+ let __iframeCanvas;
+
+ await new Promise( ( resolve ) => {
+ const intervalId = setInterval( () => {
+ __iframeCanvas =
+ document.getElementsByName( 'editor-canvas' );
+ if ( __iframeCanvas.length > 0 ) {
+ __iframeCanvas = __iframeCanvas[ 0 ];
+ clearInterval( intervalId );
+ resolve();
+ }
+ }, 100 );
+ } );
+
+ setIframeCanvas( __iframeCanvas );
+ } )();
+ }, [] );
+
+ useEffect( () => {
+ if ( ! iframeCanvas || ! iframeRef.current ) {
+ return;
+ }
+
+ // Get the newly created iframe's document.
+ const iframeDocument =
+ iframeRef.current.contentDocument ||
+ iframeRef.current.contentWindow.document;
+
+ // Copy the styles from the existing iframe (editor canvas).
+ const editorIframeDocument =
+ iframeCanvas.contentDocument || iframeCanvas.contentWindow.document;
+ const iframeStyles = editorIframeDocument.querySelectorAll(
+ 'link[rel="stylesheet"], style'
+ );
+
+ // Append styles (external & internal) to the new iframe's body.
+ iframeStyles.forEach( ( style ) => {
+ if ( style.tagName === 'LINK' ) {
+ iframeDocument.head.appendChild( style.cloneNode( true ) );
+ } else if ( style.tagName === 'STYLE' ) {
+ const clonedStyle = document.createElement( 'style' );
+ clonedStyle.textContent = style.textContent;
+ iframeDocument.head.appendChild( clonedStyle );
+ }
+ } );
+
+ const intervalId = setInterval( () => {
+ if ( ! iframeDocument.body ) {
+ return;
+ }
+
+ iframeDocument.body.classList.add(
+ 'block-editor-iframe__body',
+ 'editor-styles-wrapper',
+ 'post-type-post',
+ 'admin-color-fresh',
+ 'wp-embed-responsive'
+ );
+ iframeDocument.body
+ .querySelector( '.is-root-container' )
+ .classList.add(
+ 'is-desktop-preview',
+ 'is-layout-constrained',
+ 'wp-block-post-content-is-layout-constrained',
+ 'has-global-padding',
+ 'alignfull',
+ 'wp-block-post-content',
+ 'block-editor-block-list__layout'
+ );
+
+ clearInterval( intervalId );
+ }, 100 );
+
+ // Use React Portal to render the children into the iframe container.
+ // TODO: Might need to replace with `createPortal` due to React 18.
+ const portal = createPortal( children, iframeDocument.body );
+ render( portal, iframeDocument.body );
+ }, [ iframeCanvas ] );
+
+ if ( ! iframeCanvas ) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+const RewriteTonePlugin = () => {
+ const allowedTextBlocks = [
+ 'core/paragraph',
+ 'core/heading',
+ 'core/list-item',
+ ];
+
+ const apiUrl = `${ wpApiSettings.root }classifai/v1/rewrite-tone`;
+
+ // Stores ChatGPT response.
+ const [ response, setResponse ] = useState( null );
+
+ // Flag indicating if a rewrite is in progress.
+ const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false );
+
+ // Stores all the editor blocks (modified and unmodified) that are created for preview.
+ const [ previewBlocks, setPreviewBlocks ] = useState( [] );
+
+ // Stores the subset of editor blocks that have undergone tone rewriting.
+ const [ modifiedBlocks, setModifiedBlocks ] = useState( [] );
+
+ // Flag indicating if the previewer modal is open.
+ const [ isPopupVisible, setIsPopupVisible ] = useState( false );
+
+ // Holds a reference to the original, unmodified editor blocks.
+ const blocksBackup = useRef( null );
+
+ // We use this to replace blocks if the user is happy with the result.
+ const { replaceBlock } = useDispatch( blockEditorStore );
+
+ /**
+ * Replaces subset of blocks in the copy of the editor's original blocks with
+ * modified blocks and returns a new array.
+ *
+ * Suppose the editor originally has 6 blocks and blocks 3 & 4 have undergone tone
+ * rewriting which returns blocks 3' and 4'. This function returns 1-2-3'-4'-5-6.
+ *
+ * @param {Array} originalBlocks Array of original, unmodified editor blocks.
+ * @param {Array} rewrittenBlocks Subset of editor blocks which have undergone tone rewriting.
+ * @return {Array} Array of blocks that include original and modified blocks.
+ */
+ function updateBlocksWithModified( originalBlocks, rewrittenBlocks ) {
+ const updateBlock = ( blocks ) => {
+ return blocks.map( ( block ) => {
+ const modified = rewrittenBlocks.find(
+ ( modifiedBlock ) =>
+ modifiedBlock.clientId === block.clientId
+ );
+
+ if ( modified ) {
+ return modified.blocks[ 0 ];
+ }
+
+ return {
+ ...block,
+ innerBlocks: block.innerBlocks
+ ? updateBlock( block.innerBlocks )
+ : [],
+ };
+ } );
+ };
+
+ return updateBlock( originalBlocks );
+ }
+
+ /**
+ * Removes the delimiters from the content.
+ *
+ * @param {Array} blocks Array of { clientId, content } objects.
+ * @return {Array} Array of objects with content without delimiters.
+ */
+ const removeDelimiters = ( blocks ) =>
+ blocks.map( ( { clientId, content } ) => {
+ return {
+ clientId,
+ content: content.replace( //g, '' ),
+ };
+ } );
+
+ /**
+ * Returns a transformer function depending on the transforms passed as args.
+ *
+ * @param {...any} fns Array of functions that forms the pipeline.
+ * @return {Function} The transformer function.
+ */
+ const blocksTransformerPipeline =
+ ( ...fns ) =>
+ ( value ) =>
+ fns.reduce( ( acc, fn ) => fn( acc ), value );
+
+ // `selectedBlocks` contains array of blocks that are selected in the editor.
+ // `postId` is the current post ID.
+ const { selectedBlocks, postId } = useSelect( ( select ) => {
+ const selectedBlock = select( blockEditorStore ).getSelectedBlock();
+ const multiSelectedBlocks =
+ select( blockEditorStore ).getMultiSelectedBlocks();
+ const __selectedBlocks = selectedBlock
+ ? [ selectedBlock ]
+ : multiSelectedBlocks;
+
+ const getSelectedRootBlocks = () => {
+ const selectedRootBlocks = __selectedBlocks.map(
+ ( { clientId } ) => {
+ return select( blockEditorStore ).getBlock( clientId );
+ }
+ );
+
+ return [
+ ...new Map(
+ selectedRootBlocks.map( ( item ) => [
+ item.clientId,
+ item,
+ ] )
+ ).values(),
+ ];
+ };
+
+ const flattenAllowedBlocks = ( blocks ) =>
+ blocks.reduce(
+ ( acc, block ) => [
+ ...acc,
+ ...( allowedTextBlocks.includes( block.name )
+ ? [ block ]
+ : [] ),
+ ...( block.innerBlocks
+ ? flattenAllowedBlocks( block.innerBlocks )
+ : [] ),
+ ],
+ []
+ );
+
+ /**
+ * Returns { clientId, content } of a block.
+ *
+ * @param {Array} blocks Array of blocks.
+ * @return {Array} Array of { clientId, content } objects extracted from `block`.
+ */
+ const gatherPostData = ( blocks ) =>
+ blocks.map( ( block ) => ( {
+ clientId: block.clientId,
+ content: getBlockContent( block ),
+ } ) );
+
+ const blocksTransformer = blocksTransformerPipeline(
+ flattenAllowedBlocks,
+ gatherPostData,
+ removeDelimiters
+ );
+
+ return {
+ postId: select( editorStore ).getCurrentPostId(),
+ selectedBlocks: blocksTransformer( getSelectedRootBlocks() ),
+ };
+ } );
+
+ /**
+ * Performs rewrite when triggered by the user on Button click.
+ *
+ * @return {void}
+ */
+ async function rewriteTone() {
+ try {
+ // We backup the original blocks.
+ blocksBackup.current = wp.data
+ .select( blockEditorStore )
+ .getBlocks();
+
+ setIsPopupVisible( false );
+ setIsRewriteInProgress( true );
+ setPreviewBlocks( [] );
+
+ let __response = await fetch( apiUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify( {
+ id: postId,
+ content: selectedBlocks,
+ } ),
+ } );
+
+ setIsRewriteInProgress( false );
+
+ if ( ! __response.ok ) {
+ return;
+ }
+
+ __response = await __response.json();
+ setResponse( JSON.parse( __response ) );
+ } catch ( e ) {
+ setIsRewriteInProgress( false );
+ }
+ }
+
+ /**
+ * Returns HTML string without the outermost tags.
+ *
+ * @param {string} htmlContent HTML as string.
+ * @return {string} HTML string without outermost tags stripped.
+ */
+ function removeOutermostTag( htmlContent ) {
+ // Parse the input HTML string into a DOM structure
+ const parser = new DOMParser();
+ const doc = parser.parseFromString( htmlContent, 'text/html' );
+
+ // Get the first element within the body (this is the outermost element)
+ const outermostElement = doc.body.firstElementChild;
+
+ // Return the innerHTML of the outermost element, which removes the outermost tag
+ return outermostElement ? outermostElement.innerHTML : htmlContent;
+ }
+
+ /**
+ * Applies the result to the editor canvas when the user
+ * accepts it.
+ */
+ const applyResult = () => {
+ modifiedBlocks.forEach( ( { clientId, blocks } ) => {
+ replaceBlock( clientId, blocks );
+ } );
+
+ setIsPopupVisible( false );
+ };
+
+ useEffect( () => {
+ if ( ! Array.isArray( response ) ) {
+ return;
+ }
+
+ const __modifiedBlocks = response.map( ( { clientId, content } ) => {
+ const currentBlock = wp.data
+ .select( blockEditorStore )
+ .getBlock( clientId );
+
+ // We apply the original block attributes to the newly created.
+ currentBlock.attributes = wp.data
+ .select( blockEditorStore )
+ .getBlockAttributes( clientId );
+
+ let createdBlock = wp.blocks.rawHandler( { HTML: content } );
+
+ if (
+ Array.isArray( createdBlock ) &&
+ 1 === createdBlock.length &&
+ 'core/html' === createdBlock[ 0 ].name
+ ) {
+ createdBlock = createBlock( currentBlock.name, {
+ content: removeOutermostTag( content ),
+ } );
+
+ return {
+ clientId,
+ blocks: [ createdBlock ],
+ };
+ }
+
+ return {
+ clientId,
+ blocks: createdBlock,
+ };
+ } );
+
+ const __previewBlocks = updateBlocksWithModified(
+ blocksBackup.current,
+ __modifiedBlocks
+ );
+
+ setPreviewBlocks( __previewBlocks );
+ setModifiedBlocks( __modifiedBlocks );
+ setIsPopupVisible( true );
+ }, [ response ] );
+
+ return (
+
+
+ { isPopupVisible && (
+ setIsPopupVisible( false ) }
+ >
+
+
+
+
+
+
+
+
+
+
+ ) }
+
+ );
+};
+
+registerPlugin( 'classifai-rewrite-tone-plugin', {
+ render: RewriteTonePlugin,
+} );
diff --git a/src/js/gutenberg-plugin.js b/src/js/gutenberg-plugin.js
new file mode 100644
index 000000000..c07f35e6a
--- /dev/null
+++ b/src/js/gutenberg-plugin.js
@@ -0,0 +1,672 @@
+/* eslint-disable no-unused-vars */
+import { ReactComponent as icon } from '../../assets/img/block-icon.svg';
+import { handleClick } from './helpers';
+import { useSelect, useDispatch, subscribe } from '@wordpress/data';
+import { PluginDocumentSettingPanel } from '@wordpress/edit-post';
+import {
+ Button,
+ Icon,
+ ToggleControl,
+ BaseControl,
+ Modal,
+ SlotFillProvider,
+ Slot,
+ Fill,
+} from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { registerPlugin, PluginArea } from '@wordpress/plugins';
+import { useState, useEffect, useRef } from '@wordpress/element';
+import { store as postAudioStore } from './store/register';
+import TaxonomyControls from './taxonomy-controls';
+import PrePubClassifyPost from './gutenberg-plugins/pre-publish-classify-post';
+import { DisableFeatureButton } from './components';
+
+const { classifaiPostData, classifaiTTSEnabled } = window;
+
+/**
+ * Create the ClassifAI icon
+ */
+const ClassifAIIcon = () => (
+
+);
+
+/**
+ * ClassificationToggle Component.
+ *
+ * Used to toggle the classification process on or off.
+ */
+const ClassificationToggle = () => {
+ // Use the datastore to retrieve all the meta for this post.
+ const processContent = useSelect( ( select ) =>
+ select( 'core/editor' ).getEditedPostAttribute(
+ 'classifai_process_content'
+ )
+ );
+
+ // Use the datastore to tell the post to update the meta.
+ const { editPost } = useDispatch( 'core/editor' );
+ const enabled = 'yes' === processContent ? 'yes' : 'no';
+
+ return (
+ {
+ editPost( { classifai_process_content: value ? 'yes' : 'no' } );
+ } }
+ />
+ );
+};
+
+/**
+ * Classify button.
+ *
+ * Used to manually classify the content.
+ */
+const ClassificationButton = () => {
+ const processContent = useSelect( ( select ) =>
+ select( 'core/editor' ).getEditedPostAttribute(
+ 'classifai_process_content'
+ )
+ );
+
+ const { select, dispatch } = wp.data;
+ const postId = select( 'core/editor' ).getCurrentPostId();
+ const postType = select( 'core/editor' ).getCurrentPostType();
+ const postTypeLabel =
+ select( 'core/editor' ).getPostTypeLabel() || __( 'Post', 'classifai' );
+
+ const [ isLoading, setLoading ] = useState( false );
+ const [ resultReceived, setResultReceived ] = useState( false );
+ const [ isOpen, setOpen ] = useState( false );
+ const [ popupOpened, setPopupOpened ] = useState( false );
+ const openModal = () => setOpen( true );
+ const closeModal = () => setOpen( false );
+
+ const [ taxQuery, setTaxQuery ] = useState( [] );
+ const [ featureTaxonomies, setFeatureTaxonomies ] = useState( [] );
+ let [ taxTermsAI, setTaxTermsAI ] = useState( [] );
+
+ /**
+ * Callback function to handle API response.
+ *
+ * @param {Object} resp Response from the API.
+ * @param {Object} callbackArgs Callback arguments.
+ */
+ const buttonClickCallBack = async ( resp, callbackArgs ) => {
+ if ( resp && resp.terms ) {
+ // set feature taxonomies
+ if ( resp?.feature_taxonomies ) {
+ setFeatureTaxonomies( resp.feature_taxonomies );
+ }
+
+ const taxonomies = resp.terms;
+ const taxTerms = {};
+ const taxTermsExisting = {};
+
+ // get current terms of the post
+ const currentTerms = select( 'core' ).getEntityRecord(
+ 'postType',
+ postType,
+ postId
+ );
+
+ Object.keys( taxonomies ).forEach( ( taxonomy ) => {
+ let tax = taxonomy;
+ if ( 'post_tag' === taxonomy ) {
+ tax = 'tags';
+ }
+ if ( 'category' === taxonomy ) {
+ tax = 'categories';
+ }
+
+ const currentTermsOfTaxonomy = currentTerms[ tax ];
+ if ( currentTermsOfTaxonomy ) {
+ taxTermsExisting[ tax ] = currentTermsOfTaxonomy;
+ }
+
+ const newTerms = Object.values( resp.terms[ taxonomy ] );
+ if ( newTerms && Object.keys( newTerms ).length ) {
+ // Loop through each term and add in taxTermsAI if it does not exist in the post.
+ taxTermsAI = taxTermsAI || {};
+ Object( newTerms ).forEach( ( termId ) => {
+ if ( taxTermsExisting[ tax ] ) {
+ const matchedTerm = taxTermsExisting[ tax ].find(
+ ( termID ) => termID === termId
+ );
+ if ( ! matchedTerm ) {
+ taxTermsAI[ tax ] = taxTermsAI[ tax ] || [];
+ // push only if not exist already
+ if ( ! taxTermsAI[ tax ].includes( termId ) ) {
+ taxTermsAI[ tax ].push( termId );
+ }
+ }
+ }
+ } );
+
+ // update the taxTerms
+ taxTerms[ tax ] = newTerms;
+ }
+ } );
+
+ // Merge taxterms with taxTermsExisting and remove duplicates
+ Object.keys( taxTermsExisting ).forEach( ( taxonomy ) => {
+ if ( taxTerms[ taxonomy ] ) {
+ // Merge taxTermsExisting into taxTerms
+ taxTerms[ taxonomy ] = taxTerms[ taxonomy ].concat(
+ taxTermsExisting[ taxonomy ]
+ );
+ } else {
+ // Initialize taxTerms with taxTermsExisting if not already set
+ taxTerms[ taxonomy ] = taxTermsExisting[ taxonomy ];
+ }
+
+ // Remove duplicate items from taxTerms
+ taxTerms[ taxonomy ] = [ ...new Set( taxTerms[ taxonomy ] ) ];
+ } );
+
+ setTaxQuery( taxTerms );
+ setTaxTermsAI( taxTermsAI );
+ }
+ if ( callbackArgs?.openPopup ) {
+ openModal();
+ setPopupOpened( true );
+ }
+ setLoading( false );
+ setResultReceived( true );
+ };
+
+ /**
+ * Save the terms (Modal).
+ *
+ * @param {Object} taxTerms Taxonomy terms.
+ */
+ const saveTerms = async ( taxTerms ) => {
+ // Remove index values from the nested object
+ // Convert the object into an array of key-value pairs
+ const taxTermsArray = Object.entries( taxTerms );
+
+ // Remove index values from the nested objects and convert back to an object
+ const newtaxTerms = Object.fromEntries(
+ taxTermsArray.map( ( [ key, value ] ) => {
+ if ( typeof value === 'object' ) {
+ return [ key, Object.values( value ) ];
+ }
+ return [ key, value ];
+ } )
+ );
+
+ await dispatch( 'core' ).editEntityRecord(
+ 'postType',
+ postType,
+ postId,
+ newtaxTerms
+ );
+
+ // If no edited values in post trigger save.
+ const isDirty = await select( 'core/editor' ).isEditedPostDirty();
+ if ( ! isDirty ) {
+ await dispatch( 'core' ).saveEditedEntityRecord(
+ 'postType',
+ postType,
+ postId
+ );
+ }
+
+ // Display success notice.
+ dispatch( 'core/notices' ).createSuccessNotice(
+ sprintf(
+ /** translators: %s is post type label. */
+ __( '%s classified successfully.', 'classifai' ),
+ postTypeLabel
+ ),
+ { type: 'snackbar' }
+ );
+ closeModal();
+ };
+
+ // Display classify post button only when process content on update is disabled.
+ const enabled = 'no' === processContent ? 'no' : 'yes';
+ if ( 'yes' === enabled ) {
+ return null;
+ }
+
+ const buttonText = __( 'Suggest terms & tags', 'classifai' );
+
+ let updatedTaxQuery = Object.entries( taxQuery || {} ).reduce(
+ ( accumulator, [ taxonomySlug, terms ] ) => {
+ accumulator[ taxonomySlug ] = terms;
+
+ return accumulator;
+ },
+ {}
+ );
+
+ if ( updatedTaxQuery.taxQuery ) {
+ updatedTaxQuery = updatedTaxQuery.taxQuery;
+ }
+
+ const modalData = (
+ <>
+ {
+ setTaxQuery( newTaxQuery );
+ } }
+ query={ {
+ contentPostType: postType,
+ featureTaxonomies,
+ taxQuery: updatedTaxQuery,
+ taxTermsAI: taxTermsAI || {},
+ isLoading,
+ } }
+ />
+
+
+ { sprintf(
+ /* translators: %s is post type label */
+ __(
+ 'Note that the lists above include any pre-existing terms from this %s.',
+ 'classifai'
+ ),
+ postTypeLabel
+ ) }
+
+ { __(
+ 'AI recommendations saved to this post will not include the "[AI]" text.',
+ 'classifai'
+ ) }
+