fingerprint`, see * verification_fingerprint()). Stored as a global user option (one * unprefixed meta key for the whole network, with the site as the * array key), so the network admin * (which reads the main site's record) and each site's screens agree * with each other and uninstall removes it with a single call. * * @var string */ public const USER_OPTION_VERIFICATION_DISMISSED = 'babilim_verification_dismissed'; /** * User option (global, `blog id => ids`) holding the ids of the * waiting error reports whose notice this user closed on that site. * The notice stays hidden until a report that is not in that list * is waiting. * * @var string */ public const USER_OPTION_ERROR_NOTICE_HIDDEN = 'babilim_error_notice_hidden'; /** * Whether `custom/` is kept on `babilim_purge_all`/uninstall -- see * Babilim_Installer::purge_all(). Checked (kept) by default: a site- * specific override under `custom/` is the site owner's own work, * never deleted without an explicit opt-out. * * @var string */ private const OPTION_KEEP_CUSTOM = 'babilim_keep_custom_on_purge'; /** * Transient key `render_managed_locales()` caches `GET /v1/meta/ * locales`'s own parsed catalog under -- this list barely ever * changes, and the settings screen renders (so would otherwise call * out) on every single request. * * @var string */ private const META_LOCALES_CACHE_TRANSIENT = 'babilim_meta_locales_cache'; /** * How long the cached locale catalog is trusted before the next * render tries the service again. * * @var int */ private const META_LOCALES_CACHE_TTL = 12 * HOUR_IN_SECONDS; /** * Seconds an admin-screen request to the service may take before it * gives up (the page or poll is waiting on it). * * @var int */ private const INTERACTIVE_TIMEOUT = 5; /** * Transient set when the locale catalog fetch fails, so the settings * screen does not wait on a failing service again on every render. * * @var string */ private const META_LOCALES_FAILURE_TRANSIENT = 'babilim_meta_locales_failed'; /** * How long a failed locale catalog fetch is remembered. * * @var int */ private const META_LOCALES_FAILURE_TTL = 5 * MINUTE_IN_SECONDS; /** * `POST /v1/feedback`'s own `category` enum, pinned by the service's * own versioned HTTP contract. * * @var string[] */ public const FEEDBACK_CATEGORIES = array( 'mistranslation', 'terminology', 'placeholder', 'context', 'plural', 'negation', 'loading', 'other', ); /** * @var Babilim_Fs|null */ private $fs; /** * @var Babilim_Http|null */ private $http; /** * @var Babilim_Manifest|null */ private $manifest; /** * @var callable|null */ private $parse_entries; /** * @var Babilim_State|null */ private $state; /** * @var Babilim_Inventory|null */ private $inventory; /** * @var Babilim_Site|null */ private $site; /** * @var Babilim_Installer|null */ private $installer; /** * @var Babilim_Default_Index|null */ private $default_index; /** * @var Babilim_Consent|null */ private $consent; /** * What the bulk POST of this request did, for the result block. * * @var array|null */ private $bulk_result; /** * The hook suffix add_menu_page() itself returns from register_menu() * -- the only reliable way to scope enqueue_admin_style() (an * admin_enqueue_scripts callback, which fires on every admin screen, * not just this plugin's own) to this plugin's one page. Empty until * register_menu() runs. * * @var string */ private $menu_hook_suffix = ''; /** * The hook suffix of the Settings submenu page; empty until * register_menu() runs. * * @var string */ private $settings_hook_suffix = ''; /** * Whether the notice close-button script was already added this request. * * @var bool */ private $notice_dismiss_script_added = false; /** * @param Babilim_Fs|null $fs Overrides the production Babilim_Wp_Fs -- production * passes Babilim::instance()'s own shared one (see * Babilim::manifest()'s own docblock for why); a test * may still override it independently. * @param Babilim_Http|null $http Overrides the production Babilim_Wp_Http -- for tests. * @param Babilim_Installer|null $installer Overrides the production installer (normally bound to * Babilim::instance()'s real transform/parse/unzip * callables, which need a real WordPress * install/zip extension) -- for a test driving a real * download-then-install round trip against fake ones. * @param Babilim_Manifest|null $manifest Overrides the production Babilim_Manifest -- production * passes Babilim::instance()'s own shared one, the SAME * instance default_index()/installer()/site() share, so a * write an admin action makes is visible to a hook that * fires later in the same request, and vice versa (see * Babilim::manifest()'s own docblock for the data-loss * bug this replaces). A test may still override it * independently. * @param callable|null $parse_entries Overrides the production reader of a translation * file's entries (Babilim::parse_entries()). */ public function __construct( ?Babilim_Fs $fs = null, ?Babilim_Http $http = null, ?Babilim_Installer $installer = null, ?Babilim_Manifest $manifest = null, ?callable $parse_entries = null ) { $this->fs = $fs; $this->http = $http; $this->installer = $installer; $this->manifest = $manifest; $this->parse_entries = $parse_entries; } /** * Registers the admin menu and every admin-post.php action handler. * Only ever called when is_admin() -- see Babilim::init(). */ public function init(): void { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_plugins_list_script' ) ); add_action( 'admin_notices', array( $this, 'render_verification_admin_notice' ) ); add_action( 'network_admin_notices', array( $this, 'render_verification_admin_notice' ) ); add_action( 'admin_notices', array( $this, 'render_error_consent_notice' ) ); add_filter( 'plugin_action_links_' . plugin_basename( BABILIM_FILE ), array( $this, 'confirm_deactivate_link' ) ); add_filter( 'network_admin_plugin_action_links_' . plugin_basename( BABILIM_FILE ), array( $this, 'confirm_deactivate_link' ) ); foreach ( self::ACTIONS as $action ) { add_action( 'admin_post_' . $action, array( $this, 'dispatch_' . $action ) ); } add_action( 'wp_ajax_babilim_license_status', array( $this, 'ajax_babilim_license_status' ) ); add_action( 'admin_bar_menu', array( $this, 'register_toolbar_node' ), 100 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_report_script' ) ); add_action( 'wp_ajax_babilim_report_panel', array( $this, 'ajax_babilim_report_panel' ) ); add_action( 'wp_ajax_babilim_translation_search', array( $this, 'ajax_babilim_translation_search' ) ); add_action( 'wp_ajax_babilim_translation_report', array( $this, 'ajax_babilim_translation_report' ) ); } /** * `admin_menu` callback. */ public function register_menu(): void { $hook_suffix = add_menu_page( __( 'Babilim', 'babilim' ), __( 'Babilim', 'babilim' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ), self::MENU_ICON ); $this->menu_hook_suffix = $hook_suffix; if ( '' !== $this->menu_hook_suffix ) { add_action( 'load-' . $this->menu_hook_suffix, array( $this, 'on_translations_load' ) ); } // The first submenu entry reuses the top-level slug, so the // parent item is labelled "Translations" instead of repeating // the plugin name. add_submenu_page( self::MENU_SLUG, __( 'Translations', 'babilim' ), __( 'Translations', 'babilim' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ) ); $settings_suffix = add_submenu_page( self::MENU_SLUG, __( 'Babilim Settings', 'babilim' ), __( 'Settings', 'babilim' ), self::CAPABILITY, self::SETTINGS_SLUG, array( $this, 'render' ) ); $this->settings_hook_suffix = is_string( $settings_suffix ) ? $settings_suffix : ''; add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); } /** * `load-{hook}` callback of the translations page: the place a bulk * POST from the list is handled before anything is printed. One run * handles one batch and does not redirect: the result (and the form * that continues with what was not tried) is rendered by this same * response. */ public function on_translations_load(): void { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- only tells a POST from a GET; the nonce is checked below before anything runs. if ( 'POST' !== $method ) { return; } $op = self::bulk_action_from_post(); if ( null === $op ) { return; } check_admin_referer( 'bulk-translations' ); if ( ! self::can( 'request' !== $op ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'babilim' ), '', array( 'response' => 403 ) ); } $selected = self::post_list( 'components' ); $pending = self::post_list( 'pending' ); // Only the rows the current tab shows: a bulk run never reaches // into the other kind. $items = Babilim_Translations_Table::filter_items( $this->list_items( $this->state()->get() ), 'all', '', '', self::kind_tab( self::field_get( 'babilim_kind' ) ) ); if ( array() !== $pending ) { // Only what is still eligible right now may run, whatever the form says. $eligible = array(); foreach ( Babilim_Bulk::expand( $items, $op, self::item_keys( $items ), '' ) as $triple ) { $eligible[] = implode( ':', $triple ); } $ops = array(); foreach ( array_unique( $pending ) as $key ) { $parts = explode( ':', $key ); if ( 3 === count( $parts ) && in_array( $key, $eligible, true ) ) { $ops[] = $parts; } } } else { $ops = Babilim_Bulk::expand( $items, $op, $selected, self::field_get( 'babilim_locale' ) ); } $this->bulk_result = array( 'op' => $op, 'components' => $selected ); if ( count( $ops ) > Babilim_Bulk::MAX_SELECTION_OPS ) { $this->bulk_result['error'] = sprintf( /* translators: 1: the most operations one run accepts, 2: how many were selected. */ __( 'Select fewer components: at most %1$d operations per run (you selected %2$d).', 'babilim' ), Babilim_Bulk::MAX_SELECTION_OPS, count( $ops ) ); return; } $execute = function ( string $kind, string $slug, string $locale, int $timeout ) use ( $op ): Babilim_Result { if ( 'request' !== $op ) { return $this->do_download( $kind, $slug, $locale, $timeout ); } $component = $this->installed_component( $kind, $slug ); if ( null === $component ) { return Babilim_Result::error( 'unknown_component', __( 'That component is not installed on this site, so a translation cannot be requested for it.', 'babilim' ) ); } return $this->do_request( $kind, $slug, (string) ( $component['version'] ?? '' ), $locale, $timeout ); }; $this->bulk_result += Babilim_Bulk::run( $ops, $op, new Babilim_Deadline( Babilim_Bulk::BUDGET_SECONDS ), $execute, $this->state() ); } /** * A list of strings from $_POST (components[], pending[]). * * @param string $key Field name. * @return string[] */ private static function post_list( string $key ): array { // sanitize_text_field() turns a nested array into '', which the strlen filter then drops, so only real strings survive. return isset( $_POST[ $key ] ) && is_array( $_POST[ $key ] ) ? array_values( array_filter( array_map( 'sanitize_text_field', wp_unslash( $_POST[ $key ] ) ), static fn( $v ) => '' !== $v ) ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the nonce is checked by the caller before any field is read. } /** * A GET query value as a plain string, or ''. * * @param string $key Query key. * @return string */ private static function field_get( string $key ): string { return isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only filter. } /** * The list tab named by a request value: plugin, theme, or '' for * every kind (also for anything else). * * @param string $raw Request value. * @return string */ private static function kind_tab( string $raw ): string { return in_array( $raw, array( 'plugin', 'theme' ), true ) ? $raw : ''; } /** * Every row's `kind:slug`. * * @param array[] $items List rows. * @return string[] */ private static function item_keys( array $items ): array { return array_map( static function ( array $item ): string { return $item['kind'] . ':' . $item['slug']; }, $items ); } /** * The list rows for a stored state, with installed read from the manifest. * * @param array $state Stored state. * @return array[] */ private function list_items( array $state ): array { $manifest = $this->manifest(); return Babilim_Translations_Table::build_items( $state, $this->inventory()->components(), static function ( string $kind, string $slug, string $loc ) use ( $manifest ): ?array { try { return $manifest->installed( $kind, $slug, $loc ); } catch ( \RuntimeException $e ) { return null; } }, self::can( true ) ); } /** * The bulk action the list form posted: the top select wins, and * the bottom select counts when the top one is unset. Not the base * class's current_action(), which also reads $_REQUEST. * * @return string|null download|update|request, or null. */ public static function bulk_action_from_post(): ?string { $action = isset( $_POST['action'] ) ? sanitize_key( wp_unslash( $_POST['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read only; the nonce is checked before anything runs. if ( '' === $action || '-1' === $action ) { $action = isset( $_POST['action2'] ) ? sanitize_key( wp_unslash( $_POST['action2'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read only; the nonce is checked before anything runs. } return in_array( $action, array( 'download', 'update', 'request' ), true ) ? $action : null; } /** * `admin_enqueue_scripts` callback -- this plugin's small layout * stylesheet and its one script, and only on this plugin's own two * admin pages: every other admin screen gets this callback fired * too, so it no-ops immediately whenever $hook is neither of the * hook suffixes register_menu() recorded. * * Registered against a `false` src ("no file to enqueue, just a * handle to hang inline CSS off") rather than shipping a real .css * file -- the same wp_register_style()-with-false-src pattern core * itself uses for inline-only styles (see, e.g., WP_Widget_Block's * own registration). admin_css() is the only source of truth for * what layout this page actually needs; see its own docblock for why * these rules exist at all (real wp_kses()'s safecss_filter_attr() * strips `display` from any inline `style` attribute, admin.php no * longer emits one at all -- see render_managed_locales(), render_maintenance_ * buttons()). * * @param string $hook The current admin screen's own hook suffix. */ public function enqueue_admin_assets( string $hook ): void { if ( '' === $this->menu_hook_suffix || ( $hook !== $this->menu_hook_suffix && $hook !== $this->settings_hook_suffix ) ) { return; } // The explicit BABILIM_VERSION -- not the parameter's own `false` // default -- matters here even though `false` is also the src // (there is no file to cache-bust the URL of): WordPress.WP. // EnqueuedResourceParameters flags a missing $ver on ANY // wp_register_style() call, inline-only or not, since core's own // browser-caching concern is about the version string this // stylesheet is served under changing when its contents do, not // specifically about a file's URL query string. wp_register_style( 'babilim-admin', false, array(), BABILIM_VERSION ); wp_enqueue_style( 'babilim-admin' ); wp_add_inline_style( 'babilim-admin', self::admin_css() ); wp_enqueue_script( 'babilim-admin', plugins_url( 'admin/js/babilim-admin.js', BABILIM_FILE ), array(), BABILIM_VERSION, true ); } /** * `admin_enqueue_scripts` callback for the Plugins screen, site admin * and network admin alike (core passes `plugins.php` in both). Hooked * from init(), not from register_menu(): the network admin never fires * `admin_menu`. Carries the Deactivate link's confirmation (see * confirm_deactivate_link()) on a handle with no file of its own. * * @param string $hook The current admin screen's hook suffix. */ public function enqueue_plugins_list_script( string $hook ): void { if ( 'plugins.php' !== $hook || ! self::can( is_multisite() && is_network_admin() ) ) { return; } wp_register_script( 'babilim-plugins-list', false, array(), BABILIM_VERSION, true ); wp_enqueue_script( 'babilim-plugins-list' ); wp_add_inline_script( 'babilim-plugins-list', self::plugins_list_js() ); } /** * The plugins-list script: a link carrying data-babilim-confirm is only * followed once the visitor accepts the question (Cancel leaves the * plugin active). * * @return string */ private static function plugins_list_js(): string { return implode( "\n", array( "document.addEventListener('click', function (e) {", "\tvar a = e.target && e.target.closest ? e.target.closest('a[data-babilim-confirm]') : null;", "\tif (a && !window.confirm(a.getAttribute('data-babilim-confirm'))) {", "\t\te.preventDefault();", "\t}", '});', ) ); } /** * This page's own small layout stylesheet -- exactly the three * `display`/`margin-right` rules render_managed_locales() and render_maintenance_ * buttons() used to carry as inline `style="..."` attributes, before * real WordPress core's own wp_kses() (safecss_filter_attr(), which * every allowed `style` attribute's value is run through regardless * of what this file's own allowed_admin_html() declares) turned out * to strip `display` on every core version checked -- `display` was * never on safe_style_css's own allow-list. Class names, not element * selectors: this stylesheet is enqueued on the whole admin page, * where a bare `form`/`label` selector would also reach markup this * plugin doesn't own. * * @return string */ private static function admin_css(): string { return '.babilim-inline{display:inline}.babilim-inline-block{display:inline-block}.babilim-mr-1em{margin-right:1em}' . '.babilim-cell-list{container-type:inline-size}.babilim-cell-line{display:grid;grid-template-columns:minmax(min-content,max-content) minmax(10em,1fr) max-content;gap:0 1em;align-items:start;padding:2px 0}' . '.babilim-cell-action{position:relative;text-align:right}' // The busy label (and the idle label, so the button never shrinks back) plus room for the spinner is laid out invisibly from the start, so pressing the button never changes the column width; the buttons of a cell fill that width and the spinner is drawn inside the button, left of its text, never over the status column. . '.babilim-cell-action button[data-babilim-busy-label]{min-width:100%}' . '.babilim-cell-action button[data-babilim-busy-label]::after{content:attr(data-babilim-busy-label);display:block;height:0;overflow:hidden;visibility:hidden;padding-left:22px}' . '.babilim-cell-action button[data-babilim-busy-label]::before{content:attr(data-babilim-idle-label);display:block;height:0;overflow:hidden;visibility:hidden}' . '.babilim-cell-action .spinner{position:absolute;left:5px;top:7px;float:none;width:16px;height:16px;background-size:16px 16px;margin:0}' . '.babilim-cell-text{display:contents}.babilim-cell-lang,.babilim-cell-status{display:block}' . '.babilim-cell-status{overflow-wrap:anywhere}.babilim-cell-lang{white-space:normal}.babilim-cell-tail{display:inline-block;max-width:100%}' . '.babilim-choose{margin:1em 0}.babilim-choose li{margin:0 0 .5em}' . '.babilim-locale-links{clear:both;margin:.5em 0 0}.babilim-advanced-first{margin-top:1em}.babilim-account{margin:0}' // One grid per cell: the lines take the cell's columns, so the name column is as wide as the longest name and every status starts at the same x. . '@supports (grid-template-columns:subgrid){.babilim-cell-grid{display:grid;grid-template-columns:minmax(min-content,max-content) minmax(10em,1fr) max-content;column-gap:1em}.babilim-cell-row,.babilim-cell-line,.babilim-cell-warning{grid-column:1/-1}.babilim-cell-row,.babilim-cell-line{display:grid;grid-template-columns:subgrid}}' . '@container (max-width:40em){.babilim-cell-grid,.babilim-cell-row{display:block}.babilim-cell-line{display:flex;flex-wrap:wrap;align-items:flex-start;gap:2px 1em}.babilim-cell-text{display:block;flex:1 1 10em}.babilim-cell-action{flex:none;margin-left:auto}}' . '@media (max-width:782px){.babilim-cell-grid,.babilim-cell-row{display:block}.babilim-cell-line{display:grid;grid-template-columns:minmax(0,1fr);gap:2px 0}.babilim-cell-text{display:contents}.babilim-cell-action{text-align:left;margin-left:0;width:max-content}}'; } /** * The site's own override folder, as a short path for display: relative * to the WordPress root when it lies under it, otherwise its last three * segments (languages folder, plugin folder, `custom`). Trailing slash. * * @return string */ private static function custom_dir_label(): string { return self::custom_dir_label_for( dirname( Babilim_Paths::dir( 'custom/plugins' ) ), defined( 'ABSPATH' ) ? (string) ABSPATH : '' ); } /** * The pure part of custom_dir_label(). * * @param string $dir The override folder, no trailing slash. * @param string $root The WordPress root with its trailing slash, or ''. * @return string */ public static function custom_dir_label_for( string $dir, string $root ): string { if ( '' !== $root && 0 === strpos( $dir, $root ) ) { return ltrim( substr( $dir, strlen( $root ) ), '/' ) . '/'; } return basename( dirname( $dir, 2 ) ) . '/' . basename( dirname( $dir ) ) . '/' . basename( $dir ) . '/'; } /** * Whether `custom/` is currently kept on purge/uninstall -- defaults * true (kept) until the site owner explicitly opts out on the * settings screen. * * @return bool */ private static function keep_custom_on_purge(): bool { return (bool) get_option( self::OPTION_KEEP_CUSTOM, true ); } // -- lazily-built collaborators, sharing one Babilim_Fs/Babilim_Manifest // per request/instance -- Babilim::admin() always hands this class the // SAME shared instance its own site()/installer()/default_index() use // (see Babilim::manifest()'s own docblock); the fallback new // Babilim_Manifest($this->fs()) below only ever runs when this class // is constructed directly, outside Babilim::admin(), with no manifest // passed in. A single admin request can chain several of these // together, e.g. download() then install(), and needs writes from one // to be visible to the next without re-reading from disk. ------------- private function fs(): Babilim_Fs { if ( null === $this->fs ) { $this->fs = new Babilim_Wp_Fs(); } return $this->fs; } private function http(): Babilim_Http { if ( null === $this->http ) { $this->http = new Babilim_Wp_Http(); } return $this->http; } private function manifest(): Babilim_Manifest { if ( null === $this->manifest ) { $this->manifest = new Babilim_Manifest( $this->fs() ); } return $this->manifest; } private function state(): Babilim_State { if ( null === $this->state ) { $this->state = new Babilim_State(); } return $this->state; } private function inventory(): Babilim_Inventory { if ( null === $this->inventory ) { $babilim = Babilim::instance(); $this->inventory = new Babilim_Inventory( array( $babilim, 'get_plugins_for_inventory' ), 'wp_get_themes', static function ( $name ) { return function_exists( 'get_site_transient' ) ? get_site_transient( $name ) : false; }, array( $babilim, 'get_users_locales' ) ); } return $this->inventory; } private function site(): Babilim_Site { if ( null === $this->site ) { $this->site = new Babilim_Site( $this->http(), BABILIM_API_BASE, $this->state(), $this->inventory(), $this->manifest() ); } return $this->site; } private function installer(): Babilim_Installer { if ( null === $this->installer ) { $babilim = Babilim::instance(); $this->installer = new Babilim_Installer( $this->fs(), $this->manifest(), array( $babilim, 'transform_mo_to_l10n_php' ), array( $babilim, 'parse_translation_file_entries' ), array( $babilim, 'unzip_package' ) ); } return $this->installer; } private function sync_installer(): Babilim_Sync_Installer { return new Babilim_Sync_Installer( $this->state(), $this->site(), $this->installer(), $this->http(), $this->fs(), BABILIM_API_BASE ); } private function default_index(): Babilim_Default_Index { if ( null === $this->default_index ) { $babilim = Babilim::instance(); $this->default_index = new Babilim_Default_Index( $this->fs(), $this->manifest(), array( $babilim, 'active_components' ), array( $babilim, 'parse_entries' ), array( $babilim, 'core_has' ), array( $babilim, 'core_plural_forms' ) ); } return $this->default_index; } private function consent(): Babilim_Consent { if ( null === $this->consent ) { $this->consent = new Babilim_Consent(); } return $this->consent; } /** * A fresh Babilim_Api_Client bound to this site's current token. * * @return Babilim_Api_Client */ private function api(): Babilim_Api_Client { return new Babilim_Api_Client( $this->http(), BABILIM_API_BASE, $this->site()->token() ); } // -- guard: nonce + capability, every state-changing action. --------- /** * check_admin_referer() + current_user_can() -- wp_die()s (via * check_admin_referer()/this method itself) on failure, exactly like * every other WordPress admin-post.php action. $network_wide raises * the required capability to network_capability() (manage_options * everywhere except multisite, where it's manage_network_options) * for an action that writes to the network-wide shared translation * tree; every other action stays at CAPABILITY. * * @param string $nonce_action The nonce action name. * @param bool $network_wide Whether this action writes to the network-wide shared translation tree. */ private function guard( string $nonce_action, bool $network_wide = false ): void { check_admin_referer( $nonce_action ); if ( ! self::can( $network_wide ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'babilim' ), '', array( 'response' => 403 ) ); } } /** * Sanitized string from $_POST/$_GET (whichever the request used). * * @param string $key Field name. * @return string */ private static function field( string $key ): string { return isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce itself is checked by guard() before any field is read. } /** * Like field(), but for a free-form multi-line plain-text field (a *

'; $html .= '


' . '

'; $html .= '


' . '

'; $html .= '


' . '

'; $html .= '

' . esc_html__( 'Your report is processed automatically by AI models and may improve this translation for other sites.', 'babilim' ) . '

'; $html .= '

'; $html .= ''; return $html; } }