Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
RevTagStore
0.00% covered (danger)
0.00%
0 / 118
0.00% covered (danger)
0.00%
0 / 9
380
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 replaceTag
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getLatestRevisionWithTag
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getLatestRevisionsForTags
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 removeTags
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 isRevIdFuzzy
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 getTransver
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 setTransver
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getTranslatableBundleIds
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupProcessing;
5
6use MediaWiki\Extension\Translate\Utilities\Utilities;
7use MediaWiki\Page\PageIdentity;
8use Wikimedia\Rdbms\IConnectionProvider;
9use Wikimedia\Rdbms\SelectQueryBuilder;
10
11/**
12 * Class to manage revision tags for translatable bundles.
13 * @author Abijeet Patro
14 * @author Niklas Laxström
15 * @since 2022.04
16 * @license GPL-2.0-or-later
17 */
18class RevTagStore {
19    /** Indicates that a translation is fuzzy (outdated or not passing validation). */
20    public const FUZZY_TAG = 'fuzzy';
21    /** Stores the revision id of the corresponding source text. Used for showing diffs for outdated messages. */
22    public const TRANSVER_PROP = 'tp:transver';
23    /** Indicates a revision of a page that can be marked for translation. */
24    public const TP_MARK_TAG = 'tp:mark';
25    /** Indicates a revision of a translatable page that is marked for translation. */
26    public const TP_READY_TAG = 'tp:tag';
27    /** Indicates a revision of a page that is a valid message bundle. */
28    public const MB_VALID_TAG = 'mb:valid';
29
30    private IConnectionProvider $dbProvider;
31    private array $tagCache = [];
32
33    public function __construct( IConnectionProvider $dbProvider ) {
34        $this->dbProvider = $dbProvider;
35    }
36
37    /** Add tag for the given revisionId, while deleting it from others */
38    public function replaceTag(
39        PageIdentity $identity,
40        string $tag,
41        int $revisionId,
42        ?array $value = null
43    ): void {
44        if ( !$identity->exists() ) {
45            return;
46        }
47
48        $articleId = $identity->getId();
49
50        $dbw = $this->dbProvider->getPrimaryDatabase();
51        $conditions = [
52            'rt_page' => $articleId,
53            'rt_type' => $tag
54        ];
55        $dbw->newDeleteQueryBuilder()
56            ->deleteFrom( 'revtag' )
57            ->where( $conditions )
58            ->caller( __METHOD__ )
59            ->execute();
60
61        if ( $value !== null ) {
62            $conditions['rt_value'] = serialize( implode( '|', $value ) );
63        }
64
65        $conditions['rt_revision'] = $revisionId;
66        $dbw->newInsertQueryBuilder()
67            ->insertInto( 'revtag' )
68            ->row( $conditions )
69            ->caller( __METHOD__ )
70            ->execute();
71
72        $this->tagCache[$articleId][$tag] = $revisionId;
73    }
74
75    public function getLatestRevisionWithTag( PageIdentity $identity, string $tag ): ?int {
76        $response = $this->getLatestRevisionsForTags( $identity, $tag );
77        return $response[$tag] ?? null;
78    }
79
80    /** @return null|int[] */
81    public function getLatestRevisionsForTags( PageIdentity $identity, string ...$tags ): ?array {
82        if ( !$identity->exists() ) {
83            return null;
84        }
85
86        $articleId = $identity->getId();
87
88        $response = [];
89        $remainingTags = [];
90
91        // ATTENTION: Cache should only be updated on POST requests.
92        foreach ( $tags as $tag ) {
93            if ( isset( $this->tagCache[$articleId][$tag] ) ) {
94                $response[$tag] = $this->tagCache[$articleId][$tag];
95            } else {
96                $remainingTags[] = $tag;
97            }
98        }
99
100        if ( !$remainingTags ) {
101            // All tags were available in the cache, no need to run any queries.
102            return $response;
103        }
104
105        $dbr = Utilities::getSafeReadDB();
106        $results = $dbr->newSelectQueryBuilder()
107            ->select( [ 'rt_revision' => 'MAX(rt_revision)', 'rt_type' ] )
108            ->from( 'revtag' )
109            ->where( [
110                'rt_page' => $articleId,
111                'rt_type' => $remainingTags
112            ] )
113            ->groupBy( 'rt_type' )
114            ->caller( __METHOD__ )
115            ->fetchResultSet();
116
117        foreach ( $results as $row ) {
118            $response[$row->rt_type] = (int)$row->rt_revision;
119        }
120
121        return $response;
122    }
123
124    public function removeTags( PageIdentity $identity, string ...$tag ): void {
125        if ( !$identity->exists() ) {
126            return;
127        }
128
129        $articleId = $identity->getId();
130
131        $dbw = $this->dbProvider->getPrimaryDatabase();
132        $dbw->newDeleteQueryBuilder()
133            ->deleteFrom( 'revtag' )
134            ->where( [
135                'rt_page' => $articleId,
136                'rt_type' => $tag,
137            ] )
138            ->caller( __METHOD__ )
139            ->execute();
140
141        unset( $this->tagCache[$articleId] );
142    }
143
144    public function isRevIdFuzzy( int $articleId, int $revisionId ): bool {
145        $dbw = $this->dbProvider->getPrimaryDatabase();
146        $res = $dbw->newSelectQueryBuilder()
147            ->select( 'rt_type' )
148            ->from( 'revtag' )
149            ->where( [
150                'rt_page' => $articleId,
151                'rt_type' => self::FUZZY_TAG,
152                'rt_revision' => $revisionId
153            ] )
154            ->caller( __METHOD__ )
155            ->fetchField();
156
157        return $res !== false;
158    }
159
160    /**
161     * Get the revision ID of the original message that was live the last
162     * time a non-fuzzy translation was saved (presumably the version whose
163     * translation the translation is). Used to determine whether the
164     * translation is outdated and to show a diff of the original message
165     * if it is.
166     * @return int|null The revision ID, or `null` if none is found
167     */
168    public function getTransver( PageIdentity $identity ): ?int {
169        $db = Utilities::getSafeReadDB();
170        $result = $db->newSelectQueryBuilder()
171            ->select( 'rt_value' )
172            ->from( 'revtag' )
173            ->where( [
174                'rt_page' => $identity->getId(),
175                'rt_type' => self::TRANSVER_PROP,
176            ] )
177            ->orderBy( 'rt_revision', SelectQueryBuilder::SORT_DESC )
178            ->caller( __METHOD__ )
179            ->fetchField();
180        if ( $result === false ) {
181            return null;
182        } else {
183            // The revtag database is defined to store a string in rt_value,
184            // but tp:transver is always an integer
185            return (int)$result;
186        }
187    }
188
189    /**
190     * Sets the tp:transver revtag of the given page/revision. This
191     * normally represents the last time a non-fuzzy translation was saved
192     * (presumably the version whose translation the translation is).
193     * and is used to determine whether the translation is outdated
194     * and to show a diff of the original message if it is.
195     * @return int|null The revision ID, or `null` if none is found
196     */
197    public function setTransver( PageIdentity $identity, int $translationRevision, int $transver ) {
198        $dbw = $this->dbProvider->getPrimaryDatabase();
199
200        $conds = [
201            'rt_page' => $identity->getId(),
202            'rt_type' => self::TRANSVER_PROP,
203            'rt_revision' => $translationRevision,
204            'rt_value' => $transver,
205        ];
206        $dbw->newReplaceQueryBuilder()
207            ->replaceInto( 'revtag' )
208            ->uniqueIndexFields( [ 'rt_type', 'rt_page', 'rt_revision' ] )
209            ->row( $conds )
210            ->caller( __METHOD__ )
211            ->execute();
212    }
213
214    /** Get a list of page ids where the latest revision is either tagged or marked */
215    public static function getTranslatableBundleIds( string ...$revTags ): array {
216        $dbr = Utilities::getSafeReadDB();
217        $res = $dbr->newSelectQueryBuilder()
218            ->select( 'rt_page' )
219            ->from( 'revtag' )
220            ->join(
221                'page',
222                null,
223                [ 'rt_page = page_id', 'rt_revision = page_latest', 'rt_type' => $revTags ]
224            )
225            ->groupBy( 'rt_page' )
226            ->caller( __METHOD__ )
227            ->fetchResultSet();
228        $results = [];
229        foreach ( $res as $row ) {
230            $results[] = (int)$row->rt_page;
231        }
232
233        return $results;
234    }
235}