* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * String_Utils class. */ class String_Utils { /** * Multibyte-safe strpos with fallback * * @param string $haystack The string to search in * @param string $needle The string to search for * @param int $offset The position to start searching from * @return int|false The position of the needle, or false if not found * @since 1.0.0 */ public static function mb_strpos( $haystack, $needle, $offset = 0 ) { return function_exists( 'mb_strpos' ) ? mb_strpos( $haystack, $needle, $offset ) : strpos( $haystack, $needle, $offset ); } /** * Multibyte-safe strlen with fallback * * @param string $str The string to measure * @return int The length of the string * @since 1.0.0 */ public static function mb_strlen( $str ) { return function_exists( 'mb_strlen' ) ? mb_strlen( $str ) : strlen( $str ); } /** * Multibyte-safe substr with fallback * * @param string $str The input string * @param int $start The start position * @param int|null $length The length of the substring * @return string The extracted substring * @since 1.0.0 */ public static function mb_substr( $str, $start, $length = null ) { return function_exists( 'mb_substr' ) ? mb_substr( $str, $start, $length ) : substr( $str, $start, $length ); } /** * Multibyte-safe strtolower with fallback * * @param string $str The string to convert * @return string The lowercase string * @since 1.0.0 */ public static function mb_strtolower( $str ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $str ) : strtolower( $str ); } /** * Multibyte-safe trim using regex * * @param string $str The string to trim * @return string The trimmed string * @since 1.0.0 */ public static function mb_trim( $str ) { return preg_replace( '/(^\s+)|(\s+$)/us', '', $str ); } /** * Sanitize a string for safe use as an HTTP header value * * Strips CR, LF, and NUL bytes from anywhere in the string to defeat header * injection (CRLF) and C-string truncation attacks, then trims surrounding * whitespace. Use this on any user-controlled value before interpolating it * into a header line or passing it to a client library that builds headers. * * @param mixed $value The raw value * @return string Sanitized value safe to use as a header value * @since 1.0.1 */ public static function sanitize_header_value( $value ) { return self::mb_trim( str_replace( array( "\r", "\n", "\0" ), '', (string) $value ) ); } /** * Multibyte-safe strcut with fallback * * Cuts a string at byte position but ensures it doesn't break multibyte characters. * This is safer than substr() for UTF-8 strings. * * @param string $str The input string * @param int $start The start position in bytes * @param int|null $length The length in bytes * @param string $encoding The character encoding (default: UTF-8) * @return string The extracted substring * @since 1.0.0 */ public static function mb_strcut( $str, $start, $length = null, $encoding = 'UTF-8' ) { if ( function_exists( 'mb_strcut' ) ) { return mb_strcut( $str, $start, $length, $encoding ); } // Fallback: use substr but this may break multibyte characters return null === $length ? substr( $str, $start ) : substr( $str, $start, $length ); } /** * Decode JSON string with error handling * * @param string $json JSON string to decode * @param bool $associative When true, returns associative arrays instead of objects * @return mixed Decoded value * @throws \Exception If JSON is invalid * @since 1.0.0 */ public static function decode_json( $json, $associative = true ) { $data = json_decode( $json, $associative ); if ( json_last_error() !== JSON_ERROR_NONE ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( self::replace_tags( __( 'Invalid JSON: {error}', 'agenistrator' ), array( 'error' => json_last_error_msg() ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } return $data; } /** * Fix empty arrays that should be objects for proper JSON encoding * * PHP's json_encode() encodes empty arrays as [] but APIs often expect * certain fields to be objects {}. This method converts empty arrays to * stdClass for paths that match the provided patterns. * * Path patterns use a simple matching system: * - Exact path: 'tools.0.function.parameters.properties' matches exactly that path * - Wildcard: '*.properties' matches 'properties' at any depth * - The path is built by joining keys with '.' * * Only empty arrays are converted. Non-empty arrays and non-array values * (e.g., booleans) are left unchanged. This allows handling fields that * CAN be objects but aren't guaranteed to be (like additionalProperties). * * @param array $data The data array to fix * @param array $object_paths Array of path patterns that should be objects when empty * @return array The fixed data with empty arrays converted to stdClass where needed * @since 1.0.0 */ public static function fix_empty_arrays_to_objects( $data, $object_paths ) { if ( ! is_array( $data ) ) { return $data; } return self::fix_empty_arrays_recursive( $data, '', $object_paths ); } /** * Recursively fix empty arrays that should be objects * * @param mixed $value The current value being processed * @param string $current_path The current path in the data (dot-separated) * @param array $object_paths Array of path patterns that should be objects * @return mixed The fixed value */ private static function fix_empty_arrays_recursive( $value, $current_path, $object_paths ) { foreach ( $value as $key => &$child ) { // Build the path for this child $child_path = '' === $current_path ? (string) $key : $current_path . '.' . $key; // Only process arrays - leave non-arrays (booleans, strings, etc.) unchanged if ( ! is_array( $child ) ) { continue; } // Check if this path should be an object when empty if ( empty( $child ) && self::path_matches_pattern( $child_path, $key, $object_paths ) ) { $child = new \stdClass(); } else { // Recursively process $child = self::fix_empty_arrays_recursive( $child, $child_path, $object_paths ); } } return $value; } /** * Check if a path matches any of the provided patterns * * @param string $full_path The full dot-separated path * @param string $key The current key name * @param array $patterns Array of patterns to match against * @return bool True if the path matches any pattern */ private static function path_matches_pattern( $full_path, $key, $patterns ) { foreach ( $patterns as $pattern ) { // Check for wildcard pattern (*.key) if ( 0 === strpos( $pattern, '*.' ) ) { $pattern_key = substr( $pattern, 2 ); if ( $key === $pattern_key ) { return true; } } elseif ( $full_path === $pattern ) { return true; } } return false; } /** * Helper for replace_tags function * * @param string $str The string to wrap in curly braces * @return string The string wrapped in curly braces */ private static function add_curly_braces( $str ) { return '{' . $str . '}'; } /** * Takes a string with tags and replaces tags in the string with the given values in $tags array * * @param string $template The string with tags in {tag} format * @param array $tags Associative array of tag => value pairs * IMPORTANT: By convention, all tag values must be pre-escaped using appropriate * WordPress escaping functions (esc_html, esc_attr, esc_url, etc.) * or esc_html__() for localized strings before passing to this function. * This function does NOT perform any escaping on the values. * @return string The string with tags replaced * @since 1.0.0 */ public static function replace_tags( $template, $tags = array() ) { return str_replace( array_map( array( __CLASS__, 'add_curly_braces' ), array_keys( $tags ) ), array_values( $tags ), $template ); } /** * Takes html template file and renders it with the given attributes * * @param string $template_filepath Full path to the template file * @param array $attributes Associative array of attributes to replace in the template * IMPORTANT: By convention, all attribute values must be pre-escaped using appropriate * WordPress escaping functions (esc_html, esc_attr, esc_url, etc.) * or esc_html__() for localized strings before passing to this function. * This function does NOT perform any escaping on the values. * @return string The rendered HTML * @since 1.0.0 */ public static function render_file( $template_filepath, $attributes = array() ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- $template_filepath is a local filesystem path return self::replace_tags( file_get_contents( $template_filepath ), $attributes ); } /** * Renders an HTML template with the given attributes * * @param string $template The template name without .html extension * @param array $attributes Associative array of attributes to replace in the template * IMPORTANT: By convention, all attribute values must be pre-escaped using appropriate * WordPress escaping functions (esc_html, esc_attr, esc_url, etc.) * or esc_html__() for localized strings before passing to this function. * This function does NOT perform any escaping on the values. * @return string The rendered HTML * @since 1.0.0 */ public static function render( $template, $attributes = array() ) { return self::render_file( plugin_dir_path( __DIR__ ) . 'admin/html/' . $template . '.html', $attributes ); } /** * Renders a notice with the given attributes * * @param string $notice_id Unique ID for the notice (for dismissible notices) * @param string $type Notice type (success, warning, error) * @param array $attributes Associative array of attributes * @return string The rendered HTML * @since 1.0.0 */ public static function render_notice( $notice_id, $type, $attributes = array() ) { if ( ! isset( $attributes['classes'] ) ) { $attributes['classes'] = ''; } $attributes['classes'] = trim( $attributes['classes'] . ' notice-' . esc_attr( $type ) ); if ( ! isset( $attributes['label'] ) ) { $attributes['label'] = esc_html__( 'Agenistrator', 'agenistrator' ); } if ( $notice_id ) { $attributes['attributes'] = 'data-notice-id="' . esc_attr( $notice_id ) . '"'; $attributes['classes'] .= ' is-dismissible'; } return self::render( 'notice', $attributes ); } /** * Renders a success notice with the given attributes * * @param string $notice_id Unique ID for the notice * @param array $attributes Associative array of attributes * @return string The rendered HTML * @since 1.0.0 */ public static function render_success_notice( $notice_id, $attributes = array() ) { return self::render_notice( $notice_id, 'success', $attributes ); } /** * Renders a warning notice with the given attributes * * @param string $notice_id Unique ID for the notice * @param array $attributes Associative array of attributes * @return string The rendered HTML * @since 1.0.0 */ public static function render_warning_notice( $notice_id, $attributes = array() ) { return self::render_notice( $notice_id, 'warning', $attributes ); } /** * Renders an error notice with the given attributes * * @param string $notice_id Unique ID for the notice * @param array $attributes Associative array of attributes * @return string The rendered HTML * @since 1.0.0 */ public static function render_error_notice( $notice_id, $attributes = array() ) { return self::render_notice( $notice_id, 'error', $attributes ); } /** * Truncate text to a maximum length, preserving token boundaries and appending ellipsis * * Uses split_on_token_boundary to ensure truncation happens at token boundaries, * preventing tokens from being cut mid-way. * * Note: This method works with byte count (not characters) to limit information density. * Bytes are a better measure of information content than character count - for example, * a Chinese character (4 bytes) carries more semantic information than an ASCII character (1 byte), * but at the same time the Chinese character takes up more bytes, so, byte count is going to be roughly similar. * * @param string $text The text to truncate * @param int $size_in_bytes Target length in bytes (actual size may be larger due to token-boundary alignment) * @return string Truncated text with ellipsis * @since 1.0.0 */ public static function truncate_to_token_boundary_with_ellipsis( $text, $size_in_bytes ) { // Fixed truncation marker used in tool/LLM-facing output $ellipsis = '... [truncated]'; // Use strlen for byte length comparison if ( strlen( $text ) <= $size_in_bytes ) { return $text; } // Reserve space for ellipsis (in bytes) $ellipsis_bytes = strlen( $ellipsis ); $size_in_bytes -= $ellipsis_bytes; if ( $size_in_bytes <= 0 ) { return $ellipsis; } // Use split_on_token_boundary to truncate at token boundary $result = self::split_on_token_boundary( $text, 0, $size_in_bytes ); return $result['content'] . $ellipsis; } /** * Multibyte-safe strrpos with fallback * * @param string $haystack The string to search in * @param string $needle The string to search for * @param int $offset The position to start searching from * @return int|false The position of the last occurrence, or false if not found * @since 1.0.0 */ public static function mb_strrpos( $haystack, $needle, $offset = 0 ) { return function_exists( 'mb_strrpos' ) ? mb_strrpos( $haystack, $needle, $offset ) : strrpos( $haystack, $needle, $offset ); } /** * Split content on token boundary * * This method cuts content at a specific byte offset and chunk size, but ensures * the cut happens on a token boundary to prevent confusion for LLMs. It starts * from the exact byte offset but has flexibility in chunk size. * * Algorithm: * 1. Cut at the requested byte offset and chunk size * 2. Count tokens in the chunk * 3. Keep appending bytes until token count increases * 4. This guarantees we cut on token boundaries * * @param string $text The text to split * @param int $offset The byte offset to start from * @param int $size_in_bytes Target chunk size in bytes (actual size may be larger due to token-boundary alignment) * @return array Array with keys: 'content', 'total_size', 'offset', 'size', 'has_more' * @since 1.0.0 */ public static function split_on_token_boundary( $text, $offset, $size_in_bytes ) { $total_size = strlen( $text ); // Validate offset if ( $offset < 0 || $offset >= $total_size ) { return array( 'content' => '', 'total_size' => $total_size, 'offset' => $offset, 'size' => 0, 'has_more' => false, ); } // Extract initial chunk - mb_strcut works with byte offsets/lengths and ensures we don't break multibyte UTF-8 characters $chunk = self::mb_strcut( $text, $offset, $size_in_bytes, 'UTF-8' ); $chunk_size = strlen( $chunk ); // If we've reached the end, return as-is if ( $offset + $chunk_size >= $total_size ) { return array( 'content' => $chunk, 'total_size' => $total_size, 'offset' => $offset, 'size' => $chunk_size, 'has_more' => false, ); } // Count tokens in the initial chunk $token_counter = Token_Counter::get_instance(); $current_token_count = $token_counter->count_tokens( $chunk ); // Keep appending bytes until token count increases // This ensures we cut on a token boundary $max_iterations = 100; // Safety limit to prevent infinite loops $iteration = 0; while ( $iteration < $max_iterations && $offset + $chunk_size < $total_size ) { // Try adding one more byte $next_chunk = self::mb_strcut( $text, $offset, $chunk_size + 1, 'UTF-8' ); $next_token_count = $token_counter->count_tokens( $next_chunk ); // If token count increased, we've crossed a token boundary if ( $next_token_count > $current_token_count ) { break; } // Token count didn't increase, keep this byte $chunk = $next_chunk; $chunk_size = strlen( $chunk ); ++$iteration; } return array( 'content' => $chunk, 'total_size' => $total_size, 'offset' => $offset, 'size' => $chunk_size, 'has_more' => ( $offset + $chunk_size ) < $total_size, ); } }