diff --git a/includes/Experiments/Content_Classification/Content_Classification.php b/includes/Experiments/Content_Classification/Content_Classification.php index 38484578..356fb4ed 100644 --- a/includes/Experiments/Content_Classification/Content_Classification.php +++ b/includes/Experiments/Content_Classification/Content_Classification.php @@ -15,6 +15,8 @@ use WordPress\AI\Experiments\Experiment_Category; use WordPress\AI\Settings\Settings_Registration; +use function WordPress\AI\get_min_content_length; + if ( ! defined( 'ABSPATH' ) ) { exit; } @@ -151,9 +153,10 @@ public function enqueue_assets( string $hook_suffix ): void { 'content_classification', 'ContentClassificationData', array( - 'enabled' => $this->is_enabled(), - 'strategy' => $this->get_strategy(), - 'maxSuggestions' => $this->get_max_suggestions(), + 'enabled' => $this->is_enabled(), + 'strategy' => $this->get_strategy(), + 'maxSuggestions' => $this->get_max_suggestions(), + 'minContentLength' => get_min_content_length( 'content-classification', 150 ), ) ); } diff --git a/includes/Experiments/Content_Resizing/Content_Resizing.php b/includes/Experiments/Content_Resizing/Content_Resizing.php index 6090dd23..e204bfbd 100644 --- a/includes/Experiments/Content_Resizing/Content_Resizing.php +++ b/includes/Experiments/Content_Resizing/Content_Resizing.php @@ -14,6 +14,8 @@ use WordPress\AI\Asset_Loader; use WordPress\AI\Experiments\Experiment_Category; +use function WordPress\AI\get_min_content_length; + // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; @@ -87,7 +89,8 @@ public function enqueue_assets( string $hook_suffix ): void { 'content_resizing', 'ContentResizingData', array( - 'enabled' => $this->is_enabled(), + 'enabled' => $this->is_enabled(), + 'minContentLength' => get_min_content_length( 'content-resizing', 5 ), ) ); } diff --git a/includes/Experiments/Summarization/Summarization.php b/includes/Experiments/Summarization/Summarization.php index 4255612e..3c9d70b7 100644 --- a/includes/Experiments/Summarization/Summarization.php +++ b/includes/Experiments/Summarization/Summarization.php @@ -14,6 +14,8 @@ use WordPress\AI\Asset_Loader; use WordPress\AI\Experiments\Experiment_Category; +use function WordPress\AI\get_min_content_length; + // Exit if accessed directly. if ( ! defined( 'ABSPATH' ) ) { exit; @@ -106,10 +108,11 @@ public function enqueue_assets( string $hook_suffix ): void { * Filters the minimum content length required to enable summarization. * * @since 1.0.0 + * @deprecated x.x.x Use {@see 'wpai_min_content_length'} instead. * * @param int $min_content_length The minimum number of characters required. Default 100. */ - $min_content_length = (int) apply_filters( 'wpai_summarization_min_content_length', 100 ); + $min_content_length = (int) apply_filters( 'wpai_summarization_min_content_length', get_min_content_length( 'summarization', 100 ) ); Asset_Loader::localize_script( 'summarization', diff --git a/includes/helpers.php b/includes/helpers.php index 537d1ebe..d8425038 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -540,3 +540,24 @@ function is_connector_plugin_active( array $connector_data ): bool { return is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_file ); } + +/** + * Returns the minimum content length required for a given feature. + * + * @since x.x.x + * + * @param string $feature_id The feature identifier (e.g. 'content-resizing', 'content-classification', 'summarization'). + * @param int $content_length The default minimum content length. + * @return int The minimum content length. + */ +function get_min_content_length( string $feature_id, int $content_length = 100 ): int { + /** + * Filters the minimum content length required for a feature. + * + * @since x.x.x + * + * @param int $content_length The minimum content length. Default 100. + * @param string $feature_id The feature identifier. + */ + return (int) apply_filters( 'wpai_min_content_length', $content_length, $feature_id ); +} diff --git a/src/experiments/content-classification/components/SuggestionPanel.tsx b/src/experiments/content-classification/components/SuggestionPanel.tsx index 60f1ac23..87866f45 100644 --- a/src/experiments/content-classification/components/SuggestionPanel.tsx +++ b/src/experiments/content-classification/components/SuggestionPanel.tsx @@ -15,6 +15,7 @@ import { close as closeIcon, update } from '@wordpress/icons'; * Internal dependencies */ import { useContentClassification } from './useContentClassification'; +import { getWordCountType } from '../../../utils/word-count'; import type { TagSuggestion } from '../types'; interface SuggestionPanelProps { @@ -42,6 +43,7 @@ export default function SuggestionPanel( { handleAccept, handleDismiss, handleDismissAll, + minContentLength, } = useContentClassification( taxonomy ); const taxonomyObject: any = useSelect( @@ -76,10 +78,23 @@ export default function SuggestionPanel( { { ! hasEnoughContent && ! hasSuggestions && (
- { __( - 'Add more content to enable AI suggestions (approximately 150 words).', - 'ai' - ) } + { getWordCountType() !== 'words' + ? sprintf( + /* translators: %d: Minimum content length. */ + __( + 'Add more content to enable AI suggestions (approximately %d characters).', + 'ai' + ), + minContentLength + ) + : sprintf( + /* translators: %d: Minimum content length. */ + __( + 'Add more content to enable AI suggestions (approximately %d words).', + 'ai' + ), + minContentLength + ) }
) } diff --git a/src/experiments/content-classification/components/useContentClassification.ts b/src/experiments/content-classification/components/useContentClassification.ts index cb06e127..3a960661 100644 --- a/src/experiments/content-classification/components/useContentClassification.ts +++ b/src/experiments/content-classification/components/useContentClassification.ts @@ -10,7 +10,6 @@ import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; import { useState, useCallback } from '@wordpress/element'; import { store as noticesStore } from '@wordpress/notices'; -import { count as wordCount } from '@wordpress/wordcount'; import { addQueryArgs } from '@wordpress/url'; import apiFetch from '@wordpress/api-fetch'; @@ -19,6 +18,7 @@ import apiFetch from '@wordpress/api-fetch'; */ import { runAbility } from '../../../utils/run-ability'; import { ensureProvider } from '../../../utils/provider-status'; +import { hasMinimumContent } from '../../../utils/word-count'; import type { ContentClassificationAbilityInput, ContentClassificationResponse, @@ -26,7 +26,7 @@ import type { ContentClassificationData, } from '../types'; -const MINIMUM_WORD_COUNT = 150; +const MINIMUM_CONTENT_COUNT_DEFAULT = 150; const NOTICE_ID = 'ai_content_classification_error'; const DEFAULT_MAX_SUGGESTIONS = 5; const MIN_SUGGESTIONS = 1; @@ -55,6 +55,8 @@ const getSettings = (): ContentClassificationData => { enabled: settings.enabled ?? false, strategy: settings.strategy ?? 'existing_only', maxSuggestions: normalizeMaxSuggestions( settings.maxSuggestions ), + minContentLength: + settings.minContentLength ?? MINIMUM_CONTENT_COUNT_DEFAULT, }; }; @@ -139,6 +141,7 @@ export function useContentClassification( taxonomy: string ): { handleAccept: ( suggestion: TagSuggestion ) => void; handleDismiss: ( suggestion: TagSuggestion ) => void; handleDismissAll: () => void; + minContentLength: number; } { const { postId, content } = useSelect( ( selectFn ) => { const editor = selectFn( editorStore ); @@ -153,8 +156,10 @@ export function useContentClassification( taxonomy: string ): { const { removeNotice, createErrorNotice } = dispatch( noticesStore ) as any; // Check if content has enough words. - const hasEnoughContent = - wordCount( content || '', 'words' ) >= MINIMUM_WORD_COUNT; + const hasEnoughContent = hasMinimumContent( + content || '', + getSettings().minContentLength + ); const handleGenerate = useCallback( async () => { if ( ! ensureProvider( NOTICE_ID ) ) { @@ -233,6 +238,7 @@ export function useContentClassification( taxonomy: string ): { handleAccept, handleDismiss, handleDismissAll, + minContentLength: getSettings().minContentLength, }; } diff --git a/src/experiments/content-classification/types.ts b/src/experiments/content-classification/types.ts index 8657b27e..079a840f 100644 --- a/src/experiments/content-classification/types.ts +++ b/src/experiments/content-classification/types.ts @@ -38,4 +38,5 @@ export interface ContentClassificationData { enabled: boolean; strategy: string; maxSuggestions: number; + minContentLength: number; } diff --git a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx index fdd20368..c66c5dcd 100644 --- a/src/experiments/content-resizing/components/ContentResizingToolbar.tsx +++ b/src/experiments/content-resizing/components/ContentResizingToolbar.tsx @@ -19,21 +19,31 @@ import { useState, useCallback, useMemo } from '@wordpress/element'; import { __, _n, sprintf } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import { store as editorStore } from '@wordpress/editor'; -import { count } from '@wordpress/wordcount'; /** * Internal dependencies */ import { runAbility } from '../../../utils/run-ability'; import { getBlockText } from '../../../utils/blocks'; -import type { ContentResizingAction } from '../types'; +import type { ContentResizingAction, ContentResizingData } from '../types'; import { ICON_SHORTEN, ICON_EXPAND, ICON_REPHRASE } from '../icons'; import { ensureProvider } from '../../../utils/provider-status'; +import { getContentCount, getWordCountType } from '../../../utils/word-count'; import AIIcon from '../../../../routes/ai-home/ai-icon'; -const SHORTEN_MIN_WORDS = 5; +const SHORTEN_MIN_CONTENT_LENGTH = 5; const NOTICE_ID = 'ai_content_resizing_error'; +const getSettings = (): ContentResizingData => { + const settings = ( window as any ).aiContentResizingData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? SHORTEN_MIN_CONTENT_LENGTH, + }; +}; + /** * Content resizing toolbar component. * @@ -80,9 +90,9 @@ export default function ContentResizingToolbar( { } if ( action === 'shorten' ) { - const wordCount = count( blockContent, 'words', {} ); - // We need at least 5 words to shorten the content. - if ( wordCount < SHORTEN_MIN_WORDS ) { + const contentCount = getContentCount( blockContent ); + // We need at least the minimum content length to shorten. + if ( contentCount < getSettings().minContentLength ) { noticesDispatch.createErrorNotice( __( 'Text is too short to shorten further.', 'ai' ), { @@ -158,9 +168,10 @@ export default function ContentResizingToolbar( { return null; } + const isCharacterType = getWordCountType() !== 'words'; const delta = - count( suggestedContent, 'words', {} ) - - count( blockContent, 'words', {} ); + getContentCount( suggestedContent ) - + getContentCount( blockContent ); if ( delta === 0 ) { return { @@ -173,33 +184,49 @@ export default function ContentResizingToolbar( { const magnitude = Math.abs( delta ); if ( delta > 0 ) { + const label = isCharacterType + ? /* translators: %d: Number of characters added. */ + _n( '+%d character', '+%d characters', magnitude, 'ai' ) + : /* translators: %d: Number of words added. */ + _n( '+%d word', '+%d words', magnitude, 'ai' ); + const ariaLabel = isCharacterType + ? /* translators: %d: Number of characters added. */ + _n( + '%d character added', + '%d characters added', + magnitude, + 'ai' + ) + : /* translators: %d: Number of words added. */ + _n( '%d word added', '%d words added', magnitude, 'ai' ); + return { modifier: 'positive' as const, - label: sprintf( - /* translators: %d: Number of words added. */ - _n( '+%d word', '+%d words', magnitude, 'ai' ), - magnitude - ), - ariaLabel: sprintf( - /* translators: %d: Number of words added. */ - _n( '%d word added', '%d words added', magnitude, 'ai' ), - magnitude - ), + label: sprintf( label, magnitude ), + ariaLabel: sprintf( ariaLabel, magnitude ), }; } + const label = isCharacterType + ? /* translators: %d: Number of characters removed. */ + _n( '-%d character', '-%d characters', magnitude, 'ai' ) + : /* translators: %d: Number of words removed. */ + _n( '-%d word', '-%d words', magnitude, 'ai' ); + const ariaLabel = isCharacterType + ? /* translators: %d: Number of characters removed. */ + _n( + '%d character removed', + '%d characters removed', + magnitude, + 'ai' + ) + : /* translators: %d: Number of words removed. */ + _n( '%d word removed', '%d words removed', magnitude, 'ai' ); + return { modifier: 'negative' as const, - label: sprintf( - /* translators: %d: Number of words removed. */ - _n( '−%d word', '−%d words', magnitude, 'ai' ), - magnitude - ), - ariaLabel: sprintf( - /* translators: %d: Number of words removed. */ - _n( '%d word removed', '%d words removed', magnitude, 'ai' ), - magnitude - ), + label: sprintf( label, magnitude ), + ariaLabel: sprintf( ariaLabel, magnitude ), }; }, [ blockContent, suggestedContent ] ); diff --git a/src/experiments/content-resizing/types.ts b/src/experiments/content-resizing/types.ts index ae6f68d7..167fd1f6 100644 --- a/src/experiments/content-resizing/types.ts +++ b/src/experiments/content-resizing/types.ts @@ -4,3 +4,11 @@ export interface ContentResizingAbilityInput { content: string; action: ContentResizingAction; } + +/** + * Localized data from the PHP side. + */ +export interface ContentResizingData { + enabled: boolean; + minContentLength: number; +} diff --git a/src/experiments/summarization/components/SummarizationPlugin.tsx b/src/experiments/summarization/components/SummarizationPlugin.tsx index b5d8d114..a8367886 100644 --- a/src/experiments/summarization/components/SummarizationPlugin.tsx +++ b/src/experiments/summarization/components/SummarizationPlugin.tsx @@ -13,6 +13,7 @@ import { update } from '@wordpress/icons'; /** * Internal dependencies */ +import { getWordCountType } from '../../../utils/word-count'; import { useSummaryGeneration } from '../functions/useSummaryGeneration'; const { aiSummarizationData } = window as any; @@ -42,14 +43,24 @@ export default function SummarizationPlugin() { let buttonDescription: string; if ( isContentTooShort ) { - buttonDescription = sprintf( - /* translators: %d: minimum number of characters required */ - __( - 'Summarization will be available when the post content has at least %d characters.', - 'ai' - ), - minContentLength - ); + const isCharacterType = getWordCountType() !== 'words'; + buttonDescription = isCharacterType + ? sprintf( + /* translators: %d: minimum number of characters required */ + __( + 'Summarization will be available when the post content has at least %d characters.', + 'ai' + ), + minContentLength + ) + : sprintf( + /* translators: %d: minimum number of words required */ + __( + 'Summarization will be available when the post content has at least %d words.', + 'ai' + ), + minContentLength + ); } else if ( hasSummary ) { buttonDescription = __( 'This will update the generated summary block with a new summary of the content of this post.', diff --git a/src/experiments/summarization/functions/useSummaryGeneration.ts b/src/experiments/summarization/functions/useSummaryGeneration.ts index 44583cb6..8bbaff16 100644 --- a/src/experiments/summarization/functions/useSummaryGeneration.ts +++ b/src/experiments/summarization/functions/useSummaryGeneration.ts @@ -18,10 +18,21 @@ import { store as noticesStore } from '@wordpress/notices'; */ import { generateSummary } from './generate-summary'; import { ensureProvider } from '../../../utils/provider-status'; -import { count } from '@wordpress/wordcount'; +import { hasMinimumContent } from '../../../utils/word-count'; +import type { SummarizationData } from '../types'; +const MINIMUM_CONTENT_COUNT_DEFAULT = 100; const NOTICE_ID = 'ai_summarization_error'; -const { aiSummarizationData } = window as any; + +const getSettings = (): SummarizationData => { + const settings = ( window as any ).aiSummarizationData ?? {}; + + return { + enabled: settings.enabled ?? false, + minContentLength: + settings.minContentLength ?? MINIMUM_CONTENT_COUNT_DEFAULT, + }; +}; /** * Summary generation hook. @@ -131,10 +142,10 @@ export function useSummaryGeneration() { }; // Minimum content length required for summarization. - const minContentLength: number = - aiSummarizationData?.minContentLength ?? 100; - const isContentTooShort = - count( content, 'characters_including_spaces' ) < minContentLength; + const isContentTooShort = ! hasMinimumContent( + content || '', + getSettings().minContentLength + ); return { isSummarizing, @@ -142,6 +153,6 @@ export function useSummaryGeneration() { summary, handleSummarize, isContentTooShort, - minContentLength, + minContentLength: getSettings().minContentLength, }; } diff --git a/src/experiments/summarization/types.ts b/src/experiments/summarization/types.ts index aefdc538..a279b14e 100644 --- a/src/experiments/summarization/types.ts +++ b/src/experiments/summarization/types.ts @@ -10,3 +10,11 @@ export interface SummarizationAbilityInput { context: string; [ key: string ]: string | undefined; } + +/** + * Localized data from the PHP side. + */ +export interface SummarizationData { + enabled: boolean; + minContentLength: number; +} diff --git a/src/utils/word-count.ts b/src/utils/word-count.ts new file mode 100644 index 00000000..dccfc475 --- /dev/null +++ b/src/utils/word-count.ts @@ -0,0 +1,56 @@ +/** + * Shared word count utilities. + * + * Provides a standardized way to count content length across all features, + * respecting the user's locale for word/character-based counting. + */ + +/** + * WordPress dependencies + */ +import { _x } from '@wordpress/i18n'; +import { count as wordCount, type Strategy } from '@wordpress/wordcount'; + +/** + * Returns the word count type based on the user's locale. + * + * Uses the default (core) text domain so the word count type stays consistent + * with WordPress core's behavior. + * + * @return {Strategy} The word count strategy. + */ +export function getWordCountType(): Strategy { + /* + * translators: If your word count is based on single characters (e.g. East Asian characters), + * enter 'characters_excluding_spaces' or 'characters_including_spaces'. Otherwise, enter 'words'. + * Do not translate into your own language. + */ + // eslint-disable-next-line @wordpress/i18n-text-domain + return _x( 'words', 'Word count type. Do not translate!' ) as Strategy; +} + +/** + * Counts the content length using the locale-appropriate strategy. + * + * @param {string} content The content to count. + * + * @return {number} The content count (words or characters based on locale). + */ +export function getContentCount( content: string ): number { + return wordCount( content, getWordCountType() ); +} + +/** + * Checks if the content meets the minimum length requirement. + * + * @param {string} content The content to check. + * @param {number} minCount The minimum count required. + * + * @return {boolean} Whether the content meets the minimum length. + */ +export function hasMinimumContent( + content: string, + minCount: number +): boolean { + return getContentCount( content ) >= minCount; +} diff --git a/tests/e2e/specs/experiments/content-summarization.spec.js b/tests/e2e/specs/experiments/content-summarization.spec.js index 51c46605..9d782408 100644 --- a/tests/e2e/specs/experiments/content-summarization.spec.js +++ b/tests/e2e/specs/experiments/content-summarization.spec.js @@ -36,12 +36,12 @@ test.describe( 'Content Summarization Experiment', () => { // Enable the Content Summarization Experiment. await enableExperiment( admin, page, 'Content Summarization' ); - // Create a new post with content that meets the minimum length requirement (>= 100 chars). + // Create a new post with content that meets the minimum length requirement (>= 100 words). await admin.createNewPost( { postType: 'post', title: 'Test Content Summarization Experiment', content: - 'This is some test content for the Content Summarization Experiment. It needs to be at least one hundred characters long.', + 'This is some test content for the Content Summarization Experiment. It needs to have enough words to meet the minimum content length requirement for summarization to be enabled. The summarization feature requires a substantial amount of text before it will allow the user to generate a summary of the post content. This ensures that the generated summary is meaningful and provides value to readers who want a quick overview of what the full article contains. Adding more words here to make sure we exceed the minimum threshold that is configured for this experiment in the plugin settings and server side filters.', } ); // Save the post. @@ -132,7 +132,7 @@ test.describe( 'Content Summarization Experiment', () => { // Enable the Content Summarization Experiment. await enableExperiment( admin, page, 'Content Summarization' ); - // Create a new post with content shorter than 100 characters. + // Create a new post with content shorter than 100 words. await admin.createNewPost( { postType: 'post', title: 'Test Short Content', @@ -156,7 +156,7 @@ test.describe( 'Content Summarization Experiment', () => { // The descriptive text should explain when the button will be enabled. await expect( page.locator( '.ai-summarization-plugin-container .description' ) - ).toContainText( '100 characters' ); + ).toContainText( '100 words' ); } ); test( 'Summarize button is enabled when content meets the minimum length', async ( { @@ -170,12 +170,12 @@ test.describe( 'Content Summarization Experiment', () => { // Enable the Content Summarization Experiment. await enableExperiment( admin, page, 'Content Summarization' ); - // Create a new post with content that is at least 100 characters. + // Create a new post with content that is at least 100 words. await admin.createNewPost( { postType: 'post', title: 'Test Sufficient Content', content: - 'This post has enough content to meet the minimum character requirement for the summarization feature to be enabled.', + 'This post has enough content to meet the minimum word count requirement for the summarization feature to be enabled. The content needs to contain at least one hundred words so that the summarization experiment can generate a meaningful summary of the text. By including multiple sentences with various topics and ideas, we ensure that the AI has sufficient material to work with when creating a concise overview. This paragraph continues to add more words to reach the necessary threshold for testing purposes and to verify the feature works correctly.', } ); // Save the post. @@ -192,10 +192,10 @@ test.describe( 'Content Summarization Experiment', () => { await expect( generateButton ).toBeVisible(); await expect( generateButton ).toBeEnabled(); - // The descriptive text should NOT mention the minimum character requirement. + // The descriptive text should NOT mention the minimum word requirement. await expect( page.locator( '.ai-summarization-plugin-container .description' ) - ).not.toContainText( 'characters' ); + ).not.toContainText( 'words' ); } ); test( 'Ensure the Content Summarization Experiment UI is not visible when the experiment is disabled', async ( {