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' + ) } +
+ +
+ + + ); + + return ( +
+ { isOpen && ( + + { modalData } + + ) } + + + + + { ! resultReceived && ( + <> + + + + + ) } + { resultReceived && modalData } + +
+ ); +}; + +/** + * ClassifAI Text to Audio component. + */ +const ClassifAITTS = () => { + // State of the audio being previewed in PluginDocumentSettingPanel. + const [ isPreviewing, setIsPreviewing ] = useState( false ); + + const [ timestamp, setTimestamp ] = useState( new Date().getTime() ); + + // Indicates whether speech synthesis is enabled for the current post. + const isSynthesizeSpeech = useSelect( ( select ) => + select( 'core/editor' ).getEditedPostAttribute( + 'classifai_synthesize_speech' + ) + ); + + // Indicates whether generated audio should be displayed on the frontend. + const displayGeneratedAudio = useSelect( ( select ) => + select( 'core/editor' ).getEditedPostAttribute( + 'classifai_display_generated_audio' + ) + ); + + // Post type label. + const postTypeLabel = useSelect( + ( select ) => + ( typeof select( 'core/editor' ).getPostTypeLabel !== 'undefined' && + select( 'core/editor' ).getPostTypeLabel() ) || + __( 'Post', 'classifai' ) + ); + + // Says whether speech synthesis is in progress. + const isProcessingAudio = useSelect( ( select ) => + select( postAudioStore ).getIsProcessing() + ); + + // The audio ID saved in the DB for the current post. + const defaultAudioId = useSelect( ( select ) => + select( 'core/editor' ).getEditedPostAttribute( + 'classifai_post_audio_id' + ) + ); + + // New audio ID in case it is regenerated manually or through publishing/updating the current post. + const audioId = + useSelect( ( select ) => select( postAudioStore ).getAudioId() ) || + defaultAudioId; + + // Get the attachment data by audio ID. + const attachments = useSelect( ( select ) => + select( 'core' ).getEntityRecords( 'postType', 'attachment', { + include: [ audioId ], + } ) + ); + + // Get URL for the attachment. + const sourceUrl = + attachments && attachments.length > 0 && attachments[ 0 ].source_url; + + const isProcessingAudioProgress = useRef( false ); + const isPostSavingInProgress = useRef( false ); + const { isSavingPost } = useSelect( ( select ) => { + return { + isSavingPost: select( 'core/editor' ).isSavingPost(), + }; + } ); + const { isAutosavingPost } = useSelect( ( select ) => { + return { + isSavingPost: select( 'core/editor' ).isAutosavingPost(), + }; + } ); + + // Handles playing/pausing post audio during preview. + useEffect( () => { + const audioEl = document.getElementById( 'classifai-audio-preview' ); + + if ( ! audioEl ) { + return; + } + + if ( isPreviewing ) { + audioEl.play(); + } else { + audioEl.pause(); + } + }, [ isPreviewing ] ); + + // Generates a unique timestamp to cache bust audio file. + useEffect( () => { + if ( isProcessingAudio ) { + isProcessingAudioProgress.current = true; + } + + if ( isProcessingAudioProgress.current && ! isProcessingAudio ) { + setTimestamp( new Date().getTime() ); + } + }, [ isProcessingAudio ] ); + + useEffect( () => { + // Code to run during post saving is in process. + if ( + isSavingPost && + ! isAutosavingPost && + ! isPostSavingInProgress.current + ) { + isPostSavingInProgress.current = true; + if ( isSynthesizeSpeech ) { + wp.data.dispatch( postAudioStore ).setIsProcessing( true ); + } + } + + if ( + ! isSavingPost && + ! isAutosavingPost && + isPostSavingInProgress.current + ) { + // Code to run after post is done saving. + isPostSavingInProgress.current = false; + wp.data.dispatch( postAudioStore ).setIsProcessing( false ); + } + }, [ isSavingPost, isAutosavingPost, isSynthesizeSpeech ] ); + + // Fetches the latest audio file to avoid disk cache. + const cacheBustingUrl = `${ sourceUrl }?ver=${ timestamp }`; + + let audioIcon = 'controls-play'; + + if ( isProcessingAudio ) { + audioIcon = 'format-audio'; + } else if ( isPreviewing ) { + audioIcon = 'controls-pause'; + } + + return ( + <> + { + wp.data.dispatch( 'core/editor' ).editPost( { + classifai_synthesize_speech: value, + } ); + } } + disabled={ isProcessingAudio } + isBusy={ isProcessingAudio } + /> + { sourceUrl && ( + <> + { + wp.data.dispatch( 'core/editor' ).editPost( { + classifai_display_generated_audio: value, + } ); + } } + disabled={ isProcessingAudio } + isBusy={ isProcessingAudio } + /> + + + + + ) } + { sourceUrl && ( + + ) } + + ); +}; + +/** + * Add the ClassifAI panel to Gutenberg + */ +const ClassifAIPlugin = () => { + const postType = useSelect( ( select ) => + select( 'core/editor' ).getCurrentPostType() + ); + const postStatus = useSelect( ( select ) => + select( 'core/editor' ).getCurrentPostAttribute( 'status' ) + ); + + // Ensure that at least one feature is enabled. + const isNLULanguageProcessingEnabled = + classifaiPostData && classifaiPostData.NLUEnabled; + + // Ensure we are on a supported post type, checking settings from all features. + const isNLUPostTypeSupported = + classifaiPostData && + classifaiPostData.supportedPostTypes && + classifaiPostData.supportedPostTypes.includes( postType ); + + // Ensure we are on a supported post status, checking settings from all features. + const isNLUPostStatusSupported = + classifaiPostData && + classifaiPostData.supportedPostStatues && + classifaiPostData.supportedPostStatues.includes( postStatus ); + + // Ensure the user has permissions to use the feature. + const userHasNLUPermissions = + classifaiPostData && + ! ( + classifaiPostData.noPermissions && + 1 === parseInt( classifaiPostData.noPermissions ) + ); + + const nluPermissionCheck = + userHasNLUPermissions && + isNLULanguageProcessingEnabled && + isNLUPostTypeSupported && + isNLUPostStatusSupported; + + return ( + + <> + { nluPermissionCheck && ( + <> + + { nluPermissionCheck && } + + ) } + { classifaiTTSEnabled && } + + + + + + + ); +}; + +let saveHappened = false; +let showingNotice = false; + +subscribe( () => { + if ( saveHappened === false ) { + saveHappened = wp.data.select( 'core/editor' ).isSavingPost() === true; + } + + if ( + saveHappened && + wp.data.select( 'core/editor' ).isSavingPost() === false && + showingNotice === false + ) { + const meta = wp.data + .select( 'core/editor' ) + .getCurrentPostAttribute( 'meta' ); + if ( meta && meta._classifai_text_to_speech_error ) { + showingNotice = true; + const error = JSON.parse( meta._classifai_text_to_speech_error ); + wp.data + .dispatch( 'core/notices' ) + .createErrorNotice( + `Audio generation failed. Error: ${ error.code } - ${ error.message }` + ); + saveHappened = false; + showingNotice = false; + } + } +} ); + +registerPlugin( 'classifai-plugin', { render: ClassifAIPlugin } ); diff --git a/webpack.config.js b/webpack.config.js index 322100481..d0c35a876 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,6 +36,7 @@ module.exports = { './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-rewrite-tone': './src/js/features/rewrite-tone/index.js', }, module: { rules: [