Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.81% covered (success)
93.81%
91 / 97
75.00% covered (warning)
75.00%
9 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditResultBuilder
93.81% covered (success)
93.81%
91 / 97
75.00% covered (warning)
75.00%
9 / 12
40.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildEditResult
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 setRevisionRecord
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIsNew
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 markAsRevert
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 setOriginalRevision
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 detectManualRevert
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 guessOriginalRevisionId
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
9.00
 getOriginalRevision
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 isExactRevert
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 isNullEdit
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getRevertTags
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Storage;
22
23use MediaWiki\Config\ServiceOptions;
24use MediaWiki\MainConfigNames;
25use MediaWiki\Revision\RevisionRecord;
26use MediaWiki\Revision\RevisionStore;
27use Wikimedia\Assert\Assert;
28
29/**
30 * Builder class for the EditResult object.
31 *
32 * @internal Only for use by PageUpdater
33 * @since 1.35
34 * @author Ostrzyciel
35 */
36class EditResultBuilder {
37
38    public const CONSTRUCTOR_OPTIONS = [
39        MainConfigNames::ManualRevertSearchRadius,
40    ];
41
42    /**
43     * A mapping from EditResult's revert methods to relevant change tags.
44     * For use by getRevertTags()
45     */
46    private const REVERT_METHOD_TO_CHANGE_TAG = [
47        EditResult::REVERT_UNDO => 'mw-undo',
48        EditResult::REVERT_ROLLBACK => 'mw-rollback',
49        EditResult::REVERT_MANUAL => 'mw-manual-revert'
50    ];
51
52    /** @var RevisionRecord|null */
53    private $revisionRecord = null;
54
55    /** @var bool */
56    private $isNew = false;
57
58    /** @var int|false */
59    private $originalRevisionId = false;
60
61    /** @var RevisionRecord|null */
62    private $originalRevision = null;
63
64    /** @var int|null */
65    private $revertMethod = null;
66
67    /** @var int|null */
68    private $newestRevertedRevId = null;
69
70    /** @var int|null */
71    private $oldestRevertedRevId = null;
72
73    /** @var int|null */
74    private $revertAfterRevId = null;
75
76    /** @var RevisionStore */
77    private $revisionStore;
78
79    /** @var string[] */
80    private $softwareTags;
81
82    /** @var ServiceOptions */
83    private $options;
84
85    /**
86     * @param RevisionStore $revisionStore
87     * @param string[] $softwareTags Array of currently enabled software change tags. Can be
88     *        obtained from ChangeTags::getSoftwareTags()
89     * @param ServiceOptions $options Options for this instance.
90     */
91    public function __construct(
92        RevisionStore $revisionStore,
93        array $softwareTags,
94        ServiceOptions $options
95    ) {
96        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
97
98        $this->revisionStore = $revisionStore;
99        $this->softwareTags = $softwareTags;
100        $this->options = $options;
101    }
102
103    /**
104     * @return EditResult
105     */
106    public function buildEditResult(): EditResult {
107        if ( $this->revisionRecord === null ) {
108            throw new PageUpdateException(
109                'Revision was not set prior to building an EditResult'
110            );
111        }
112
113        // If we don't know the original revision ID, but know which one was undone, try to find out
114        $this->guessOriginalRevisionId();
115
116        // do a last-minute check if this was a manual revert
117        $this->detectManualRevert();
118
119        return new EditResult(
120            $this->isNew,
121            $this->originalRevisionId,
122            $this->revertMethod,
123            $this->oldestRevertedRevId,
124            $this->newestRevertedRevId,
125            $this->isExactRevert(),
126            $this->isNullEdit(),
127            $this->getRevertTags()
128        );
129    }
130
131    /**
132     * Set the revision associated with this edit.
133     * Should only be called by PageUpdater when saving an edit.
134     *
135     * @param RevisionRecord $revisionRecord
136     */
137    public function setRevisionRecord( RevisionRecord $revisionRecord ) {
138        $this->revisionRecord = $revisionRecord;
139    }
140
141    /**
142     * Set whether the edit created a new page.
143     * Should only be called by PageUpdater when saving an edit.
144     *
145     * @param bool $isNew
146     */
147    public function setIsNew( bool $isNew ) {
148        $this->isNew = $isNew;
149    }
150
151    /**
152     * Marks this edit as a revert and applies relevant information.
153     *
154     * @param int $revertMethod The method used to make the revert:
155     *   REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL
156     * @param int $newestRevertedRevId the revision ID of the latest reverted revision.
157     * @param int|null $revertAfterRevId the revision ID after which revisions
158     *   are being reverted. Defaults to the revision before the $newestRevertedRevId.
159     */
160    public function markAsRevert(
161        int $revertMethod,
162        int $newestRevertedRevId,
163        int $revertAfterRevId = null
164    ) {
165        Assert::parameter(
166            in_array(
167                $revertMethod,
168                [ EditResult::REVERT_UNDO, EditResult::REVERT_ROLLBACK, EditResult::REVERT_MANUAL ]
169            ),
170            '$revertMethod',
171            'must be one of REVERT_UNDO, REVERT_ROLLBACK, REVERT_MANUAL'
172        );
173        $this->revertAfterRevId = $revertAfterRevId;
174
175        if ( $newestRevertedRevId ) {
176            $this->revertMethod = $revertMethod;
177            $this->newestRevertedRevId = $newestRevertedRevId;
178            $revertAfterRevision = $revertAfterRevId ?
179                $this->revisionStore->getRevisionById( $revertAfterRevId ) :
180                null;
181            $oldestRevertedRev = $revertAfterRevision ?
182                $this->revisionStore->getNextRevision( $revertAfterRevision ) : null;
183            if ( $oldestRevertedRev ) {
184                $this->oldestRevertedRevId = $oldestRevertedRev->getId();
185            } else {
186                // Can't find the oldest reverted revision.
187                // Oh well, just mark the one we know was undone.
188                $this->oldestRevertedRevId = $this->newestRevertedRevId;
189            }
190        }
191    }
192
193    /**
194     * @param RevisionRecord|int|false|null $originalRevision
195     *   RevisionRecord or revision ID for the original revision.
196     *   False or null to unset.
197     */
198    public function setOriginalRevision( $originalRevision ) {
199        if ( $originalRevision instanceof RevisionRecord ) {
200            $this->originalRevision = $originalRevision;
201            $this->originalRevisionId = $originalRevision->getId();
202        } else {
203            $this->originalRevisionId = $originalRevision ?? false;
204            $this->originalRevision = null; // Will be lazy-loaded.
205        }
206    }
207
208    /**
209     * If this edit was not already marked as a revert using EditResultBuilder::markAsRevert(),
210     * tries to establish whether this was a manual revert, i.e. someone restored the page to
211     * an exact previous state manually.
212     *
213     * If successful, mutates the builder accordingly.
214     */
215    private function detectManualRevert() {
216        $searchRadius = $this->options->get( MainConfigNames::ManualRevertSearchRadius );
217        if ( !$searchRadius ||
218            // we already marked this as a revert
219            $this->revertMethod !== null ||
220            // it's a null edit, nothing was reverted
221            $this->isNullEdit() ||
222            // we wouldn't be able to figure out what was the newest reverted edit
223            // this also discards new pages
224            !$this->revisionRecord->getParentId()
225        ) {
226            return;
227        }
228
229        $revertedToRev = $this->revisionStore->findIdenticalRevision( $this->revisionRecord, $searchRadius );
230        if ( !$revertedToRev ) {
231            return;
232        }
233        $oldestReverted = $this->revisionStore->getNextRevision( $revertedToRev );
234        if ( !$oldestReverted ) {
235            return;
236        }
237
238        $this->setOriginalRevision( $revertedToRev );
239        $this->revertMethod = EditResult::REVERT_MANUAL;
240        $this->oldestRevertedRevId = $oldestReverted->getId();
241        $this->newestRevertedRevId = $this->revisionRecord->getParentId();
242        $this->revertAfterRevId = $revertedToRev->getId();
243    }
244
245    /**
246     * In case we have not got the original revision ID, try to guess.
247     */
248    private function guessOriginalRevisionId() {
249        if ( !$this->originalRevisionId ) {
250            if ( $this->revertAfterRevId ) {
251                $this->setOriginalRevision( $this->revertAfterRevId );
252            } elseif ( $this->newestRevertedRevId ) {
253                // Try finding the original revision ID by assuming it's the one before the edit
254                // that is being reverted.
255                $undidRevision = $this->revisionStore->getRevisionById( $this->newestRevertedRevId );
256                if ( $undidRevision ) {
257                    $originalRevision = $this->revisionStore->getPreviousRevision( $undidRevision );
258                    if ( $originalRevision ) {
259                        $this->setOriginalRevision( $originalRevision );
260                    }
261                }
262            }
263        }
264
265        // Make sure original revision's content is the same as
266        // the new content and save the original revision ID.
267        if ( $this->getOriginalRevision() &&
268            !$this->getOriginalRevision()->hasSameContent( $this->revisionRecord )
269        ) {
270            $this->setOriginalRevision( false );
271        }
272    }
273
274    /**
275     * Returns the revision that is being repeated or restored.
276     * Returns null if not set for this edit.
277     *
278     * @return RevisionRecord|null
279     */
280    private function getOriginalRevision(): ?RevisionRecord {
281        if ( $this->originalRevision ) {
282            return $this->originalRevision;
283        }
284        if ( !$this->originalRevisionId ) {
285            return null;
286        }
287
288        $this->originalRevision = $this->revisionStore->getRevisionById( $this->originalRevisionId );
289        return $this->originalRevision;
290    }
291
292    /**
293     * Whether the edit was an exact revert, i.e. the contents of the revert
294     * revision and restored revision match
295     *
296     * @return bool
297     */
298    private function isExactRevert(): bool {
299        if ( $this->isNew || $this->oldestRevertedRevId === null ) {
300            return false;
301        }
302
303        $originalRevision = $this->getOriginalRevision();
304        if ( !$originalRevision ) {
305            // we can't find the original revision for some reason, better return false
306            return false;
307        }
308
309        return $this->revisionRecord->hasSameContent( $originalRevision );
310    }
311
312    /**
313     * An edit is a null edit if the original revision is equal to the parent revision.
314     *
315     * @return bool
316     */
317    private function isNullEdit(): bool {
318        if ( $this->isNew ) {
319            return false;
320        }
321
322        return $this->getOriginalRevision() &&
323            $this->originalRevisionId === $this->revisionRecord->getParentId();
324    }
325
326    /**
327     * Returns an array of revert-related tags that will be applied automatically to this edit.
328     *
329     * @return string[]
330     */
331    private function getRevertTags(): array {
332        if ( isset( self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod] ) ) {
333            $revertTag = self::REVERT_METHOD_TO_CHANGE_TAG[$this->revertMethod];
334            if ( in_array( $revertTag, $this->softwareTags ) ) {
335                return [ $revertTag ];
336            }
337        }
338        return [];
339    }
340}