diff --git a/.github/workflows/plugin-check.yml b/.github/workflows/plugin-check.yml index d8a78e9fd..cd850b1ee 100644 --- a/.github/workflows/plugin-check.yml +++ b/.github/workflows/plugin-check.yml @@ -38,3 +38,4 @@ jobs: exclude-checks: 'plugin_readme,plugin_updater' # Plugin isn't on .org so excluding these for now. exclude-directories: 'assets,dist,vendor' ignore-warnings: true + ignore-codes: 'PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound' diff --git a/includes/Classifai/Admin/Settings.php b/includes/Classifai/Admin/Settings.php index d7d55f126..17605872d 100644 --- a/includes/Classifai/Admin/Settings.php +++ b/includes/Classifai/Admin/Settings.php @@ -110,6 +110,7 @@ public function admin_enqueue_scripts( $hook_suffix ) { 'postStatuses' => get_post_statuses_for_language_settings(), 'isEPinstalled' => is_elasticpress_installed(), 'nluTaxonomies' => $this->get_nlu_taxonomies(), + 'acfFields' => $this->get_acf_fields(), ); wp_add_inline_script( @@ -218,6 +219,49 @@ public function get_nlu_taxonomies(): array { return apply_filters( 'classifai_settings_ibm_watson_nlu_taxonomies', $taxonomies ); } + /** + * Get ACF fields if ACF is active. + * + * @since 3.6.0 + * + * @return array + */ + public function get_acf_fields(): array { + $acf_fields = array(); + + // Check if ACF is active. + if ( ! function_exists( 'acf_get_field_groups' ) ) { + return $acf_fields; + } + + // Get all ACF field groups. + $field_groups = acf_get_field_groups(); + + if ( empty( $field_groups ) ) { + return $acf_fields; + } + + // Loop through field groups and get their fields. + foreach ( $field_groups as $field_group ) { + $fields = function_exists( 'acf_get_fields' ) ? acf_get_fields( $field_group ) : array(); + + if ( ! empty( $fields ) ) { + foreach ( $fields as $field ) { + // Only include text, textarea, and wysiwyg fields for excerpt generation. + if ( in_array( $field['type'], array( 'text', 'textarea', 'wysiwyg' ), true ) ) { + $acf_fields[] = array( + 'key' => $field['key'], + 'name' => $field['name'], + 'label' => $field['label'], + 'type' => $field['type'], + ); + } + } + } + } + + return $acf_fields; + } /** * Get the settings. diff --git a/includes/Classifai/Features/ExcerptGeneration.php b/includes/Classifai/Features/ExcerptGeneration.php index e0bea64d0..4bbf121d8 100644 --- a/includes/Classifai/Features/ExcerptGeneration.php +++ b/includes/Classifai/Features/ExcerptGeneration.php @@ -143,6 +143,36 @@ public function register_endpoints() { ], ] ); + + // Add endpoint to get target field value. + register_rest_route( + 'classifai/v1', + 'get-target-field-value/(?P\d+)', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_target_field_value_callback' ], + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Post ID to get target field value for.', 'classifai' ), + ], + ], + 'permission_callback' => [ $this, 'generate_excerpt_permissions_check' ], + ] + ); + + // Add endpoint to get target field settings. + register_rest_route( + 'classifai/v1', + 'get-target-field-settings', + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_target_field_settings_callback' ], + 'permission_callback' => [ $this, 'generate_excerpt_permissions_check' ], + ] + ); } /** @@ -159,8 +189,23 @@ public function register_endpoints() { public function generate_excerpt_permissions_check( WP_REST_Request $request ) { $post_id = $request->get_param( 'id' ); + // For endpoints that don't require a post ID (like get-target-field-settings), + // just check if the feature is enabled and user has appropriate permissions. + if ( empty( $post_id ) ) { + if ( ! current_user_can( 'edit_posts' ) ) { + 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__( 'Excerpt generation not currently enabled.', 'classifai' ) ); + } + + return true; + } + // Ensure we have a logged in user that can edit the item. - if ( empty( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { + if ( ! current_user_can( 'edit_post', $post_id ) ) { return false; } @@ -215,22 +260,58 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { */ $author_name = apply_filters( 'classifai_excerpt_generation_author_name', $author_name, $post_id ); - return rest_ensure_response( - $this->run( - $post_id, - 'excerpt', - [ - 'content' => $request->get_param( 'content' ), - 'title' => $request->get_param( 'title' ), - 'author' => $author_name, - ] - ) + $result = $this->run( + $post_id, + 'excerpt', + [ + 'content' => $request->get_param( 'content' ), + 'title' => $request->get_param( 'title' ), + 'author' => $author_name, + ] ); + + // If generation was successful and we have a post ID, save to target field. + if ( ! is_wp_error( $result ) && $post_id ) { + $save_result = $this->save_excerpt_to_target_field( $result, $post_id ); + if ( is_wp_error( $save_result ) ) { + return rest_ensure_response( $save_result ); + } + } + + return rest_ensure_response( $result ); } return parent::rest_endpoint_callback( $request ); } + /** + * REST API callback to get target field value. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response + */ + public function get_target_field_value_callback( WP_REST_Request $request ) { + $post_id = $request->get_param( 'id' ); + $value = $this->get_current_target_field_value( $post_id ); + + return rest_ensure_response( + [ + 'value' => $value, + ] + ); + } + + /** + * REST API callback to get target field settings. + * + * @return WP_REST_Response + */ + public function get_target_field_settings_callback() { + $settings = $this->get_target_field_info(); + + return rest_ensure_response( $settings ); + } + /** * Enqueue the editor scripts. */ @@ -280,15 +361,19 @@ public function enqueue_admin_assets( string $hook_suffix ) { true ); + // Get target field information for JavaScript. + $target_field_info = $this->get_target_field_info(); + wp_add_inline_script( 'classifai-plugin-classic-excerpt-generation-js', sprintf( 'var classifaiGenerateExcerpt = %s;', wp_json_encode( [ - 'path' => '/classifai/v1/generate-excerpt/', - 'buttonText' => __( 'Generate excerpt', 'classifai' ), - 'regenerateText' => __( 'Re-generate excerpt', 'classifai' ), + 'path' => '/classifai/v1/generate-excerpt/', + 'buttonText' => __( 'Generate excerpt', 'classifai' ), + 'regenerateText' => __( 'Re-generate excerpt', 'classifai' ), + 'target_field_info' => $target_field_info, ] ) ), @@ -305,7 +390,7 @@ public function enqueue_admin_assets( string $hook_suffix ) { * @return string */ public function get_enable_description(): string { - return esc_html__( 'A button will be added to the excerpt panel that can be used to generate an excerpt.', 'classifai' ); + return esc_html__( 'A button will be added to the excerpt panel that can be used to generate an excerpt. You can configure where the generated excerpt is saved (default excerpt field, custom meta fields, or ACF fields).', 'classifai' ); } /** @@ -365,6 +450,108 @@ public function add_custom_settings_fields() { 'description' => __( 'How many words should the excerpt be? Note that the final result may not exactly match this, it often tends to exceed this number by 10-15 words.', 'classifai' ), ] ); + + // Add target field settings. + add_settings_field( + 'target_field', + esc_html__( 'Target field', 'classifai' ), + [ $this, 'render_target_field_settings' ], + $this->get_option_name(), + $this->get_option_name() . '_section', + [ + 'label_for' => 'target_field', + 'default_value' => $settings['target_field_type'] ?? 'post_excerpt', + 'description' => __( 'Choose where to save the generated excerpt. You can target the default excerpt field, custom meta fields, or ACF fields.', 'classifai' ), + ] + ); + } + + /** + * Render the target field settings. + * + * @param array $args Field arguments. + */ + public function render_target_field_settings( $args ) { + $settings = $this->get_settings(); + $field_type = $settings['target_field_type'] ?? 'post_excerpt'; + $custom_field = $settings['target_custom_field'] ?? ''; + + ?> +
+ + +
+
+ + +
+ +
+
+ + +
+
+ + + absint( apply_filters( 'excerpt_length', 55 ) ), 'provider' => ChatGPT::ID, + 'target_field_type' => 'post_excerpt', + 'target_custom_field' => '', + 'target_acf_field' => '', ]; } @@ -425,6 +615,15 @@ public function sanitize_default_feature_settings( array $new_settings ): array $new_settings['length'] = absint( $new_settings['length'] ?? $settings['length'] ); + // Sanitize target field settings. + $new_settings['target_field_type'] = sanitize_text_field( $new_settings['target_field_type'] ?? 'post_excerpt' ); + + if ( 'custom_meta' === $new_settings['target_field_type'] ) { + $new_settings['target_custom_field'] = sanitize_text_field( $new_settings['target_custom_field'] ?? '' ); + } elseif ( 'acf_field' === $new_settings['target_field_type'] ) { + $new_settings['target_acf_field'] = sanitize_text_field( $new_settings['target_acf_field'] ?? '' ); + } + foreach ( $post_types as $post_type ) { if ( ! post_type_supports( $post_type->name, 'excerpt' ) ) { continue; @@ -484,6 +683,202 @@ public function migrate_settings() { $new_settings['user_based_opt_out'] = $old_settings['excerpt_generation_user_based_opt_out']; } + // Set default target field type for existing installations. + $new_settings['target_field_type'] = 'post_excerpt'; + return $new_settings; } + + /** + * Save the generated excerpt to the target field. + * + * @param string $excerpt The generated excerpt. + * @param int $post_id The post ID. + * @return bool|WP_Error True on success, WP_Error on failure. + */ + public function save_excerpt_to_target_field( $excerpt, $post_id ) { + $settings = $this->get_settings(); + $field_type = $settings['target_field_type'] ?? 'post_excerpt'; + + /** + * Filter the excerpt before saving to target field. + * + * @since 3.x.x + * @hook classifai_excerpt_before_save_to_target + * + * @param {string} $excerpt The generated excerpt. + * @param {int} $post_id The post ID. + * @param {string} $field_type The target field type. + * + * @return {string} The excerpt to save. + */ + $excerpt = apply_filters( 'classifai_excerpt_before_save_to_target', $excerpt, $post_id, $field_type ); + + switch ( $field_type ) { + case 'post_excerpt': + $result = wp_update_post( + [ + 'ID' => $post_id, + 'post_excerpt' => wp_kses_post( $excerpt ), + ] + ); + return is_wp_error( $result ) ? $result : true; + + case 'custom_meta': + $meta_key = $settings['target_custom_field'] ?? ''; + if ( empty( $meta_key ) ) { + return new WP_Error( 'no_meta_key', __( 'No custom meta key specified.', 'classifai' ) ); + } + + /** + * Filter the meta key for custom meta field saving. + * + * @since 3.x.x + * @hook classifai_excerpt_custom_meta_key + * + * @param {string} $meta_key The meta key. + * @param {int} $post_id The post ID. + * + * @return {string} The meta key to use. + */ + $meta_key = apply_filters( 'classifai_excerpt_custom_meta_key', $meta_key, $post_id ); + + update_post_meta( $post_id, $meta_key, wp_kses_post( $excerpt ) ); + return true; + + case 'acf_field': + $acf_field_key = $settings['target_acf_field'] ?? ''; + if ( empty( $acf_field_key ) ) { + return new WP_Error( 'no_acf_field', __( 'No ACF field specified.', 'classifai' ) ); + } + if ( function_exists( 'update_field' ) ) { + update_field( $acf_field_key, wp_kses_post( $excerpt ), $post_id ); + return true; + } + return new WP_Error( 'acf_not_available', __( 'ACF plugin is not available.', 'classifai' ) ); + + default: + return new WP_Error( 'invalid_field_type', __( 'Invalid target field type.', 'classifai' ) ); + } + } + + /** + * Get the current value from the target field. + * + * @param int $post_id The post ID. + * @return string The current value. + */ + public function get_current_target_field_value( $post_id ) { + $settings = $this->get_settings(); + $field_type = $settings['target_field_type'] ?? 'post_excerpt'; + + switch ( $field_type ) { + case 'post_excerpt': + return get_the_excerpt( $post_id ); + + case 'custom_meta': + $meta_key = $settings['target_custom_field'] ?? ''; + return get_post_meta( $post_id, $meta_key, true ); + + case 'acf_field': + $acf_field_key = $settings['target_acf_field'] ?? ''; + if ( function_exists( 'get_field' ) ) { + return get_field( $acf_field_key, $post_id ); + } + return ''; + + default: + /** + * Filter for custom target field types when getting current value. + * + * @since 3.x.x + * @hook classifai_excerpt_get_custom_target_value + * + * @param {string} $value The current value. + * @param {int} $post_id The post ID. + * @param {string} $field_type The target field type. + * + * @return {string} The current value. + */ + return apply_filters( 'classifai_excerpt_get_custom_target_value', '', $post_id, $field_type ); + } + } + + /** + * Get available ACF fields for the target field selector. + * + * @return array Array of ACF fields. + */ + public function get_available_acf_fields() { + $fields = []; + + if ( ! function_exists( 'acf_get_field_groups' ) ) { + return $fields; + } + + $field_groups = acf_get_field_groups(); + foreach ( $field_groups as $field_group ) { + $acf_fields = function_exists( 'acf_get_fields' ) ? acf_get_fields( $field_group ) : array(); + if ( $acf_fields ) { + foreach ( $acf_fields as $field ) { + // Only include text-based fields. + if ( in_array( $field['type'], [ 'text', 'textarea', 'wysiwyg' ], true ) ) { + $fields[] = [ + 'key' => $field['key'], + 'label' => $field['label'], + 'group' => $field_group['title'], + ]; + } + } + } + } + + return $fields; + } + + /** + * Get target field information for JavaScript. + * + * @return array Target field information. + */ + public function get_target_field_info() { + $settings = $this->get_settings(); + $field_type = $settings['target_field_type'] ?? 'post_excerpt'; + + $info = [ + 'field_type' => $field_type, + 'field_name' => __( 'Default excerpt field', 'classifai' ), + ]; + + switch ( $field_type ) { + case 'custom_meta': + $meta_key = $settings['target_custom_field'] ?? ''; + // translators: %s is the custom meta key. + $info['field_name'] = $meta_key ? sprintf( __( 'Custom meta: %s', 'classifai' ), $meta_key ) : __( 'Custom meta field', 'classifai' ); + $info['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + break; + + case 'acf_field': + $acf_field_key = $settings['target_acf_field'] ?? ''; + if ( $acf_field_key && function_exists( 'acf_get_field' ) ) { + $acf_field = acf_get_field( $acf_field_key ); + $info['field_name'] = $acf_field ? $acf_field['label'] : __( 'ACF field', 'classifai' ); + $info['meta_key'] = $acf_field_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + } else { + $info['field_name'] = __( 'ACF field', 'classifai' ); + } + break; + } + + return $info; + } + + /** + * Check if ACF is available and active. + * + * @return bool True if ACF is available. + */ + public function is_acf_available() { + return function_exists( 'acf_get_field_groups' ) && function_exists( 'acf_get_fields' ); + } } diff --git a/includes/Classifai/Helpers/CredentialReuse.php b/includes/Classifai/Helpers/CredentialReuse.php index 77d80b253..5850f9ec5 100644 --- a/includes/Classifai/Helpers/CredentialReuse.php +++ b/includes/Classifai/Helpers/CredentialReuse.php @@ -13,14 +13,14 @@ * * Handles detection and reuse of service provider credentials across features. * - * @since x.x.x + * @since 3.6.0 */ class CredentialReuse { /** * Provider groups that share the same API key. * - * @since x.x.x + * @since 3.6.0 * * @var array */ @@ -48,7 +48,7 @@ class CredentialReuse { /** * Get all configured providers across all features. * - * @since x.x.x + * @since 3.6.0 * * @return array Array of configured providers with their credentials. */ @@ -76,7 +76,7 @@ public static function get_configured_providers(): array { /** * Get the provider group for a given provider ID. * - * @since x.x.x + * @since 3.6.0 * * @param string $provider_id The provider ID. * @return string|null The provider group name or null if not found. @@ -93,7 +93,7 @@ private static function get_provider_group( string $provider_id ): ?string { /** * Get all providers in the same group as the given provider. * - * @since x.x.x + * @since 3.6.0 * * @param string $provider_id The provider ID. * @return array Array of provider IDs in the same group. @@ -109,7 +109,7 @@ private static function get_providers_in_same_group( string $provider_id ): arra /** * Check if a provider is compatible with a feature. * - * @since x.x.x + * @since 3.6.0 * * @param string $provider_id The provider ID to check. * @param string $feature_id The feature ID to check against. @@ -129,7 +129,7 @@ public static function is_provider_compatible( string $provider_id, string $feat /** * Check if any provider in the same group is compatible with a feature. * - * @since x.x.x + * @since 3.6.0 * * @param string $provider_id The provider ID to check. * @param string $feature_id The feature ID to check against. @@ -150,7 +150,7 @@ private static function is_provider_group_compatible( string $provider_id, strin /** * Get the best matching provider ID for a feature from a provider group. * - * @since x.x.x + * @since 3.6.0 * * @param string $source_provider_id The source provider ID. * @param string $feature_id The target feature ID. @@ -177,7 +177,7 @@ private static function get_best_matching_provider( string $source_provider_id, /** * Get reusable credentials for a feature. * - * @since x.x.x + * @since 3.6.0 * * @param string $feature_id The feature ID to get credentials for. * @return array Array of compatible providers with existing credentials. @@ -216,7 +216,7 @@ public static function get_reusable_credentials( string $feature_id ): array { /** * Filter the reusable credentials for a feature. * - * @since x.x.x + * @since 3.6.0 * @hook classifai_reusable_credentials * * @param {array} $reusable Array of reusable credentials. @@ -230,7 +230,7 @@ public static function get_reusable_credentials( string $feature_id ): array { /** * Copy credentials from one feature to another. * - * @since x.x.x + * @since 3.6.0 * * @param string $source_feature_id Source feature ID. * @param string $target_feature_id Target feature ID. @@ -278,7 +278,7 @@ public static function copy_provider_credentials( string $source_feature_id, str /** * Fires after credentials are copied between features. * - * @since x.x.x + * @since 3.6.0 * @hook classifai_credentials_copied * * @param {string} $source_feature_id Source feature ID. @@ -293,7 +293,7 @@ public static function copy_provider_credentials( string $source_feature_id, str /** * Get all feature instances. * - * @since x.x.x + * @since 3.6.0 * * @return array Array of feature instances. */ @@ -320,7 +320,7 @@ private static function get_all_features(): array { /** * Get a user-friendly provider name. * - * @since x.x.x + * @since 3.6.0 * * @param string $provider_id The provider ID. * @return string The formatted provider name. @@ -352,7 +352,7 @@ public static function get_provider_display_name( string $provider_id ): string /** * Get provider groups for external use. * - * @since x.x.x + * @since 3.6.0 * * @return array Array of provider groups. */ diff --git a/src/js/features/excerpt-generation/classic/index.js b/src/js/features/excerpt-generation/classic/index.js index 5e4d000f3..5fcff45b6 100644 --- a/src/js/features/excerpt-generation/classic/index.js +++ b/src/js/features/excerpt-generation/classic/index.js @@ -39,8 +39,13 @@ const classifaiExcerptData = window.classifaiGenerateExcerpt || {}; // Boolean indicating whether generation is in progress. let isProcessing = false; + // Get target field settings and value. + let targetFieldSettings = null; + let targetFieldValue = ''; + const postId = $( '#post_ID' ).val(); + // Creates and appends the "Generate excerpt" button. - $( '', { + const regenerateButton = $( '', { text: buttonText, class: 'classifai-excerpt-generation__excerpt-generate-btn--text', } ) @@ -52,8 +57,10 @@ const classifaiExcerptData = window.classifaiGenerateExcerpt || {}; $( '', { class: 'classifai-excerpt-generation__excerpt-generate-btn--spinner', } ) - ) - .insertAfter( excerptContainer ); + ); + + // Insert the button after the excerpt container, but we'll move it later if custom field is enabled. + regenerateButton.insertAfter( excerptContainer ); $( '

', { class: 'classifai-excerpt-generation__excerpt-generate-error', @@ -87,10 +94,322 @@ const classifaiExcerptData = window.classifaiGenerateExcerpt || {}; ); } - // The current post ID. - const postId = $( '#post_ID' ).val(); + // Get target field settings. + apiFetch( { + path: '/classifai/v1/get-target-field-settings/', + } ) + .then( ( result ) => { + targetFieldSettings = result; + + // If custom field is enabled, hide the default excerpt field and show custom field info. + if ( + targetFieldSettings && + targetFieldSettings.field_type !== 'post_excerpt' + ) { + hideDefaultExcerptField(); + showCustomFieldInfo(); + } + } ) + .catch( () => { + // If endpoint doesn't exist, use default settings. + targetFieldSettings = { + field_type: 'post_excerpt', + field_name: __( 'Default excerpt field', 'classifai' ), + }; + } ); + + // Function to hide the default excerpt field. + function hideDefaultExcerptField() { + // Hide the default excerpt textarea and label, but keep the #postexcerpt container. + const excerptTextarea = $( '#excerpt' ); + const excerptLabel = $( 'label[for="excerpt"]' ); + + if ( excerptTextarea.length ) { + excerptTextarea.hide(); + } + + if ( excerptLabel.length ) { + excerptLabel.hide(); + } + + // Hide any other default excerpt content, but not the regenerate button. + const defaultExcerptContent = $( '#postexcerpt .inside' ); + if ( defaultExcerptContent.length ) { + defaultExcerptContent.hide(); + } + + // Make sure the regenerate button is visible. + const existingRegenerateButton = $( + '#classifai-excerpt-generation__excerpt-generate-btn' + ); + if ( existingRegenerateButton.length ) { + existingRegenerateButton.show(); + } + } + + // Function to show custom field info. + function showCustomFieldInfo() { + if ( + ! targetFieldSettings || + targetFieldSettings.field_type === 'post_excerpt' + ) { + return; + } + + // Get the current value. + apiFetch( { + path: `/classifai/v1/get-target-field-value/${ postId }`, + } ) + .then( ( result ) => { + targetFieldValue = result.value || ''; + + // Create custom field info container. + const customFieldContainer = $( '

', { + class: 'classifai-custom-excerpt-info', + style: 'margin: 0; padding: 15px; background: #f9f9f9; border: 1px solid #ddd; border-radius: 4px;', + } ); + + const label = $( '