403 ) ); } check_admin_referer( self::ACTION_PURGE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce проверен выше. $locale = isset( $_POST['lingocanvas_locale'] ) ? Locale::normalize( sanitize_text_field( wp_unslash( (string) $_POST['lingocanvas_locale'] ) ) ) : ''; $language = $this->settings->get( $locale ); if ( null === $language || $language->isDefault ) { wp_die( esc_html__( 'This additional language is not in the settings.', 'lingocanvas' ), '', array( 'response' => 400 ) ); } $deleted = $this->translations->deleteAllForLocale( $language->locale ); $this->cache->flush(); wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'lingocanvas_locale' => $language->locale, 'lingocanvas-purged' => (string) $deleted, ), admin_url( 'admin.php' ) ) ); exit; } /** * Убирает собранные строки интерфейса, которые никто не переводил. * * Отдельно от «удалить все переводы»: там удаляются переводы и * остаются исходные строки, а здесь наоборот — уходят сами строки, * но только те, что никто не трогал руками. Для gettext это * безопасно: они собираются заново сами при следующем показе страницы. */ public function handleCleanGettext(): void { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have sufficient permissions.', 'lingocanvas' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::ACTION_CLEAN_GETTEXT ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce проверен выше. $locale = isset( $_POST['lingocanvas_locale'] ) ? Locale::normalize( sanitize_text_field( wp_unslash( (string) $_POST['lingocanvas_locale'] ) ) ) : ''; $tab = self::parseTab( isset( $_POST['lingocanvas_tab'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['lingocanvas_tab'] ) ) : '' ); // phpcs:enable WordPress.Security.NonceVerification.Missing /* * Чистим ровно то, что показано на этой вкладке. Иначе кнопка на * «Контенте» молча снесла бы и SEO-строки — а это разные списки с * разной ценой ошибки. */ if ( self::TAB_INTERFACE === $tab ) { $deleted = $this->gettext->deleteUntranslated(); } else { $deleted = $this->sources->deleteUntranslated( self::cleanableKinds( $tab ) ); } $this->cache->flush(); wp_safe_redirect( add_query_arg( array( 'page' => self::MENU_SLUG, 'lingocanvas_tab' => $tab, 'lingocanvas_locale' => $locale, 'lingocanvas-cleaned' => (string) $deleted, ), admin_url( 'admin.php' ) ) ); exit; } /** * Какие виды строк чистит кнопка на этой вкладке. Чистая функция. * * SEO-строки и контент лежат в одних и тех же `kind`, различает их * только `attribute_name = 'content'` в местах использования, поэтому * «почистить только SEO» отдельным запросом не выйдет — и обе вкладки * чистят один и тот же набор видов. Это честнее, чем делать вид, что * кнопка на «SEO/GEO» трогает только его. * * @param string $tab Текущая вкладка. * @return list */ public static function cleanableKinds( string $tab ): array { if ( self::TAB_INTERFACE === $tab ) { return array( GettextKey::KIND ); } if ( self::TAB_LINKS === $tab ) { return array( SourceRepository::TYPE_LINK ); } return array( SourceRepository::TYPE_TEXT, SourceRepository::TYPE_ATTRIBUTE, SourceRepository::TYPE_BLOCK, SourceRepository::TYPE_SEO, ); } /** * Добавляет пункт подменю. */ public function addMenu(): void { add_submenu_page( SettingsPage::MENU_SLUG, __( 'String translation', 'lingocanvas' ), __( 'String translation', 'lingocanvas' ), self::CAPABILITY, self::MENU_SLUG, array( $this, 'render' ) ); } /** * Подключает скрипт только на своей странице. * * @param string $hook Идентификатор текущего экрана. */ public function enqueue( $hook ): void { if ( ! is_string( $hook ) || ! str_contains( $hook, self::MENU_SLUG ) ) { return; } wp_enqueue_style( 'lingocanvas-admin', Assets::url( 'assets/admin.css' ), array(), Assets::version( 'assets/admin.css' ) ); wp_enqueue_script( 'lingocanvas-admin', Assets::url( 'assets/admin.js' ), array(), Assets::version( 'assets/admin.js' ), true ); wp_localize_script( 'lingocanvas-admin', 'lingocanvasAdmin', array( 'root' => esc_url_raw( rest_url( TranslationsController::NAMESPACE . '/translations/' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'pagesRoot' => esc_url_raw( rest_url( EditorPagesController::NAMESPACE . '/editor/pages' ) ), 'i18n' => array( 'pickerLoading' => __( 'Searching…', 'lingocanvas' ), 'pickerEmpty' => __( 'Nothing found', 'lingocanvas' ), 'pickerError' => __( 'Search failed', 'lingocanvas' ), 'saving' => __( 'Saving…', 'lingocanvas' ), 'saved' => __( 'Saved', 'lingocanvas' ), 'failed' => __( 'Save error', 'lingocanvas' ), 'deleting' => __( 'Deleting…', 'lingocanvas' ), 'confirmDelete' => __( 'Delete the translation of this string?', 'lingocanvas' ), 'translating' => __( 'Translating with AI…', 'lingocanvas' ), 'aiSuggested' => __( 'Suggested by AI — review and save', 'lingocanvas' ), 'aiFailed' => __( 'AI could not translate', 'lingocanvas' ), ), ) ); } /** * Выводит страницу. */ public function render(): void { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $secondary = $this->settings->secondary(); if ( array() === $secondary ) { printf( '

%s

%s

', esc_html__( 'String translation', 'lingocanvas' ), esc_html__( 'First add at least one additional language on the “Languages” page.', 'lingocanvas' ) ); return; } $filters = $this->readFilters( $secondary ); $tab = self::parseTab( $this->rawTab() ); ?>

renderTabs( $tab, $filters['locale'] ); ?> renderPurgeNotice(); ?> renderAiNotice(); ?> renderCleanNotice(); ?> renderInterfaceTab( $secondary[ $filters['locale'] ], $secondary, $filters ); ?> renderStringsTab( $tab, $secondary, $filters ); ?> renderCleanForm( $tab, $filters['locale'] ); ?>
interface->render( $language, $secondary, array( 'domain' => $filters['domain'], 'status' => $filters['status'], 'search' => $filters['search'], 'page' => $filters['page'], ), self::PER_PAGE, function ( int $total, int $page ) use ( $filters ): void { $filters['page'] = $page; $this->renderTableNav( $total, $filters, 'top' ); } ); } /** * Вкладки «Контент» и «SEO/GEO» — один и тот же список строк из DOM, * отличается только тем, какие виды строк в него попадают. * * @param string $tab Текущая вкладка. * @param array $secondary Дополнительные языки. * @param array{locale: string, status: string, search: string, scope: string, object_id: int, type: string, page: int} $filters Текущие фильтры. */ private function renderStringsTab( string $tab, array $secondary, array $filters ): void { /* * Вкладка решает, какие виды строк показывать, а не пользователь: * SEO/GEO и строки интерфейса живут на своих экранах, и «все типы» * на вкладке «Контент» означает «всё содержимое», а не буквально всё. */ if ( self::TAB_SEO === $tab ) { $type = SourceRepository::TYPE_SEO; } elseif ( self::TAB_LINKS === $tab ) { $type = SourceRepository::TYPE_LINK; } else { $type = '' !== $filters['type'] ? $filters['type'] : SourceRepository::TYPE_CONTENT; } $result = $this->sources->paginate( array( 'locale' => $filters['locale'], 'status' => $filters['status'], 'search' => $filters['search'], 'scope' => $filters['scope'], 'object_id' => $filters['object_id'], 'type' => $type, 'page' => $filters['page'], 'per_page' => self::PER_PAGE, ) ); ?>




renderFilters( $secondary, $filters, $result['total'], $tab ); ?> renderTableNav( $result['total'], $filters, 'top' ); ?> renderRow( $row, $filters['locale'] ); ?>
renderTableNav( $result['total'], $filters, 'bottom' ); ?> gettext->countUntranslated() : $this->sources->countUntranslated( self::cleanableKinds( $tab ) ); ?>

%s

', esc_html( sprintf( /* translators: %s: number of deleted strings */ __( 'Strings deleted: %s.', 'lingocanvas' ), number_format_i18n( $deleted ) ) ) ); } /** * Переключатель вкладок в оформлении ядра WordPress. * * @param string $current Текущая вкладка. * @param string $locale Выбранный язык — переносится между вкладками. */ private function renderTabs( string $current, string $locale ): void { $tabs = array( self::TAB_CONTENT => __( 'Content', 'lingocanvas' ), self::TAB_SEO => __( 'SEO/GEO', 'lingocanvas' ), self::TAB_INTERFACE => __( 'Interface', 'lingocanvas' ), self::TAB_LINKS => __( 'Links', 'lingocanvas' ), ); echo ''; } /** * Сырое значение вкладки из адресной строки. */ private function rawTab(): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- значение проходит через allowlist. return isset( $_GET['lingocanvas_tab'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_tab'] ) ) : ''; } /** * Проверяет значение вкладки по allowlist. Чистая функция. * * @param string $value Сырое значение параметра. */ public static function parseTab( string $value ): string { $allowed = array( self::TAB_CONTENT, self::TAB_SEO, self::TAB_INTERFACE, self::TAB_LINKS ); return in_array( $value, $allowed, true ) ? $value : self::TAB_CONTENT; } /** * Строка таблицы. * * @param array $row Данные строки. * @param string $locale Целевой язык. */ /** * Строка списка — это адрес ссылки (`href`). Чистая функция. * * Языковой модели такую строку не показываем: она переводит то, что * видит, — `/o-nas/` станет `/about-us/`, параметры перемешаются, и * ссылка молча поведёт в никуда, а перевод будет выглядеть успешным * (та же причина, что и в PostTranslationController::isLinkTarget() и * в редакторе — см. assets/editor.js). Список строк отдаёт кнопку на * каждой строке отдельно от одиночного редактора, поэтому проверка * нужна и здесь. * * @param array $row Строка из SourceRepository::paginate(). */ public static function isLinkRow( array $row ): bool { return Segment::KIND_ATTRIBUTE === (string) ( $row['kind'] ?? '' ) && 'href' === (string) ( $row['attribute_name'] ?? '' ); } private function renderRow( array $row, string $locale ): void { $status = (string) ( $row['status'] ?? '' ); $text = (string) ( $row['translated_text'] ?? '' ); if ( '' === $text ) { $status = TranslationStatus::MISSING; } $isLink = self::isLinkRow( $row ); ?> kindLabel( (string) $row['kind'], isset( $row['attribute_name'] ) && '' !== (string) $row['attribute_name'] ? (string) $row['attribute_name'] : null ) ); ?> providers->isReady() && ! $isLink ) : ?> $secondary Дополнительные языки. * @param array{locale: string, status: string, search: string, page: int} $filters Текущие фильтры. * @param int $total Всего строк. */ private function renderFilters( array $secondary, array $filters, int $total, string $tab = self::TAB_CONTENT ): void { ?>
0 ? trim( wp_strip_all_tags( (string) get_the_title( $filters['object_id'] ) ) ) : ''; // Разметка собрана и экранирована внутри scopeControlHtml(). echo wp_kses( self::scopeControlHtml( $filters['scope'], $filters['object_id'], $scopeTitle ), AllowedHtml::scopeControl() ); ?>
$secondary Дополнительные языки. * @param string $locale Выбранный язык. */ private function purgeConfirmation( array $secondary, string $locale ): string { $label = $secondary[ $locale ]->label ?? $locale; return sprintf( /* translators: 1: language label, 2: language code */ __( 'Delete ALL translations for the language “%1$s” (%2$s)? This action cannot be undone.', 'lingocanvas' ), $label, $locale ); } /** * Объясняет, почему кнопки «Перевести с ИИ» нет. * * Сообщение обязано называть недостающее поле поимённо. Общая фраза * «ключ не настроен» уже один раз отправила владельца сайта проверять * ключ, который на самом деле был сохранён, — не хватало модели. */ private function renderAiNotice(): void { $missing = $this->providers->missing(); if ( array() === $missing ) { return; } $names = array( ProviderFactory::FIELD_KEY => _x( 'OpenAI key', 'mid-sentence', 'lingocanvas' ), ProviderFactory::FIELD_MODEL => _x( 'OpenAI model', 'mid-sentence', 'lingocanvas' ), ProviderFactory::FIELD_CLOUD_TOKEN => _x( 'connection to LingoCanvas Cloud', 'mid-sentence', 'lingocanvas' ), ProviderFactory::FIELD_CLOUD_CONSENT => _x( 'consent to send texts to LingoCanvas Cloud', 'mid-sentence', 'lingocanvas' ), ); $cloudMissing = array() !== array_intersect( $missing, array( ProviderFactory::FIELD_CLOUD_TOKEN, ProviderFactory::FIELD_CLOUD_CONSENT ) ); $settingsUrl = admin_url( 'admin.php?page=' . SettingsPage::MENU_SLUG ) . ( $cloudMissing ? '#' . CloudSection::ANCHOR : '' ); $labels = array(); foreach ( $missing as $field ) { $labels[] = $names[ $field ] ?? $field; } printf( '

%s

', wp_kses( sprintf( /* translators: 1: comma-separated list of missing fields, 2: link to the settings page */ __( 'The “Translate with AI” button is not available: missing — %1$s. Fill it in on the %2$s page.', 'lingocanvas' ), '' . esc_html( implode( ', ', $labels ) ) . '', '' . esc_html__( 'Multilingual → Languages', 'lingocanvas' ) . '' ), array( 'a' => array( 'href' => array() ), 'strong' => array(), ) ) ); } /** * Сообщение о результате массового удаления. */ private function renderPurgeNotice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- только чтение счётчика из URL. if ( ! isset( $_GET['lingocanvas-purged'] ) ) { return; } $deleted = absint( wp_unslash( $_GET['lingocanvas-purged'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only flag printf( '

%s

', esc_html( sprintf( /* translators: %s: number of deleted translations */ __( 'Translations deleted: %s.', 'lingocanvas' ), number_format_i18n( $deleted ) ) ) ); } /** * Постраничная навигация в оформлении списков WordPress. * * Раньше здесь был голый `paginate_links()`: ссылки шли слитно, без * отступов и без указания текущей страницы, и попасть по нужной цифре * было тяжело. Разметка ниже повторяет структуру `WP_List_Table`, поэтому * подхватывает стили ядра и даёт кнопки перехода плюс поле с номером. * * @param int $total Всего строк. * @param array{locale: string, status: string, search: string, scope: string, object_id: int, type: string, page: int} $filters Текущие фильтры. * @param string $position `top` или `bottom`. */ private function renderTableNav( int $total, array $filters, string $position ): void { $pages = max( 1, (int) ceil( $total / self::PER_PAGE ) ); $current = min( max( 1, $filters['page'] ), $pages ); ?>
1 ) : ?> renderPageLink( '«', 1, $filters, $current > 1, 'first-page' ); $this->renderPageLink( '‹', $current - 1, $filters, $current > 1, 'prev-page' ); ?>
renderHiddenFilters( $filters ); ?>
renderPageLink( '›', $current + 1, $filters, $current < $pages, 'next-page' ); $this->renderPageLink( '»', $pages, $filters, $current < $pages, 'last-page' ); ?>
%s', esc_html( html_entity_decode( $label, ENT_QUOTES, 'UTF-8' ) ) ); return; } printf( '%3$s', esc_attr( $class ), esc_url( $this->filteredUrl( $filters, $page ) ), esc_html( html_entity_decode( $label, ENT_QUOTES, 'UTF-8' ) ) ); } /** * Адрес страницы списка с сохранением всех фильтров. * * @param array{locale: string, status: string, search: string, scope: string, object_id: int, type: string, page: int} $filters Текущие фильтры. * @param int $page Целевая страница. */ private function filteredUrl( array $filters, int $page ): string { return add_query_arg( array( 'page' => self::MENU_SLUG, 'lingocanvas_tab' => self::parseTab( $this->rawTab() ), 'lingocanvas_locale' => $filters['locale'], 'lingocanvas_status' => $filters['status'], 'lingocanvas_scope' => self::scopeValue( $filters ), 'lingocanvas_type' => $filters['type'], 'lingocanvas_domain' => $filters['domain'] ?? '', 's' => $filters['search'], 'paged' => (string) max( 1, $page ), ), admin_url( 'admin.php' ) ); } /** * Значение фильтра области в том же виде, в каком его отдаёт `'; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; return $html; } /** * Из публичных типов записей — те, что достойны своей строки в списке * «Где встречается». Чистая функция. * * `attachment` — единственное системное исключение: вложения * зарегистрированы публичными (у них есть собственный адрес страницы), * но выбирать медиафайл как «запись, где искать строки» не имеет * смысла — там нет вашего текста, только файл. * * @param list $publicPostTypes Результат `get_post_types(['public' => true])`. * @return list */ public static function scopeDropdownPostTypes( array $publicPostTypes ): array { return array_values( array_diff( $publicPostTypes, array( 'attachment' ) ) ); } /** * Скрытые поля, сохраняющие фильтры при переходе по страницам. * * @param array{locale: string, status: string, search: string, scope: string, object_id: int, type: string, page: int} $filters Текущие фильтры. */ private function renderHiddenFilters( array $filters ): void { $fields = array( 'page' => self::MENU_SLUG, 'lingocanvas_tab' => self::parseTab( $this->rawTab() ), 'lingocanvas_locale' => $filters['locale'], 'lingocanvas_status' => $filters['status'], 'lingocanvas_scope' => self::scopeValue( $filters ), 'lingocanvas_type' => $filters['type'], 'lingocanvas_domain' => $filters['domain'] ?? '', 's' => $filters['search'], ); foreach ( $fields as $name => $value ) { printf( '', esc_attr( $name ), esc_attr( (string) $value ) ); } } /** * Читает фильтры из адресной строки. * * @param array $secondary Дополнительные языки. * @return array{locale: string, status: string, search: string, scope: string, object_id: int, type: string, page: int} */ private function readFilters( array $secondary ): array { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- фильтры только читают данные. $locale = isset( $_GET['lingocanvas_locale'] ) ? Locale::normalize( sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_locale'] ) ) ) : ''; $status = isset( $_GET['lingocanvas_status'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_status'] ) ) : ''; $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['s'] ) ) : ''; $scope = isset( $_GET['lingocanvas_scope'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_scope'] ) ) : ''; $type = isset( $_GET['lingocanvas_type'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_type'] ) ) : ''; $domain = isset( $_GET['lingocanvas_domain'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['lingocanvas_domain'] ) ) : ''; $page = isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 1; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( ! isset( $secondary[ $locale ] ) ) { $locale = (string) array_key_first( $secondary ); } list( $scopeName, $objectId ) = self::parseScope( $scope ); /* * Статус на вкладке «Интерфейс» свой: там фильтруют не по стадии * готовности перевода, а по тому, есть ли вообще наше * переопределение поверх языкового пакета. */ $isInterface = self::TAB_INTERFACE === self::parseTab( $this->rawTab() ); return array( 'locale' => $locale, 'status' => $this->parseStatus( $status, $isInterface ), 'search' => $search, 'scope' => $scopeName, 'object_id' => $objectId, 'type' => self::parseType( $type ), 'domain' => $domain, 'page' => max( 1, $page ), ); } /** * Проверяет значение фильтра статуса по allowlist нужной вкладки. * * @param string $value Сырое значение параметра. * @param bool $isInterface Вкладка «Интерфейс» — у неё свои статусы. */ private function parseStatus( string $value, bool $isInterface ): string { if ( $isInterface ) { $allowed = array( GettextRepository::STATUS_MISSING, GettextRepository::STATUS_OVERRIDDEN ); return in_array( $value, $allowed, true ) ? $value : ''; } return TranslationStatus::isValid( $value ) ? $value : ''; } /** * Проверяет значение фильтра типа. Чистая функция. * * Значение приходит из адресной строки, поэтому всё, чего нет в * allowlist, схлопывается в «любой тип» — так же, как parseScope() * поступает с областью поиска. * * @param string $value Сырое значение параметра. */ public static function parseType( string $value ): string { $allowed = array( SourceRepository::TYPE_SEO, SourceRepository::TYPE_TEXT, SourceRepository::TYPE_ATTRIBUTE, SourceRepository::TYPE_BLOCK, ); return in_array( $value, $allowed, true ) ? $value : SourceRepository::TYPE_ALL; } /** * Разбирает значение фильтра области. Чистая функция. * * Принимает `` (весь сайт), `global` (общие элементы) и `object:123` * (конкретная запись). Всё остальное трактуется как «весь сайт»: * значение приходит из адресной строки и доверять ему нельзя. * * @param string $value Сырое значение параметра. * @return array{0: string, 1: int} Название области и идентификатор записи. */ public static function parseScope( string $value ): array { if ( SourceRepository::SCOPE_GLOBAL === $value ) { return array( SourceRepository::SCOPE_GLOBAL, 0 ); } $prefix = SourceRepository::SCOPE_OBJECT . ':'; if ( str_starts_with( $value, $prefix ) ) { $id = substr( $value, strlen( $prefix ) ); /* * Только цифры целиком. absint() взял бы ведущее число и превратил * `object:-5` в запись 5, а `object:1 OR 1=1` — в запись 1: до SQL * это не дошло бы (значение уходит через %d), но фильтр молча * показывал бы не то, что просили. */ if ( '' !== $id && ctype_digit( $id ) && (int) $id > 0 ) { return array( SourceRepository::SCOPE_OBJECT, (int) $id ); } } return array( SourceRepository::SCOPE_ALL, 0 ); } /** * Название вида строки. * * `content`-атрибут — это meta-тег (SEO/GEO), не обычный атрибут вроде * `alt` или `placeholder`, поэтому у него отдельная подпись, хотя в * колонке `kind` он хранится как `attribute` — своего вида в таблице * `sources` для meta-тегов нет. * * @param string $kind Значение колонки kind. * @param string|null $attributeName Имя атрибута из occurrences, если есть. */ private function kindLabel( string $kind, ?string $attributeName = null ): string { if ( Segment::KIND_SEO === $kind || ( Segment::KIND_ATTRIBUTE === $kind && 'content' === $attributeName ) ) { return __( 'SEO/GEO', 'lingocanvas' ); } if ( Segment::KIND_HTML_BLOCK === $kind ) { return __( 'Block', 'lingocanvas' ); } return Segment::KIND_ATTRIBUTE === $kind ? __( 'Attribute', 'lingocanvas' ) : __( 'Text', 'lingocanvas' ); } }