$rows Записи. */ public function insertMany( array $rows ): void { global $wpdb; if ( array() === $rows ) { return; } $table = Schema::table( 'occurrences' ); $now = gmdate( 'Y-m-d H:i:s' ); foreach ( array_chunk( $rows, self::CHUNK ) as $chunk ) { $values = array(); $args = array(); foreach ( $chunk as $row ) { if ( ! Hash::isValid( $row['uniq_hash'] ) || (int) $row['source_id'] <= 0 ) { continue; } $urlHashSql = null !== $row['url_hash'] && Hash::isValid( $row['url_hash'] ) ? 'UNHEX(%s)' : 'NULL'; $values[] = "(%d, %s, NULLIF(%d, 0), {$urlHashSql}, NULLIF(%s, ''), UNHEX(%s), %s, %s)"; $args[] = (int) $row['source_id']; $args[] = $row['object_type']; $args[] = (int) ( $row['object_id'] ?? 0 ); if ( 'NULL' !== $urlHashSql ) { $args[] = $row['url_hash']; } $args[] = (string) ( $row['attribute_name'] ?? '' ); $args[] = $row['uniq_hash']; $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; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $wpdb->query( $wpdb->prepare( "INSERT INTO %i (source_id, object_type, object_id, url_hash, attribute_name, uniq_hash, first_seen_at, last_seen_at) VALUES {$valuesSql} ON DUPLICATE KEY UPDATE last_seen_at = VALUES(last_seen_at), object_type = VALUES(object_type), object_id = VALUES(object_id)", array_merge( array( $table ), $args ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery } } /** * Записи и страницы, на которых вообще находились строки. * * Нужен фильтру в админке: показывать в выпадающем списке только то, * что плагин реально видел, а не весь каталог записей сайта. * * @param int $limit Сколько объектов вернуть. * @return list */ public function objectIds( int $limit = 300 ): array { global $wpdb; $table = Schema::table( 'occurrences' ); $limit = max( 1, min( 1000, $limit ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT object_id FROM %i WHERE object_type = 'post' AND object_id IS NOT NULL AND object_id > 0 ORDER BY object_id DESC LIMIT %d", $table, $limit ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return array_map( 'intval', is_array( $rows ) ? $rows : array() ); } /** * Записи, у которых есть хотя бы один перевод на указанный язык. * * Отдельного признака «запись переведена» в модели нет и быть не может: * перевод хранится по строкам, а не по записям. Признак выводится из * данных — запись считается переведённой, если хоть одна её строка имеет * непустой перевод на нужный язык. * * @param string $locale Целевой язык. * @return list */ public function translatedObjectIds( string $locale ): array { global $wpdb; if ( ! Locale::isValid( $locale ) ) { return array(); } $occurrences = Schema::table( 'occurrences' ); $translations = Schema::table( 'translations' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT o.object_id FROM %i o INNER JOIN %i t ON t.source_id = o.source_id AND t.target_locale = %s WHERE o.object_type = 'post' AND o.object_id IS NOT NULL AND o.object_id > 0 AND t.translated_text IS NOT NULL AND t.translated_text <> ''", $occurrences, $translations, $locale ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return array_map( 'intval', is_array( $rows ) ? $rows : array() ); } /** * Записи, у которых переведены ВСЕ их строки на указанный язык. * * «Строки записи» и «переведена» — те же правила, что у колонки «Языки» * (PostStringScope): строка принадлежит только этой записи, адреса ссылок * не считаются, перевод непустой. Запись без единой известной строки * «полностью переведённой» не считается: на неё ещё не заходили, и пустое * множество строк ничего не доказывает. * * @param string $locale Целевой язык. * @return list */ public function fullyTranslatedObjectIds( string $locale ): array { global $wpdb; if ( ! Locale::isValid( $locale ) ) { return array(); } $translations = Schema::table( 'translations' ); $rows = PostStringScope::ownRowsSql( 'o.object_id IS NOT NULL AND o.object_id > 0' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $ids = $wpdb->get_col( $wpdb->prepare( "SELECT x.object_id FROM ( {$rows} ) x LEFT JOIN %i t ON t.source_id = x.source_id AND t.target_locale = %s GROUP BY x.object_id HAVING SUM(CASE WHEN t.translated_text IS NOT NULL AND t.translated_text <> '' THEN 1 ELSE 0 END) = COUNT(*)", $translations, Locale::normalize( $locale ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return array_map( 'intval', is_array( $ids ) ? $ids : array() ); } /** * Сколько строк у одной записи и сколько из них переведено на язык. * * Правила — как у колонки «Языки» (PostStringScope). Нужно визуальному * редактору, чтобы рядом с переключателем показа написать «переведено на N%». * * @param int $postId Запись. * @param string $locale Целевой язык. * @return array{0: int, 1: int} Всего строк и переведено. */ public function translationProgress( int $postId, string $locale ): array { global $wpdb; if ( $postId <= 0 || ! Locale::isValid( $locale ) ) { return array( 0, 0 ); } $translations = Schema::table( 'translations' ); $rows = PostStringScope::ownRowsSql( 'o.object_id = %d' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $row = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS total, SUM(CASE WHEN t.translated_text IS NOT NULL AND t.translated_text <> '' THEN 1 ELSE 0 END) AS done FROM ( {$rows} ) x LEFT JOIN %i t ON t.source_id = x.source_id AND t.target_locale = %s", $postId, $translations, Locale::normalize( $locale ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery if ( ! is_array( $row ) ) { return array( 0, 0 ); } return array( (int) ( $row['total'] ?? 0 ), (int) ( $row['done'] ?? 0 ) ); } /** * Дата последнего изменения перевода записи на этот язык — самая * свежая среди всех переведённых строк, которые встречались в этой * записи. * * Нужна карте сайта (см. Frontend\Sitemap): `wp_posts.post_modified` * относится только к исходному языку — перевод живёт в отдельной * таблице и его не меняет. Без этого метода `lastmod` английской * версии страницы навсегда оставался бы датой последней правки * русского текста, даже если сам перевод появился только сегодня. * * @param string $locale Целевой язык. * @return array Идентификатор записи => `updated_at` (UTC, `Y-m-d H:i:s`) самого свежего перевода среди её строк. */ public function lastTranslatedAt( string $locale ): array { global $wpdb; if ( ! Locale::isValid( $locale ) ) { return array(); } $occurrences = Schema::table( 'occurrences' ); $translations = Schema::table( 'translations' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT o.object_id, MAX(t.updated_at) AS last_updated FROM %i o INNER JOIN %i t ON t.source_id = o.source_id AND t.target_locale = %s WHERE o.object_type = 'post' AND o.object_id IS NOT NULL AND o.object_id > 0 AND t.translated_text IS NOT NULL AND t.translated_text <> '' GROUP BY o.object_id", $occurrences, $translations, $locale ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery $result = array(); foreach ( (array) $rows as $row ) { $result[ (int) $row['object_id'] ] = (string) $row['last_updated']; } return $result; } /** * Встречалась ли строка у этого объекта (защита commit'а массового * перевода: id строки в запросе обязан быть тем, что действительно * извлечён из ЭТОЙ записи, а не чужим, подставленным вручную в запрос). * * @param int $sourceId Исходная строка. * @param int $objectId Идентификатор объекта (записи). */ public function belongsToObject( int $sourceId, int $objectId ): bool { global $wpdb; if ( $sourceId <= 0 || $objectId <= 0 ) { return false; } $table = Schema::table( 'occurrences' ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE source_id = %d AND object_type = 'post' AND object_id = %d LIMIT 1", $table, $sourceId, $objectId ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return null !== $found; } /** * Где встречалась строка — для подсказки в админке. * * @param int $sourceId Исходная строка. * @param int $limit Сколько записей вернуть. * @return list> */ public function forSource( int $sourceId, int $limit = 5 ): array { global $wpdb; $table = Schema::table( 'occurrences' ); $limit = max( 1, min( 50, $limit ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery -- имена таблиц через %i; подзапрос PostStringScope::ownRowsSql() и VALUES собраны из кода (таблицы из allowlist, константы), значения идут через prepare(); анализатор не считает параметры в array_merge(). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT object_type, object_id, attribute_name, last_seen_at FROM %i WHERE source_id = %d ORDER BY last_seen_at DESC LIMIT %d", $table, $sourceId, $limit ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery return is_array( $rows ) ? $rows : array(); } }