Дешёвый ключ GettextKey::lookup() => перевод. */ public function overridesFor( string $locale, int $cacheVersion ): array { if ( ! Locale::isValid( $locale ) ) { return array(); } $cacheKey = $cacheVersion . ':' . $locale; $cached = wp_cache_get( $cacheKey, self::CACHE_GROUP ); if ( is_array( $cached ) ) { return $cached; } global $wpdb; $sources = Schema::table( 'sources' ); $translations = Schema::table( 'translations' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT s.source_text, s.domain, s.gettext_context, s.plural_key, t.translated_text FROM %i s INNER JOIN %i t ON t.source_id = s.id AND t.target_locale = %s WHERE s.kind = %s AND t.translated_text IS NOT NULL AND t.translated_text <> ''", $sources, $translations, $locale, GettextKey::KIND ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery $overrides = array(); foreach ( (array) $rows as $row ) { $key = GettextKey::lookup( (string) $row['source_text'], (string) ( $row['domain'] ?? '' ), (string) ( $row['gettext_context'] ?? '' ), null !== $row['plural_key'] ? (int) $row['plural_key'] : null ); $overrides[ $key ] = (string) $row['translated_text']; } wp_cache_set( $cacheKey, $overrides, self::CACHE_GROUP, HOUR_IN_SECONDS ); return $overrides; } /** * Домены, которые реально встречались, — для выпадающего фильтра. * * Список строится из самого словаря, а не из перечня установленных * плагинов: в фильтре должно быть только то, по чему действительно * есть что показать. * * @return list */ public function domains(): array { global $wpdb; $table = Schema::table( 'sources' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT domain FROM %i WHERE kind = %s AND domain IS NOT NULL AND domain <> '' ORDER BY domain ASC", $table, GettextKey::KIND ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return array_map( 'strval', is_array( $rows ) ? $rows : array() ); } /** * Постраничный список gettext-строк с переопределениями — для админки. * * Отдельно от SourceRepository::paginate() ради колонок * `domain`/`gettext_context`/`plural_key`: без них строку не показать * («откуда она» и «какой контекст») и не отличить от одноимённой из * другого плагина. * * @param array{locale: string, domain?: string, status?: string, search?: string, page?: int, per_page?: int} $args Фильтры. * @return array{items: list>, total: int} */ public function paginate( array $args ): array { global $wpdb; $locale = (string) ( $args['locale'] ?? '' ); if ( ! Locale::isValid( $locale ) ) { return array( 'items' => array(), 'total' => 0, ); } $perPage = max( 1, min( 200, (int) ( $args['per_page'] ?? 20 ) ) ); $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $offset = ( $page - 1 ) * $perPage; $search = trim( (string) ( $args['search'] ?? '' ) ); $domain = trim( (string) ( $args['domain'] ?? '' ) ); $status = (string) ( $args['status'] ?? '' ); $sources = Schema::table( 'sources' ); $translations = Schema::table( 'translations' ); $where = array( 's.kind = %s' ); $params = array( GettextKey::KIND ); if ( '' !== $domain ) { $where[] = 's.domain = %s'; $params[] = $domain; } /* * Фильтровать можно только по тому, что есть в SQL: наше * переопределение либо есть, либо нет. «Перевод из языкового * пакета» в базе не лежит вовсе (он в файлах .mo), поэтому он * остаётся признаком для показа, а не для фильтра, — иначе * счётчик найденных строк разошёлся бы с самой выборкой. */ if ( self::STATUS_OVERRIDDEN === $status ) { $where[] = "(t.id IS NOT NULL AND t.translated_text IS NOT NULL AND t.translated_text <> '')"; } elseif ( self::STATUS_MISSING === $status ) { $where[] = "(t.id IS NULL OR t.translated_text IS NULL OR t.translated_text = '')"; } if ( '' !== $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $where[] = '(s.source_text LIKE %s OR t.translated_text LIKE %s)'; $params[] = $like; $params[] = $like; } $whereSql = implode( ' AND ', $where ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i s LEFT JOIN %i t ON t.source_id = s.id AND t.target_locale = %s WHERE {$whereSql}", array_merge( array( $sources, $translations, $locale ), $params ) ) ); $items = $wpdb->get_results( $wpdb->prepare( "SELECT s.id, s.source_text, s.domain, s.gettext_context, s.plural_key, t.translated_text, t.status, t.updated_at FROM %i s LEFT JOIN %i t ON t.source_id = s.id AND t.target_locale = %s WHERE {$whereSql} ORDER BY s.domain ASC, s.source_text ASC, s.plural_key ASC LIMIT %d OFFSET %d", array_merge( array( $sources, $translations, $locale ), $params, array( $perPage, $offset ) ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return array( 'items' => is_array( $items ) ? $items : array(), 'total' => $total, ); } /** * Удаляет собранные строки интерфейса, которые никто не переводил. * * Безопасно в отличие от такой же чистки контента: gettext-строки — * данные производные, они появляются заново сами при следующем показе * страницы, если ещё нужны. А вот ручные переопределения не трогаются * вовсе: строка с непустым переводом хоть на один язык остаётся. * * Нужно после того, как в словарь успел попасть мусор — например, * строки, собранные до установки языкового пакета, когда «непереведённой» * выглядела каждая строка ядра. * * @return int Сколько строк удалено. */ public function deleteUntranslated(): int { global $wpdb; $sources = Schema::table( 'sources' ); $translations = Schema::table( 'translations' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). // Сначала пустые записи переводов: иначе они остались бы сиротами // и не дали бы удалить сам источник проверкой ниже. $wpdb->query( $wpdb->prepare( "DELETE t FROM %i t INNER JOIN %i s ON s.id = t.source_id WHERE s.kind = %s AND (t.translated_text IS NULL OR t.translated_text = '')", $translations, $sources, GettextKey::KIND ) ); $deleted = $wpdb->query( $wpdb->prepare( "DELETE s FROM %i s WHERE s.kind = %s AND NOT EXISTS (SELECT 1 FROM %i t WHERE t.source_id = s.id)", $sources, GettextKey::KIND, $translations ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return max( 0, (int) $deleted ); } /** * Сколько строк удалит deleteUntranslated(). Один COUNT. */ public function countUntranslated(): int { global $wpdb; $sources = Schema::table( 'sources' ); $translations = Schema::table( 'translations' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i s WHERE s.kind = %s AND NOT EXISTS ( SELECT 1 FROM %i t WHERE t.source_id = s.id AND t.translated_text IS NOT NULL AND t.translated_text <> '' )", $sources, GettextKey::KIND, $translations ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery } /** * Фильтр «есть наше переопределение». */ public const STATUS_OVERRIDDEN = 'overridden'; /** * Фильтр «нашего переопределения нет». */ public const STATUS_MISSING = 'missing'; /** * Заводит gettext-строки, которых ещё нет в словаре. * * Своя реализация вместо SourceRepository::insertMissing() именно ради * трёх колонок: без `domain`/`gettext_context`/`plural_key` строка * теряет то, чем она отличается от одноимённой строки другого плагина. * * @param list $rows Новые строки. * @return int Сколько строк реально добавлено. */ public function insertMissing( array $rows ): int { global $wpdb; if ( array() === $rows ) { return 0; } $table = Schema::table( 'sources' ); $now = gmdate( 'Y-m-d H:i:s' ); $inserted = 0; foreach ( array_chunk( $rows, self::CHUNK ) as $chunk ) { $values = array(); $args = array(); foreach ( $chunk as $row ) { $msgid = (string) $row['msgid']; $domain = (string) $row['domain']; $context = (string) $row['context']; $pluralKey = $row['plural_key'] ?? null; $uniqHash = GettextKey::uniqHash( $msgid, $domain, $context, $pluralKey ); $sourceHash = Hash::of( $msgid ); if ( ! Hash::isValid( $uniqHash ) || ! Hash::isValid( $sourceHash ) ) { continue; } /* * NULLIF по контексту и NULL по plural_key: в колонках должно * лежать именно NULL, когда контекста и множественного числа * нет, — так же, как у строк из DOM. На uniq_hash это не * влияет: он посчитан выше из пустых строк, а Hash::ofParts() * приводит NULL и '' к одному значению (см. HashCompatibilityTest). */ $values[] = '(%s, %s, %s, UNHEX(%s), UNHEX(%s), %s, NULLIF(%s, \'\'), ' . ( null === $pluralKey ? 'NULL' : '%d' ) . ', UNHEX(%s), %s, %s)'; $args[] = GettextKey::SOURCE_LOCALE; $args[] = GettextKey::KIND; $args[] = $msgid; $args[] = $sourceHash; $args[] = Hash::of( '' ); $args[] = $domain; $args[] = $context; if ( null !== $pluralKey ) { $args[] = (int) $pluralKey; } $args[] = $uniqHash; $args[] = $now; $args[] = $now; } if ( array() === $values ) { continue; } $valuesSql = implode( ', ', $values ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i, WHERE/VALUES собраны из литералов с плейсхолдерами, значения идут через prepare(); анализатор не считает параметры в array_merge(). $inserted += (int) $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (source_locale, kind, source_text, source_hash, context_hash, domain, gettext_context, plural_key, uniq_hash, created_at, last_seen_at) VALUES {$valuesSql}", array_merge( array( $table ), $args ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery } return $inserted; } }