lang, group_id. * Objects sharing a group_id are translations of each other; an object * without a row counts as the default language. No foreign keys (WordPress * convention): deleting a post removes only its own row. */ declare( strict_types = 1 ); namespace AddBlack\Lang\Translation; defined( 'ABSPATH' ) || exit; use AddBlack\Lang\Install\Schema; use WP_Error; final class TranslationRepository { private const CACHE_GROUP = 'blocklingo'; public function __construct( private readonly \wpdb $db, private readonly Schema $schema ) {} public function table(): string { return $this->schema->translations(); } /** * Language and group of an object, or null when it has no row. * * @return array{lang: string, group_id: string}|null */ public function find( int $object_id, string $type = 'post' ): ?array { $key = $this->cache_key( $type, $object_id ); $row = wp_cache_get( $key, self::CACHE_GROUP ); if ( false === $row ) { $row = $this->db->get_row( $this->db->prepare( 'SELECT lang, group_id FROM %i WHERE object_type = %s AND object_id = %d', $this->table(), $type, $object_id ), ARRAY_A ); $row = $row ? $row : 'none'; wp_cache_set( $key, $row, self::CACHE_GROUP ); } return 'none' === $row ? null : $row; } /** * Set an object's language. Keeps its group unless $group_id is given. * Refused when the group already has another object in that language. * * @return true|WP_Error */ public function assign( int $object_id, string $lang, ?int $group_id = null, string $type = 'post' ): bool|WP_Error { $row = $this->find( $object_id, $type ); if ( null === $group_id ) { $group_id = $row ? (int) $row['group_id'] : $object_id; } $taken = $this->db->get_var( $this->db->prepare( 'SELECT object_id FROM %i WHERE object_type = %s AND group_id = %d AND lang = %s AND object_id <> %d', $this->table(), $type, $group_id, $lang, $object_id ) ); if ( $taken ) { return new WP_Error( 'blocklingo_lang_taken', sprintf( /* translators: 1: language code, 2: post ID. */ __( 'Translation group already has a "%1$s" version (ID %2$d).', 'blocklingo' ), $lang, (int) $taken ) ); } $this->db->replace( $this->table(), array( 'object_type' => $type, 'object_id' => $object_id, 'lang' => $lang, 'group_id' => $group_id, ), array( '%s', '%d', '%s', '%d' ) ); wp_cache_delete( $this->cache_key( $type, $object_id ), self::CACHE_GROUP ); return true; } /** * Detach an object from its group: it becomes a group of its own. * * @return true|WP_Error */ public function unlink( int $object_id, string $type = 'post' ): bool|WP_Error { $row = $this->find( $object_id, $type ); return $row ? $this->assign( $object_id, $row['lang'], $object_id, $type ) : true; } /** * The object's group, itself included. An untagged object is a group of one * in $fallback_lang. * * @return array lang => object_id */ public function group( int $object_id, string $fallback_lang, string $type = 'post' ): array { $row = $this->find( $object_id, $type ); if ( ! $row ) { return array( $fallback_lang => $object_id ); } $rows = $this->db->get_results( $this->db->prepare( 'SELECT lang, object_id FROM %i WHERE object_type = %s AND group_id = %d', $this->table(), $type, $row['group_id'] ), ARRAY_A ); $out = array(); foreach ( (array) $rows as $r ) { $out[ (string) $r['lang'] ] = (int) $r['object_id']; } return $out; } public function delete( int $object_id, string $type = 'post' ): void { $this->db->delete( $this->table(), array( 'object_type' => $type, 'object_id' => $object_id, ) ); wp_cache_delete( $this->cache_key( $type, $object_id ), self::CACHE_GROUP ); } /** * Tag every untagged post of the given types with $lang, each as its own group. * * @param string[] $post_types */ public function tag_untagged_posts( array $post_types, string $lang ): void { if ( ! $post_types ) { return; } $in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $in is a list of %s placeholders. $this->db->query( $this->db->prepare( "INSERT IGNORE INTO %i (object_type, object_id, lang, group_id) SELECT 'post', ID, %s, ID FROM %i WHERE post_type IN ({$in}) AND post_status NOT IN ('auto-draft','inherit')", array_merge( array( $this->table(), $lang, $this->db->posts ), $post_types ) ) ); // phpcs:enable // Many rows changed at once: invalidate every cached lookup. wp_cache_set_last_changed( self::CACHE_GROUP ); } /** * Published posts of $post_types in the default language (tagged with it, * or not tagged at all), per type: how many there are and how many have a * version in $lang (any status). One query, however big the site. * * @param string[] $post_types * @return array */ public function coverage_counts( array $post_types, string $default_lang, string $lang ): array { if ( ! $post_types ) { return array(); } $in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- $in is a list of %s placeholders; live counts. $rows = $this->db->get_results( $this->db->prepare( "SELECT p.post_type, COUNT(*) AS total, SUM( t.object_id IS NOT NULL ) AS done FROM %i p LEFT JOIN %i s ON s.object_type = 'post' AND s.object_id = p.ID LEFT JOIN %i t ON t.object_type = 'post' AND t.group_id = s.group_id AND t.lang = %s WHERE p.post_status = 'publish' AND p.post_type IN ({$in}) AND ( s.lang = %s OR s.lang IS NULL ) GROUP BY p.post_type", array_merge( array( $this->db->posts, $this->table(), $this->table(), $lang ), $post_types, array( $default_lang ) ) ), ARRAY_A ); // phpcs:enable $out = array(); foreach ( (array) $rows as $row ) { $out[ (string) $row['post_type'] ] = array( 'total' => (int) $row['total'], 'done' => (int) $row['done'], ); } return $out; } /** * IDs of published default-language posts with no version in $lang, * ordered by type (page before post), menu order and ID. * * @param string[] $post_types * @return int[] */ public function missing_ids( array $post_types, string $default_lang, string $lang, int $limit ): array { if ( ! $post_types || $limit < 1 ) { return array(); } $in = implode( ',', array_fill( 0, count( $post_types ), '%s' ) ); // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- $in is a list of %s placeholders; live list. $ids = $this->db->get_col( $this->db->prepare( "SELECT p.ID FROM %i p LEFT JOIN %i s ON s.object_type = 'post' AND s.object_id = p.ID LEFT JOIN %i t ON t.object_type = 'post' AND t.group_id = s.group_id AND t.lang = %s WHERE p.post_status = 'publish' AND p.post_type IN ({$in}) AND ( s.lang = %s OR s.lang IS NULL ) AND t.object_id IS NULL ORDER BY p.post_type ASC, p.menu_order ASC, p.ID ASC LIMIT %d", array_merge( array( $this->db->posts, $this->table(), $this->table(), $lang ), $post_types, array( $default_lang, $limit ) ) ) ); // phpcs:enable return array_map( 'intval', (array) $ids ); } /** Cache key for one object; changes whenever many rows change at once. */ private function cache_key( string $type, int $object_id ): string { return $type . '_' . $object_id . ':' . wp_cache_get_last_changed( self::CACHE_GROUP ); } }