> lang => key => translation (per request). */ private array $maps = array(); public function __construct( private readonly \wpdb $db, private readonly Schema $schema, private readonly SettingsRepository $settings ) {} public static function key( string $domain, string $context, string $text ): string { return $domain . "\x04" . $context . "\x04" . $text; } public static function hash( string $domain, string $context, string $text ): string { return md5( self::key( $domain, $context, $text ) ); } /** * Add strings; existing ones are ignored. * * @param array $items [ domain, context, text ]. * @return int Rows inserted. */ public function add( array $items ): int { $now = current_time( 'mysql', true ); $added = 0; foreach ( array_chunk( $items, 100 ) as $chunk ) { $rows = array(); $args = array( $this->schema->strings() ); foreach ( $chunk as $it ) { list( $domain, $context, $text ) = array_map( 'strval', $it ); $rows[] = '(%s,%s,%s,%s,%s)'; array_push( $args, self::hash( $domain, $context, $text ), substr( $domain, 0, 191 ), substr( $context, 0, 191 ), $text, $now ); } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- placeholders only. $added += (int) $this->db->query( $this->db->prepare( 'INSERT IGNORE INTO %i (hash, domain, context, original, created) VALUES ' . implode( ',', $rows ), $args ) ); } return $added; } /** @return array Translations of one language: key => value. */ public function map( string $lang ): array { if ( isset( $this->maps[ $lang ] ) ) { return $this->maps[ $lang ]; } if ( get_option( Installer::DB_VERSION_OPTION ) !== Installer::DB_VERSION ) { return array(); // Tables not created yet (plugin just updated). } $map = get_transient( self::CACHE . $lang ); if ( ! is_array( $map ) ) { $rows = $this->db->get_results( $this->db->prepare( "SELECT s.domain, s.context, s.original, t.value FROM %i s INNER JOIN %i t ON t.string_id = s.id WHERE t.lang = %s AND t.value <> ''", $this->schema->strings(), $this->schema->string_translations(), $lang ) ); $map = array(); foreach ( (array) $rows as $r ) { $map[ self::key( (string) $r->domain, (string) $r->context, (string) $r->original ) ] = (string) $r->value; } set_transient( self::CACHE . $lang, $map, DAY_IN_SECONDS ); } $this->maps[ $lang ] = $map; return $map; } public function flush_cache(): void { foreach ( array_keys( $this->settings->get()->languages() ) as $code ) { delete_transient( self::CACHE . $code ); } $this->maps = array(); } /** * A page of strings with their translations. * * @param array $args domain, search, lang + status ('untranslated'), page, per_page. * @return array{items: object[], total: int} */ public function query( array $args = array() ): array { $args = wp_parse_args( $args, array( 'domain' => '', 'search' => '', 'status' => '', 'lang' => '', 'page' => 1, 'per_page' => 50, ) ); $s = $this->schema->strings(); $t = $this->schema->string_translations(); $where = array( '1=1' ); $vals = array(); if ( '' !== $args['domain'] ) { $where[] = 's.domain = %s'; $vals[] = $args['domain']; } if ( '' !== $args['search'] ) { $like = '%' . $this->db->esc_like( (string) $args['search'] ) . '%'; $where[] = '(s.original LIKE %s OR s.id IN (SELECT string_id FROM %i WHERE value LIKE %s))'; array_push( $vals, $like, $t, $like ); } if ( 'untranslated' === $args['status'] ) { $langs = $args['lang'] ? array( (string) $args['lang'] ) : $this->target_langs(); $parts = array(); foreach ( $langs as $l ) { $parts[] = "NOT EXISTS (SELECT 1 FROM %i x WHERE x.string_id = s.id AND x.lang = %s AND x.value <> '')"; array_push( $vals, $t, $l ); } if ( $parts ) { $where[] = '(' . implode( ' OR ', $parts ) . ')'; } } $sql_where = implode( ' AND ', $where ); $per = max( 1, (int) $args['per_page'] ); $offset = ( max( 1, (int) $args['page'] ) - 1 ) * $per; // $sql_where holds only fixed SQL with %s/%i placeholders; every value goes through prepare(). // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $total = (int) $this->db->get_var( $this->db->prepare( "SELECT COUNT(*) FROM %i s WHERE {$sql_where}", array_merge( array( $s ), $vals ) ) ); $items = $this->db->get_results( $this->db->prepare( "SELECT s.* FROM %i s WHERE {$sql_where} ORDER BY s.domain ASC, s.id ASC LIMIT %d, %d", array_merge( array( $s ), $vals, array( $offset, $per ) ) ) ); // phpcs:enable $by_id = array(); foreach ( (array) $items as $row ) { $row->translations = array(); $by_id[ $row->id ] = $row; } if ( $by_id ) { $ids = array_map( 'intval', array_keys( $by_id ) ); $sql = 'SELECT * FROM %i WHERE string_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')'; foreach ( (array) $this->db->get_results( $this->db->prepare( $sql, array_merge( array( $t ), $ids ) ) ) as $tr ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- %d placeholders only. $by_id[ $tr->string_id ]->translations[ $tr->lang ] = $tr->value; } } return array( 'items' => array_values( $by_id ), 'total' => $total, ); } /** @return string[] Text domains with collected strings. */ public function domains(): array { return array_map( 'strval', $this->db->get_col( $this->db->prepare( 'SELECT DISTINCT domain FROM %i ORDER BY domain', $this->schema->strings() ) ) ); } /** @return string[] Languages strings are translated into (all but the default). */ public function target_langs(): array { return array_map( 'strval', array_keys( $this->settings->get()->other_languages() ) ); } /** Set one translation, or clear it with ''. */ public function set_translation( int $id, string $lang, string $value ): bool { if ( ! $id || ! $this->settings->get()->is_language( $lang ) ) { return false; } if ( '' === trim( $value ) ) { $this->db->delete( $this->schema->string_translations(), array( 'string_id' => $id, 'lang' => $lang, ) ); } else { $this->db->replace( $this->schema->string_translations(), array( 'string_id' => $id, 'lang' => $lang, 'value' => $value, ) ); } delete_transient( self::CACHE . $lang ); unset( $this->maps[ $lang ] ); return true; } /** @param int[] $ids */ public function delete( array $ids ): int { $ids = array_filter( array_map( 'intval', $ids ) ); if ( ! $ids ) { return 0; } $ids = array_values( $ids ); $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $in is a list of %d placeholders. $this->db->query( $this->db->prepare( "DELETE FROM %i WHERE string_id IN ({$in})", array_merge( array( $this->schema->string_translations() ), $ids ) ) ); $n = (int) $this->db->query( $this->db->prepare( "DELETE FROM %i WHERE id IN ({$in})", array_merge( array( $this->schema->strings() ), $ids ) ) ); // phpcs:enable $this->flush_cache(); return $n; } /** @return object[] Strings without any translation: id, domain, context, original. */ public function untranslated(): array { return (array) $this->db->get_results( $this->db->prepare( 'SELECT s.id, s.domain, s.context, s.original FROM %i s WHERE NOT EXISTS (SELECT 1 FROM %i t WHERE t.string_id = s.id)', $this->schema->strings(), $this->schema->string_translations() ) ); } }