! empty($fields['hp']) ? $fields['hp'] : 'rsvl_hp', 'js' => ! empty($fields['js']) ? $fields['js'] : 'rsvl_js', ]; } /** * Whether Pro features are running: the Pro version with an active license. * * Only the Pro version turns this on, so in the free version it's always * false. Use it to decide whether to show an upgrade notice; the Pro * features themselves hook in and never check it. */ function resolver_is_pro(): bool { /** * Filter whether Pro features are running. Set by the Pro version only. * * @param bool $is_pro Default false. */ return (bool) apply_filters('resolver_is_pro', false); } /** * Render a template from the templates/ directory. * * Data is passed via $args and read inside the template as $args['key']. * Escaping happens in the template at the point of output. * * @param string $name Template name without extension, e.g. 'pro-notice'. * @param array $args Data made available to the template. */ function resolver_template(string $name, array $args = []) { $name = str_replace(['..', '\\'], '', $name); /** * Filter the folders templates are looked up in, first match wins. The * Pro version puts its own first, so its version of a template (a * training panel, say) replaces the free version's upgrade notice. * * @param string[] $dirs Absolute paths with a trailing slash. */ $dirs = (array) apply_filters('resolver_template_dirs', [RSVL_PLUGIN_DIR . 'templates/']); foreach ($dirs as $dir) { $file = $dir . $name . '.php'; if (file_exists($file)) { include $file; return; } } } /** * Render a template and return its output as a string. * * @param string $name Template name without extension. * @param array $args Data made available to the template. * @return string Rendered HTML. */ function resolver_get_template(string $name, array $args = []): string { ob_start(); resolver_template($name, $args); return ob_get_clean(); } /** * Output an inline SVG from admin/icons/. * * Included like a template: the icons are static markup shipped with the * plugin, with nothing dynamic in them to escape. Run through wp_kses() they * would lose the inline styles several depend on (fill, stroke, transform), * which aren't on WordPress's list of safe CSS. * * Reads from the filesystem: RSVL_PLUGIN_URL made this an HTTP request back to * the site for every icon, which is slow, breaks wherever the site cannot reach * itself, and surfaced missing icons as a raw file_get_contents warning. * * @param string $icon Icon file name without extension, e.g. 'appearance'. */ function resolver_svg(string $icon = ''): void { if ('' === $icon) { return; } // Name comes from template code, never user input, but keep it to a single // path segment so a stray value cannot walk out of the icons directory. $icon = basename($icon); $file = RSVL_PLUGIN_DIR . 'admin/icons/' . $icon . '.php'; if (! file_exists($file)) { return; } include $file; } /** * Return an inline SVG from admin/icons/ as a string, e.g. for a script. * * @param string $icon Icon file name without extension, e.g. 'like'. */ function resolver_get_svg(string $icon): string { ob_start(); resolver_svg($icon); return (string) ob_get_clean(); } /** * A percentage in the site's language: "45%", "45 %" or "%45". * * @param int|float $number */ function resolver_format_percent($number, int $decimals = 0): string { /* translators: %s: a number. Only the percent sign's position and spacing change, e.g. "%s %%" for "45 %". */ return sprintf(__('%s%%', 'resolver-ai-chatbot'), number_format_i18n($number, $decimals)); // phpcs:ignore WordPress.WP.I18n.NoEmptyStrings -- the sign's placement is what's translated. } /** * The date format for a date shown in the admin, in the site's language. * Translatable, so the order and punctuation suit each language, while * wp_date() translates the month names. Times follow the site's own Time * Format setting. * * @param string $style 'date' ("Sep 25, 2026"), 'day' ("Sep 25", for chart * labels), 'hour' ("3pm", for the activity heatmap), * 'time' or 'datetime'. */ function resolver_date_format(string $style = 'date'): string { $time = get_option('time_format') ?: 'g:i a'; return match ($style) { /* translators: Date format for chart labels: a day without the year. See https://www.php.net/manual/datetime.format.php */ 'day' => __('M j', 'resolver-ai-chatbot'), /* translators: Hour format for the activity heatmap's narrow columns, so keep it short: e.g. "3pm", or "15" for a 24-hour clock. See https://www.php.net/manual/datetime.format.php */ 'hour' => __('ga', 'resolver-ai-chatbot'), 'time' => $time, 'datetime' => resolver_date_format() . ' ' . $time, /* translators: Short date format for tables and lists. See https://www.php.net/manual/datetime.format.php */ default => __('M j, Y', 'resolver-ai-chatbot'), }; } /** * A chart label for a day ("Sep 25"), from its date as Y-m-d. The date names * a calendar day, not a moment, so it is formatted as it is rather than moved * into the site's timezone, which would show the day before west of UTC. */ function resolver_day_label(string $date): string { $utc = new DateTimeZone('UTC'); return wp_date(resolver_date_format('day'), (new DateTimeImmutable($date, $utc))->getTimestamp(), $utc); } /** * One row per day from $since to $until (Y-m-d, inclusive), for charts, which * need a continuous axis: ['date' => day] plus that day's values from * $by_day, with $empty filling any the day lacks. * * @param array $by_day Values keyed by day (Y-m-d), each an array. * @param array $empty A day's values when it has none, e.g. ['likes' => 0]. */ function resolver_daily_rows(string $since, string $until, array $by_day, array $empty): array { $rows = []; for ($day = $since; $day <= $until; $day = gmdate('Y-m-d', strtotime("{$day} +1 day"))) { $rows[] = ['date' => $day] + ($by_day[ $day ] ?? []) + $empty; } return $rows; } /** * Today's date in the site's timezone, as Y-m-d. The moment it goes by can be * pinned through the resolver_current_time filter, e.g. by tests. */ function resolver_today(): string { return wp_date('Y-m-d', (int) apply_filters('resolver_current_time', time())); } /** * The UTC bounds of a range of the site's calendar days, from the start of * $since to the end of $until, for comparing with the UTC times the plugin * stores (created_at). * * @param string $since First day (Y-m-d), in the site's timezone. * @param string $until Last day (Y-m-d), in the site's timezone. * @return string[] [start, end] as Y-m-d H:i:s, UTC. */ function resolver_utc_bounds(string $since, string $until): array { return [get_gmt_from_date($since . ' 00:00:00'), get_gmt_from_date($until . ' 23:59:59')]; } /** * SQL for the date, in the site's timezone, of a column holding UTC times: for * grouping them by the site's calendar days. It shifts each time by the site's * UTC offset, switching offset at any daylight saving change between $since and * $until, so it is exact for the days the query covers without relying on * MySQL's timezone tables, which many hosts don't load. * * @param string $column A column name from the plugin's own code, never input. * @param string $since First day (Y-m-d) the query covers, in the site's timezone. * @param string $until Last day (Y-m-d) the query covers, in the site's timezone. */ function resolver_sql_local_date(string $column, string $since, string $until): string { [$start, $end] = resolver_utc_bounds($since, $until); $site = wp_timezone(); $utc = new DateTimeZone('UTC'); $transitions = $site->getTransitions( (new DateTimeImmutable($start, $utc))->getTimestamp(), (new DateTimeImmutable($end, $utc))->getTimestamp() ); // A fixed offset such as "+02:00" has no transitions. if (! $transitions) { $offset = $site->getOffset(new DateTimeImmutable($start, $utc)); return "DATE({$column} + INTERVAL {$offset} SECOND)"; } // The first entry is the offset at $start; each later one begins a new offset. $offset = (string) (int) end($transitions)['offset']; for ($i = count($transitions) - 1; $i > 0; $i--) { $offset = sprintf("IF({$column} < '%s', %d, %s)", gmdate('Y-m-d H:i:s', $transitions[ $i ]['ts']), $transitions[ $i - 1 ]['offset'], $offset); } return "DATE({$column} + INTERVAL ({$offset}) SECOND)"; } /** * The name to show for a field a page's text was found to contain, from the * key stored for it. Custom fields are shown by their meta key. */ function resolver_field_label(string $key): string { static $labels = null; if (null === $labels) { /** * Filter the display names of stored field keys. Pro names its * WooCommerce product fields. * * @param array $labels Field key => display name. */ $labels = (array) apply_filters('resolver_field_labels', []); } return $labels[ $key ] ?? $key; } /** * Sanitize free text that is stored and used as plain text: chat messages, * AI replies, notes and error messages. * * It's never HTML, and it's escaped wherever it's output, so this keeps what * a visitor, an admin or the model meant to write. sanitize_textarea_field() * reads anything between "<" and ">" as a tag and deletes it, so "under <$50 * or > $40?" would reach the model as "under $40?", and it stores "<3" as * "<3". This makes the text safe to store instead: valid UTF-8, with no * null bytes or other control characters except tabs and line breaks, which * are normalized to "\n". */ function resolver_sanitize_plain_text(string $text): string { $text = wp_check_invalid_utf8($text, true); $text = str_replace(["\r\n", "\r"], "\n", $text); $text = (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text); return trim($text); } /** * A chat session ID in the widget's format (letters, digits, "_" and "-", at * most 64 characters, the column's size), or '' for anything else. */ function resolver_sanitize_session_id(string $session_id): string { return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $session_id) ? $session_id : ''; }