'X (Twitter)', 'linkedin' => 'LinkedIn', 'facebook' => 'Facebook', 'telegram' => 'Telegram', 'whatsapp' => 'WhatsApp', 'email' => 'Email', 'raindrop' => 'Raindrop', 'reddit' => 'Reddit', 'bluesky' => 'Bluesky', 'line' => 'LINE', 'mastodon' => 'Mastodon', 'threads' => 'Threads', 'pinterest' => 'Pinterest', 'claude' => 'Claude AI', 'chatgpt' => 'ChatGPT', 'google_ai' => 'Google AI', 'gemini' => 'Gemini', 'grok' => 'Grok', 'perplexity' => 'Perplexity', 'deepseek' => 'DeepSeek', 'mistral' => 'Mistral AI', 'copilot' => 'Microsoft Copilot', 'qwen' => 'Qwen', 'meta_ai' => 'Meta AI', ); } /** * Get social media buttons list * * @return array Social media button keys. */ function ayudawp_aiss_get_social_buttons() { return array( 'twitter', 'linkedin', 'facebook', 'telegram', 'whatsapp', 'email', 'raindrop', 'reddit', 'bluesky', 'line', 'mastodon', 'threads', 'pinterest' ); } /** * Get AI buttons list * * @return array AI button keys. */ function ayudawp_aiss_get_ai_buttons() { return array( 'claude', 'chatgpt', 'google_ai', 'gemini', 'grok', 'perplexity', 'deepseek', 'mistral', 'copilot', 'qwen', 'meta_ai' ); } /** * Sanitize a title_style value against an allowlist of HTML tag names. * * The value is emitted in tag-name position (`<{$tag}>`), where `esc_attr()` * is not enough: it does not encode spaces or `=`, so an attacker could turn * the opening tag into an arbitrary element with event-handler attributes. * Anything outside the allowlist falls back to `span`. * * @since 2.0.4 * @param string $value Untrusted tag name. * @return string Safe tag name from the allowlist. */ function ayudawp_aiss_sanitize_title_style( $value ) { $allowed = array( 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div' ); $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; return in_array( $value, $allowed, true ) ? $value : 'span'; } /** * Build an opening title/section tag from a safe tag name (v2.1.0) * * The tag name is validated against the allowlist *inside* this helper, via * ayudawp_aiss_sanitize_title_style(), so the allowlist can never be bypassed * by a caller that builds the tag itself. This replaces the inline * `'<' . esc_attr( $tag ) . ' class=...>'` pattern: esc_attr() does not encode * spaces or `=`, so emitting an unsanitized value in tag-name position was the * class of bug behind the 2.0.x stored XSS. Keeping the allowlist here removes * that recurring "looks vulnerable" pattern from the codebase. * * @since 2.1.0 * @param string $style Untrusted tag name (validated against the allowlist). * @param string $class CSS class(es) for the element (escaped here). * @return string Opening tag, e.g. '

'. */ function ayudawp_aiss_open_tag( $style, $class = '' ) { $tag = ayudawp_aiss_sanitize_title_style( $style ); $class = trim( (string) $class ); return '' !== $class ? '<' . $tag . ' class="' . esc_attr( $class ) . '">' : '<' . $tag . '>'; } /** * Build the matching closing tag for ayudawp_aiss_open_tag() (v2.1.0) * * @since 2.1.0 * @param string $style Untrusted tag name (validated against the allowlist). * @return string Closing tag, e.g. '

'. */ function ayudawp_aiss_close_tag( $style ) { return ''; } /** * Validate and sanitize plugin options * * @param array $input Raw input data. * @return array Validated and sanitized options. */ function ayudawp_aiss_validate_options( $input ) { $validated = array(); // Text fields. $text_fields = array( 'auto_insert_position', 'twitter_handle', 'title_text', 'title_style', 'button_order', 'button_alignment', 'button_colors', 'icon_style', 'seo_button_type', 'data_retention', 'button_size', 'ai_section_title', 'social_section_title', 'mastodon_instance', 'custom_color_bg', 'custom_color_text', 'dark_mode_adaptation', 'ai_summary_position', 'ai_summary_style', 'ai_summary_icon_position', 'ai_summary_label', 'ai_summary_generate_button_label', 'ai_summary_custom_bg', 'ai_summary_custom_text', ); $textarea_fields = array( 'default_prompt', 'custom_text' ); $array_fields = array( 'enabled_buttons', 'display_locations', 'auto_insert_content_types', 'button_custom_order_ai', 'button_custom_order_social', 'ai_summary_content_types' ); $boolean_fields = array( 'show_icons', 'delete_data_on_uninstall', 'exclude_noindex', 'ai_summary_collapsed_default', 'ai_summary_frontend_button', 'ai_summary_frontend_force_extractive', ); foreach ( $text_fields as $field ) { if ( isset( $input[ $field ] ) ) { $validated[ $field ] = sanitize_text_field( $input[ $field ] ); } } // title_style is emitted in tag-name position; restrict it to an allowlist. if ( isset( $validated['title_style'] ) ) { $validated['title_style'] = ayudawp_aiss_sanitize_title_style( $validated['title_style'] ); } foreach ( $textarea_fields as $field ) { if ( isset( $input[ $field ] ) ) { $validated[ $field ] = sanitize_textarea_field( $input[ $field ] ); } } foreach ( $array_fields as $field ) { if ( isset( $input[ $field ] ) && is_array( $input[ $field ] ) ) { $validated[ $field ] = array_map( 'sanitize_text_field', $input[ $field ] ); } else { $validated[ $field ] = array(); } } foreach ( $boolean_fields as $field ) { $validated[ $field ] = isset( $input[ $field ] ) ? (bool) $input[ $field ] : false; } // Validate seo_button_type. if ( isset( $validated['seo_button_type'] ) && ! in_array( $validated['seo_button_type'], array( 'link', 'button' ), true ) ) { $validated['seo_button_type'] = 'link'; } // Validate button_alignment. if ( isset( $validated['button_alignment'] ) && ! in_array( $validated['button_alignment'], array( 'left', 'center' ), true ) ) { $validated['button_alignment'] = 'left'; } // Validate data_retention. $valid_retention = array( '1_month', '3_months', '6_months', '1_year', 'forever' ); if ( isset( $validated['data_retention'] ) && ! in_array( $validated['data_retention'], $valid_retention, true ) ) { $validated['data_retention'] = 'forever'; } // Validate button_size. $valid_sizes = array( 'compact', 'normal', 'large', 'fluid' ); if ( isset( $validated['button_size'] ) && ! in_array( $validated['button_size'], $valid_sizes, true ) ) { $validated['button_size'] = 'normal'; } // Validate button_colors. $valid_styles = array( 'minimal', 'brand', 'outline', 'dark', 'icons-only', 'custom' ); if ( isset( $validated['button_colors'] ) && ! in_array( $validated['button_colors'], $valid_styles, true ) ) { $validated['button_colors'] = 'minimal'; } // Validate dark_mode_adaptation. $valid_dark_modes = array( 'disabled', 'auto' ); if ( isset( $validated['dark_mode_adaptation'] ) && ! in_array( $validated['dark_mode_adaptation'], $valid_dark_modes, true ) ) { $validated['dark_mode_adaptation'] = 'disabled'; } // Validate ai_summary_position (v2.0.0). $valid_summary_positions = array( 'before_buttons', 'after_buttons', 'before_content', 'disabled' ); if ( isset( $validated['ai_summary_position'] ) && ! in_array( $validated['ai_summary_position'], $valid_summary_positions, true ) ) { $validated['ai_summary_position'] = 'before_buttons'; } // Validate ai_summary_style (v2.1.0). $valid_summary_styles = array( 'minimal', 'outline', 'brand', 'dark', 'custom' ); if ( isset( $validated['ai_summary_style'] ) && ! in_array( $validated['ai_summary_style'], $valid_summary_styles, true ) ) { $validated['ai_summary_style'] = 'minimal'; } // Validate ai_summary_icon_position (v2.1.0). $valid_icon_positions = array( 'left', 'right', 'hidden' ); if ( isset( $validated['ai_summary_icon_position'] ) && ! in_array( $validated['ai_summary_icon_position'], $valid_icon_positions, true ) ) { $validated['ai_summary_icon_position'] = 'left'; } // Validate ai_summary_sentences (v2.0.0) — integer in 1..5. $validated['ai_summary_sentences'] = isset( $input['ai_summary_sentences'] ) ? max( 1, min( 5, absint( $input['ai_summary_sentences'] ) ) ) : 3; // Validate ai_summary_mode (v2.2.0) — explicit generation mode allowlist. $valid_summary_modes = array( 'ai_fallback', 'ai_only', 'extractive_only', 'disabled' ); $validated['ai_summary_mode'] = isset( $input['ai_summary_mode'] ) && in_array( $input['ai_summary_mode'], $valid_summary_modes, true ) ? $input['ai_summary_mode'] : 'ai_fallback'; // Validate ai_summary_model (v2.2.0) — '' (automatic) or a "provider:model" // pair restricted to a safe charset. When the live model list is available // the pair must exist in it; if enumeration is momentarily unavailable we // keep the sanitized value rather than wiping the admin's choice. $validated['ai_summary_model'] = ''; if ( isset( $input['ai_summary_model'] ) && '' !== $input['ai_summary_model'] ) { $candidate = preg_replace( '/[^a-zA-Z0-9:._-]/', '', (string) $input['ai_summary_model'] ); if ( '' !== $candidate && 1 === substr_count( $candidate, ':' ) ) { list( $cand_provider, $cand_model ) = explode( ':', $candidate, 2 ); if ( '' !== $cand_provider && '' !== $cand_model ) { $available = ayudawp_aiss_get_available_models(); $is_known = false; if ( isset( $available[ $cand_provider ]['models'] ) ) { foreach ( $available[ $cand_provider ]['models'] as $known_model ) { if ( $known_model['id'] === $cand_model ) { $is_known = true; break; } } } if ( $is_known || empty( $available ) ) { $validated['ai_summary_model'] = $candidate; } } } } // Sanitize hex colors. if ( isset( $validated['custom_color_bg'] ) ) { $validated['custom_color_bg'] = sanitize_hex_color( $validated['custom_color_bg'] ); } if ( isset( $validated['custom_color_text'] ) ) { $validated['custom_color_text'] = sanitize_hex_color( $validated['custom_color_text'] ); } if ( isset( $validated['ai_summary_custom_bg'] ) ) { $validated['ai_summary_custom_bg'] = sanitize_hex_color( $validated['ai_summary_custom_bg'] ); } if ( isset( $validated['ai_summary_custom_text'] ) ) { $validated['ai_summary_custom_text'] = sanitize_hex_color( $validated['ai_summary_custom_text'] ); } // Sanitize mastodon instance URL. if ( isset( $validated['mastodon_instance'] ) ) { $validated['mastodon_instance'] = sanitize_text_field( wp_unslash( $validated['mastodon_instance'] ) ); // Remove protocol and trailing slashes. $validated['mastodon_instance'] = preg_replace( '#^https?://#', '', $validated['mastodon_instance'] ); $validated['mastodon_instance'] = rtrim( $validated['mastodon_instance'], '/' ); } return $validated; } /** * Check if buttons should be displayed in current context * * @return bool True if buttons should be displayed. */ function ayudawp_aiss_should_display_buttons() { $options = get_option( 'ayudawp_aiss_options' ); $locations = isset( $options['display_locations'] ) ? $options['display_locations'] : array(); $content_types = isset( $options['auto_insert_content_types'] ) ? $options['auto_insert_content_types'] : array(); if ( empty( $locations ) ) { return false; } if ( is_singular() ) { $post_type = get_post_type(); if ( 'post' === $post_type && in_array( 'posts', $locations, true ) && in_array( 'post', $content_types, true ) ) { return true; } if ( 'page' === $post_type && in_array( 'pages', $locations, true ) && in_array( 'page', $content_types, true ) ) { return true; } if ( 'post' !== $post_type && 'page' !== $post_type && in_array( 'cpt', $locations, true ) && in_array( $post_type, $content_types, true ) ) { return true; } } if ( ( is_archive() || is_category() || is_tag() ) && in_array( 'archives', $locations, true ) ) { return true; } if ( is_home() && in_array( 'home', $locations, true ) ) { return true; } return false; } /** * Check if a specific post is excluded from showing buttons * * Checks both the meta box exclusion and SEO noindex status. * * @since 1.4.0 * @param int $post_id Post ID to check. * @return bool True if post is excluded, false otherwise. */ function ayudawp_aiss_is_post_excluded( $post_id ) { if ( ! $post_id ) { return false; } // Check meta box exclusion. if ( class_exists( 'AyudaWP_AISS_Meta_Box' ) && AyudaWP_AISS_Meta_Box::ayudawp_is_post_excluded( $post_id ) ) { return true; } // Check SEO noindex exclusion if enabled. $options = get_option( 'ayudawp_aiss_options' ); $exclude_noindex = isset( $options['exclude_noindex'] ) ? $options['exclude_noindex'] : true; if ( $exclude_noindex && class_exists( 'AyudaWP_AISS_SEO_Integration' ) ) { if ( AyudaWP_AISS_SEO_Integration::ayudawp_is_post_noindex( $post_id ) ) { return true; } } return false; } /** * Detect whether the canonical "AI" plugin (wordpress.org/plugins/ai/) is active * * Used to surface a hint when AI generation starts failing silently, * because that plugin ships an opt-in Connector approval system * (Tools > Connector Approvals) that intercepts and blocks calls to * `wp_ai_client_prompt()` from plugins it hasn't approved yet — without * exposing a programmatic filter for plugins to request approval, so the * only signal we have is a generic call failure when the canonical plugin * is around. * * @since 2.0.0 * @return bool */ function ayudawp_aiss_is_canonical_ai_plugin_active() { if ( ! function_exists( 'is_plugin_active' ) ) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; } return is_plugin_active( 'ai/ai.php' ); } /** * Resolve the post types where the AI summary feature applies (v2.0.0) * * Independent from the buttons' `auto_insert_content_types` so the admin * can choose, for example, to render buttons on posts only but generate * summaries on posts + products. Falls back to the buttons list when the * dedicated option has not been set yet (covers pre-migration sites). * * @since 2.0.0 * @return array List of post type slugs. */ function ayudawp_aiss_get_summary_post_types() { $options = get_option( 'ayudawp_aiss_options', array() ); if ( isset( $options['ai_summary_content_types'] ) && is_array( $options['ai_summary_content_types'] ) ) { return array_values( array_filter( array_map( 'strval', $options['ai_summary_content_types'] ) ) ); } if ( isset( $options['auto_insert_content_types'] ) && is_array( $options['auto_insert_content_types'] ) ) { return array_values( array_filter( array_map( 'strval', $options['auto_insert_content_types'] ) ) ); } return array( 'post' ); } /** * Check whether the AI summary feature applies to a given post (v2.0.0) * * Combines the per-post exclusion (meta box / noindex) with the new * `ai_summary_content_types` post-type whitelist. Centralised here so * the meta registration, save-post handler, frontend renderer and REST * endpoint all agree on the rule. * * @since 2.0.0 * @param int $post_id Post ID to check. * @return bool True when the summary should be generated and shown. */ function ayudawp_aiss_should_apply_summary( $post_id ) { $post_id = (int) $post_id; if ( ! $post_id ) { return false; } if ( ayudawp_aiss_is_post_excluded( $post_id ) ) { return false; } $post_type = get_post_type( $post_id ); if ( ! $post_type ) { return false; } $allowed = ayudawp_aiss_get_summary_post_types(); return in_array( $post_type, $allowed, true ); } /** * Resolve the AI summary generation mode (v2.2.0) * * Replaces the legacy ai_summary_enabled + ai_summary_use_extractive_fallback * boolean pair with a single explicit mode. Falls back to deriving the mode * from the old booleans for sites whose options have not been migrated yet. * * @since 2.2.0 * @return string One of: ai_fallback, ai_only, extractive_only, disabled. */ function ayudawp_aiss_get_summary_mode() { $options = get_option( 'ayudawp_aiss_options', array() ); if ( isset( $options['ai_summary_mode'] ) ) { $mode = (string) $options['ai_summary_mode']; if ( in_array( $mode, array( 'ai_fallback', 'ai_only', 'extractive_only', 'disabled' ), true ) ) { return $mode; } } // Legacy fallback for pre-2.2.0 options not yet migrated. $enabled = ! isset( $options['ai_summary_enabled'] ) || ! empty( $options['ai_summary_enabled'] ); if ( ! $enabled ) { return 'disabled'; } $fallback = ! isset( $options['ai_summary_use_extractive_fallback'] ) || ! empty( $options['ai_summary_use_extractive_fallback'] ); return $fallback ? 'ai_fallback' : 'ai_only'; } /** * Whether the AI summary feature is active (any mode other than disabled) (v2.2.0) * * @since 2.2.0 * @return bool True when summaries should be generated and displayed. */ function ayudawp_aiss_is_summary_active() { return 'disabled' !== ayudawp_aiss_get_summary_mode(); } /** * Enumerate the text-generation models available from the configured AI providers (v2.2.0) * * Walks the WordPress 7.0 AI Client provider registry, keeps the providers whose * companion plugin is active AND whose credentials are configured, and lists each * one's text-generation models. The AI Client caches the live model lookup for * 24h, so repeated calls are cheap. Used to build the model selector in Settings * and to resolve the "Automatic" default at generation time. * * Note: providers ship as separate plugins (ai-provider-for-anthropic, etc.); the * registry only lists those whose plugin is active, which is exactly the set that * can actually generate. Wrapped in try/catch \Throwable to survive any signature * drift in the AI Client builder API. * * @since 2.2.0 * @return array}> * Map of provider ID to its display name and model list. Empty when * the AI Client is unavailable or no provider is configured. */ function ayudawp_aiss_get_available_models() { if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { return array(); } $out = array(); try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); $ids = $registry->getRegisteredProviderIds(); if ( empty( $ids ) ) { return array(); } // Friendly provider display names for the UI. We deliberately avoid // wp_get_connectors() (WP 7.0) so the plugin stays within its declared // minimum WP version; unknown providers fall back to a title-cased ID. $provider_names = array( 'anthropic' => 'Anthropic', 'google' => 'Google', 'openai' => 'OpenAI', ); $requirements = new \WordPress\AiClient\Providers\Models\DTO\ModelRequirements( array( \WordPress\AiClient\Providers\Models\Enums\CapabilityEnum::textGeneration() ), array() ); foreach ( $ids as $provider_id ) { if ( ! $registry->isProviderConfigured( $provider_id ) ) { continue; } try { $models_metadata = $registry->findProviderModelsMetadataForSupport( $provider_id, $requirements ); } catch ( \Throwable $e ) { // One provider's live lookup failing must not break the others. continue; } $models = array(); foreach ( $models_metadata as $model_metadata ) { $models[] = array( 'id' => $model_metadata->getId(), 'name' => $model_metadata->getName(), ); } if ( empty( $models ) ) { continue; } $out[ $provider_id ] = array( 'name' => isset( $provider_names[ $provider_id ] ) ? $provider_names[ $provider_id ] : ucfirst( $provider_id ), 'models' => $models, ); } } catch ( \Throwable $e ) { return array(); } return $out; } /** * Resolve the ordered "Automatic" model candidate chain for summary generation * * When the admin leaves the model selector on "Automatic", we must not let the * WP AI Client fall back to its first discovered candidate, which is the newest * (and usually most expensive, sometimes access-restricted) flagship — exactly * what broke summaries when Claude Fable 5 / Sonnet 5 shipped and became the * first Anthropic candidates while being unavailable to, or pricier for, most * accounts. * * Instead we bias toward the cheapest tier of each provider (Haiku, Flash-Lite, * Nano...), matched by ID substring so the choice survives model-ID changes * (date suffixes, new minor versions). We return the FULL ordered chain — every * matching model, cheapest tier first, then any leftover models the patterns did * not match — as [providerId, modelId] tuples. Emitting tuples lets the builder * resolve the provider directly, and returning the whole chain (not a single * pick) gives generate_with_wp_ai_client() real fallbacks to degrade through * when a listed model turns out not to be usable. * * @since 2.2.0 * @since 2.2.1 Returns the full ordered chain as [providerId, modelId] tuples, * cheapest tier first, with a catch-all so a renamed catalog never * yields an empty list. * @param array|null $available Optional pre-fetched ayudawp_aiss_get_available_models() output. * @return array Ordered [providerId, modelId] tuples. May be empty. */ function ayudawp_aiss_resolve_default_model_preference( $available = null ) { if ( null === $available ) { $available = ayudawp_aiss_get_available_models(); } if ( empty( $available ) ) { return array(); } /** * Filters the per-provider model preference patterns for "Automatic" mode. * * Each provider maps to an ordered list of case-insensitive substrings, from * the cheapest/smallest tier to the priciest. Every model whose ID contains a * pattern is added in that order; models matching no pattern are appended * afterwards so the chain is never empty. Add entries for other providers to * bias their defaults too. * * @since 2.2.0 * @since 2.2.1 Ordered cheapest tier first; all matches are kept (no longer * just the first), and unmatched models are appended as a * catch-all. * @param array> $patterns Provider ID => ordered ID substrings. */ $patterns = apply_filters( 'ayudawp_aiss_default_models', array( 'anthropic' => array( 'haiku', 'sonnet', 'opus' ), 'google' => array( 'flash-lite', 'flash', 'pro' ), 'openai' => array( 'nano', 'mini' ), ) ); $preference = array(); foreach ( $available as $provider_id => $data ) { if ( empty( $data['models'] ) ) { continue; } $ordered = array(); $seen = array(); // 1) Add models matching the known tiers, cheapest first. if ( ! empty( $patterns[ $provider_id ] ) ) { foreach ( $patterns[ $provider_id ] as $needle ) { $needle = strtolower( (string) $needle ); if ( '' === $needle ) { continue; } foreach ( $data['models'] as $model ) { $id = (string) $model['id']; if ( ! isset( $seen[ $id ] ) && false !== strpos( strtolower( $id ), $needle ) ) { $ordered[] = $id; $seen[ $id ] = true; } } } } // 2) Catch-all: append any remaining models in provider order, so a // renamed catalog (no pattern matches) still yields candidates. foreach ( $data['models'] as $model ) { $id = (string) $model['id']; if ( ! isset( $seen[ $id ] ) ) { $ordered[] = $id; $seen[ $id ] = true; } } foreach ( $ordered as $id ) { $preference[] = array( $provider_id, $id ); } } return $preference; } /** * Get platform brand colors configuration * * Centralized color definitions used across CSS, JS, and admin. * * @since 1.6.0 * @return array Platform colors as key => hex pairs. */ function ayudawp_aiss_get_platform_colors() { return array( 'twitter' => '#000000', 'linkedin' => '#0077b5', 'facebook' => '#1877f2', 'telegram' => '#26A5E4', 'whatsapp' => '#25d366', 'email' => '#6c757d', 'raindrop' => '#0F66E3', 'reddit' => '#FF4500', 'bluesky' => '#0085FF', 'line' => '#00C300', 'mastodon' => '#6364FF', 'threads' => '#000000', 'pinterest' => '#BD081C', 'claude' => '#D97757', 'chatgpt' => '#10a37f', 'google_ai' => '#E84430', 'gemini' => '#8E75B2', 'grok' => '#000000', 'perplexity' => '#20808D', 'deepseek' => '#4D6BFE', 'mistral' => '#F7D046', 'copilot' => '#6264A7', 'qwen' => '#615CED', 'meta_ai' => '#0081FB', ); } /** * Enqueue frontend CSS, JS and localized data * * Centralised helper so both auto-insert (class-frontend) and * shortcode (class-shortcode) share the same enqueue logic. * Safe to call multiple times — assets are only loaded once. * * @since 1.7.2 */ function ayudawp_aiss_enqueue_frontend_assets() { if ( wp_style_is( 'ayudawp-aiss-styles', 'enqueued' ) ) { return; } wp_enqueue_style( 'ayudawp-aiss-styles', AYUDAWP_AISS_PLUGIN_URL . 'assets/css/ai-share-summarize.css', array(), AYUDAWP_AISS_VERSION ); wp_enqueue_script( 'ayudawp-aiss-scripts', AYUDAWP_AISS_PLUGIN_URL . 'assets/js/ai-share-summarize.js', array( 'jquery' ), AYUDAWP_AISS_VERSION, true ); $options = get_option( 'ayudawp_aiss_options', array() ); $dark_adapt_mode = isset( $options['dark_mode_adaptation'] ) ? $options['dark_mode_adaptation'] : 'disabled'; wp_localize_script( 'ayudawp-aiss-scripts', 'ayudawpAissL10n', array( 'promptCopied' => __( 'Prompt copied to clipboard!', 'ai-share-summarize' ), // Generic copy-prompt tooltips. 'copyPromptShort' => __( 'Copy prompt & open', 'ai-share-summarize' ), 'copyPromptLong' => __( 'Copy prompt and open', 'ai-share-summarize' ), // Gemini specific tooltips. 'geminiTooltipShort' => __( 'Copy prompt & open', 'ai-share-summarize' ), 'geminiTooltipLong' => __( 'Copy prompt and open Gemini', 'ai-share-summarize' ), // DeepSeek specific tooltips. 'deepseekTooltipShort' => __( 'Copy prompt & open', 'ai-share-summarize' ), 'deepseekTooltipLong' => __( 'Copy prompt and open DeepSeek', 'ai-share-summarize' ), // Copilot specific tooltips. 'copilotTooltipShort' => __( 'Copy prompt & open', 'ai-share-summarize' ), 'copilotTooltipLong' => __( 'Copy prompt and open Copilot', 'ai-share-summarize' ), // Platform names for tooltips. 'platformNames' => array( 'twitter' => 'X (Twitter)', 'linkedin' => 'LinkedIn', 'facebook' => 'Facebook', 'telegram' => 'Telegram', 'whatsapp' => 'WhatsApp', 'email' => __( 'Email', 'ai-share-summarize' ), 'raindrop' => 'Raindrop', 'reddit' => 'Reddit', 'bluesky' => 'Bluesky', 'line' => 'LINE', 'claude' => 'Claude AI', 'chatgpt' => 'ChatGPT', 'google_ai' => 'Google AI', 'gemini' => 'Gemini', 'grok' => 'Grok', 'perplexity' => 'Perplexity', 'deepseek' => 'DeepSeek', 'mistral' => 'Mistral AI', 'copilot' => 'Microsoft Copilot', ), // Analytics click tracking (v1.5.0) - no nonce needed for public counter. 'trackUrl' => esc_url_raw( rest_url( 'aiss/v1/track' ) ), // Dark-mode adaptation: 'disabled' (default) or 'auto' // ('auto' enables the JS background detector that toggles .is-on-dark). 'darkAdaptMode' => $dark_adapt_mode, // Public AI summary generation (v2.0.0). 'publicGenerateUrl' => esc_url_raw( rest_url( 'aiss/v1/summary/generate' ) ), 'summaryGenerating' => __( 'Generating…', 'ai-share-summarize' ), 'summaryGenerateError' => __( 'Generation failed. Try again.', 'ai-share-summarize' ), ) ); } /** * Clean HTML/block content for summarization (v2.0.0) * * `wp_strip_all_tags()` on raw post content concatenates table cells and * code blocks without separators, producing nonsense like: * "NombreDescripcionCategoriaJardinBilbaoRevisado..." * * This helper preprocesses the HTML so the sentence splitter has natural * breakpoints to work with: * * 1. Removes shortcodes and bare autoembed URLs (a URL alone on its own * line or paragraph renders as an embedded player, so it is media, * not prose). * 2. Strips WordPress block comments (``). * 3. Removes entire blocks that are usually noise for prose summarization * (tables, code, captions, forms, navigation, embeds). * 4. Replaces block-level tag boundaries with ". " so sentences from * different paragraphs/list-items don't get concatenated. * 5. Strips remaining inline tags via wp_strip_all_tags(). * 6. Normalizes whitespace and collapses runs of dots. * * Applied by both the extractive summarizer and the AI Client prompt so * the LLM also receives clean text instead of table soup. * * @since 2.0.0 * @since 2.1.1 Bare oEmbed/autoembed URLs (YouTube, Vimeo...) are removed too. * @param string $html Raw HTML / block content. * @return string Cleaned plain text ready to summarize. */ function ayudawp_aiss_clean_html_for_summary( $html ) { $html = (string) $html; if ( '' === $html ) { return ''; } // 0. Remove shortcodes wholesale BEFORE anything else, so the summary // never includes raw shortcode markup like `[gallery ids="1,2,3"]`, // `[caption ...]...[/caption]` or any plugin-defined shortcode. We // cannot `do_shortcode()` here because that would inject the whole // rendered gallery/embed back into the prose we want to summarize. $html = strip_shortcodes( $html ); // 0.5 Remove oEmbed URLs: a URL alone on its own line or alone in its // paragraph becomes an embedded player (YouTube, Vimeo, X...) when // the post renders. WP_Embed::autoembed() detects embeds with these // same two patterns — we mirror them, deleting instead of embedding. // Linked URLs () and URLs quoted inside a sentence don't // match (core doesn't embed those either), so prose is untouched. $html = preg_replace( '#^\s*https?://[^\s<>"]+\s*$#im', ' ', $html ); $html = preg_replace( '#]*)?>\s*https?://[^\s<>"]+\s*

#i', ' ', $html ); // 1. Strip block comments (Gutenberg leaves them in post_content). $html = preg_replace( '##s', '', $html ); // 2. Remove noisy elements wholesale. Filterable so themes/plugins // can add custom ones (e.g. an "advertisement" shortcode wrapper). $noisy = apply_filters( 'ayudawp_aiss_summary_noisy_tags', array( 'table', 'pre', 'code', 'figcaption', 'figure', 'form', 'button', 'nav', 'script', 'style', 'iframe', 'svg', 'audio', 'video' ) ); if ( ! empty( $noisy ) ) { $pattern = '#<(' . implode( '|', array_map( 'preg_quote', $noisy ) ) . ')\b[^>]*>.*?#is'; $html = preg_replace( $pattern, ' ', $html ); } // 3. Replace block-level tag boundaries (open AND close) with a // sentence separator so prose from different blocks doesn't get // concatenated by wp_strip_all_tags(). $boundaries = 'p|h[1-6]|li|div|td|th|tr|br|hr|blockquote|aside|section|article|header|footer|dd|dt'; $html = preg_replace( '#]*)?/?>#i', '. ', $html ); // 4. Strip the rest. $text = wp_strip_all_tags( $html ); // 5. Whitespace normalization + collapse repeated punctuation runs. $text = preg_replace( '/\s+/u', ' ', $text ); $text = preg_replace( '/(\s*\.\s*){2,}/', '. ', $text ); // ". . . " → ". " $text = preg_replace( '/\s+\./', '.', $text ); // " ." → "." $text = preg_replace( '/\.{2,}/', '.', $text ); // ".." → "." return trim( $text ); } /** * Split a block of text into sentences (v2.1.0) * * Single source of truth for sentence segmentation, shared by the extractive * summarizer and the frontend renderer (which prints one

per sentence for * readability). Splits on sentence-final punctuation followed by whitespace and * an uppercase/opening character, so decimals and most abbreviations inside a * sentence are not split. * * @since 2.1.0 * @param string $text Plain text (already stripped of HTML). * @return array Trimmed, non-empty sentences in original order. */ function ayudawp_aiss_split_sentences( $text ) { $text = trim( (string) $text ); if ( '' === $text ) { return array(); } $parts = preg_split( '/(?<=[.!?])\s+(?=[A-ZÁÉÍÓÚÑ¿¡"\'])/u', $text ); if ( ! is_array( $parts ) ) { return array( $text ); } $parts = array_map( 'trim', $parts ); return array_values( array_filter( $parts, 'strlen' ) ); } /** * Generate an extractive summary from post content (v2.0.0) * * Picks the top N sentences scored by token-frequency (excluding stopwords), * with a bonus for title words and a lead-position bonus for the first two * sentences. Returns sentences in their original order so the summary reads * naturally. * * Used as a fallback when WP AI Client is not available, or when the call * to wp_ai_client_prompt() fails. * * @since 2.0.0 * @param string $content Raw post content (HTML accepted, will be stripped). * @param string $title Post title — its words get a 2x weight bonus. * @param int $n Target sentence count (clamped to 1..5). * @return string Summary text, or '' if the content is too short to summarize. */ function ayudawp_aiss_extractive_summary( $content, $title, $n = 3 ) { $text = ayudawp_aiss_clean_html_for_summary( (string) $content ); if ( '' === $text ) { return ''; } $sentences = ayudawp_aiss_split_sentences( $text ); if ( count( $sentences ) < 3 ) { return ''; } $stopwords = apply_filters( 'ayudawp_aiss_extractive_stopwords', ayudawp_aiss_get_stopwords() ); /** * Tunable weights. Filterable so themes/plugins can adjust them without * forking the algorithm. * * - lead_bonus: multiplier applied to the first two sentences. Lowered * from the previous 1.3 because it made the first sentence always * "win", giving the impression the summarizer hadn't done anything. * - title_weight: how much extra each title word contributes to its * frequency score. Raised from 2x to 3x so title-relevant sentences * stand out more reliably. * - min_words: discard sentences shorter than this (likely headings * leaked from the markup, navigation labels, etc). * - similarity_threshold: Jaccard similarity above which a candidate * sentence is considered redundant with one already selected. * * @since 2.0.0 */ $weights = apply_filters( 'ayudawp_aiss_extractive_weights', array( 'lead_bonus' => 1.1, 'title_weight' => 3, 'min_words' => 5, 'similarity_threshold' => 0.5, ) ); // Tokenize once per sentence and reuse — Jaccard needs token sets too. $sentence_tokens = array(); foreach ( $sentences as $i => $sentence ) { $tokens = preg_split( '/\W+/u', mb_strtolower( $sentence, 'UTF-8' ) ); $tokens = is_array( $tokens ) ? array_values( array_filter( $tokens, 'strlen' ) ) : array(); $sentence_tokens[ $i ] = $tokens; } // Build word-frequency table over content-bearing tokens. $freq = array(); foreach ( $sentence_tokens as $tokens ) { foreach ( $tokens as $token ) { if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) { continue; } $freq[ $token ] = isset( $freq[ $token ] ) ? $freq[ $token ] + 1 : 1; } } // Title words boost their frequency weight. $title_tokens = preg_split( '/\W+/u', mb_strtolower( (string) $title, 'UTF-8' ) ); if ( is_array( $title_tokens ) ) { foreach ( $title_tokens as $token ) { if ( isset( $freq[ $token ] ) ) { $freq[ $token ] *= (float) $weights['title_weight']; } } } // Score sentences and remember which are eligible (have enough words). $scored = array(); $eligible = array(); $jaccard_set = array(); // Per-sentence content-token set used for similarity comparison. foreach ( $sentence_tokens as $i => $tokens ) { if ( count( $tokens ) < (int) $weights['min_words'] ) { continue; } $score = 0.0; $set = array(); foreach ( $tokens as $token ) { if ( isset( $stopwords[ $token ] ) || mb_strlen( $token, 'UTF-8' ) < 3 ) { continue; } if ( isset( $freq[ $token ] ) ) { $score += $freq[ $token ]; } $set[ $token ] = 1; } if ( $i < 2 ) { $score *= (float) $weights['lead_bonus']; } $scored[ $i ] = $score; $eligible[ $i ] = true; $jaccard_set[ $i ] = $set; } if ( empty( $scored ) ) { return ''; } arsort( $scored ); $n = max( 1, min( 5, (int) $n ) ); // Greedy MMR-style selection: walk candidates by score descending and // skip any that are too similar (Jaccard >= threshold) to a sentence // already picked. Falls back to plain top-N if Jaccard filtering would // leave us with fewer than N selections. $threshold = (float) $weights['similarity_threshold']; $selected = array(); foreach ( array_keys( $scored ) as $idx ) { if ( count( $selected ) >= $n ) { break; } $is_redundant = false; foreach ( $selected as $picked ) { $intersection = count( array_intersect_key( $jaccard_set[ $idx ], $jaccard_set[ $picked ] ) ); $union = count( $jaccard_set[ $idx ] + $jaccard_set[ $picked ] ); $similarity = $union > 0 ? ( $intersection / $union ) : 0; if ( $similarity >= $threshold ) { $is_redundant = true; break; } } if ( ! $is_redundant ) { $selected[] = $idx; } } // Backfill if the similarity filter was too aggressive. if ( count( $selected ) < $n ) { foreach ( array_keys( $scored ) as $idx ) { if ( count( $selected ) >= $n ) { break; } if ( ! in_array( $idx, $selected, true ) ) { $selected[] = $idx; } } } // Restore original sentence order in the output. sort( $selected ); $pieces = array(); foreach ( $selected as $index ) { if ( isset( $sentences[ $index ] ) ) { $pieces[] = $sentences[ $index ]; } } return trim( implode( ' ', $pieces ) ); } /** * Get the stopwords lookup table for the extractive summarizer (v2.0.0) * * Returns an associative array (`'the' => 1`) so callers can use * `isset( $stopwords[ $token ] )` for O(1) membership checks. * * Covers English and Spanish — extend via the `ayudawp_aiss_extractive_stopwords` * filter to support additional languages. * * @since 2.0.0 * @return array Associative array of stopwords. */ function ayudawp_aiss_get_stopwords() { $words = array( // English. 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use', 'with', 'this', 'that', 'from', 'they', 'have', 'were', 'been', 'will', 'your', 'what', 'when', 'make', 'like', 'time', 'just', 'know', 'take', 'into', 'some', 'them', 'than', 'then', 'look', 'only', 'come', 'over', 'also', 'back', 'after', 'work', 'first', 'well', 'even', 'want', 'because', 'these', 'give', 'most', 'about', 'which', 'their', 'would', 'there', 'could', 'other', 'should', 'where', 'while', 'being', 'does', 'each', 'such', 'very', 'much', 'many', 'more', 'made', 'said', 'here', // Spanish. 'los', 'las', 'una', 'uno', 'del', 'que', 'por', 'con', 'para', 'pero', 'sus', 'sin', 'son', 'mas', 'muy', 'ese', 'esa', 'eso', 'este', 'esta', 'esto', 'hay', 'fue', 'ser', 'han', 'has', 'como', 'cuando', 'donde', 'porque', 'sobre', 'entre', 'desde', 'hasta', 'también', 'tambien', 'cada', 'todo', 'todos', 'todas', 'algo', 'algun', 'alguna', 'mucho', 'mucha', 'muchos', 'muchas', 'otro', 'otra', 'otros', 'otras', 'mismo', 'misma', 'tanto', 'tanta', 'poco', 'poca', 'nada', 'ante', 'bajo', 'cabe', 'contra', 'segun', 'según', 'durante', 'mediante', 'hacia', 'aqui', 'aquí', 'alli', 'allí', 'ahi', 'ahí', 'asi', 'así', 'aun', 'aún', 'ya', 'si', 'sí', 'siempre', 'nunca', 'jamás', 'jamas', 'antes', 'despues', 'después', 'luego', 'entonces', 'estaba', 'estaban', 'estamos', 'estar', 'estado', 'estos', 'estas', 'eran', 'era', 'eres', 'haber', 'había', 'habia', 'habían', 'habian', 'hace', 'hacer', 'hecho', 'puede', 'pueden', 'porqué', 'porque', 'cual', 'cuál', 'quien', 'quién', 'cuanto', 'cuánto', ); return array_fill_keys( $words, 1 ); }