Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.67% covered (success)
93.67%
74 / 79
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
CategoryMembershipChange
93.67% covered (success)
93.67%
74 / 79
50.00% covered (danger)
50.00%
5 / 10
27.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 overrideNewForCategorizationCallback
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 checkTemplateLinks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 triggerCategoryAddedNotification
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 triggerCategoryRemovedNotification
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createRecentChangesEntry
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 notifyCategorization
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
8
 getUser
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 getChangeMessageText
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getPreviousRevisionTimestamp
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2
3use MediaWiki\Cache\BacklinkCache;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Revision\RevisionRecord;
6use MediaWiki\Title\Title;
7use MediaWiki\User\User;
8use MediaWiki\User\UserIdentity;
9
10/**
11 * Helper class for category membership changes
12 *
13 * This program is free software; you can redistribute it and/or modify
14 * it under the terms of the GNU General Public License as published by
15 * the Free Software Foundation; either version 2 of the License, or
16 * (at your option) any later version.
17 *
18 * This program is distributed in the hope that it will be useful,
19 * but WITHOUT ANY WARRANTY; without even the implied warranty of
20 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
21 * GNU General Public License for more details.
22 *
23 * You should have received a copy of the GNU General Public License along
24 * with this program; if not, write to the Free Software Foundation, Inc.,
25 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
26 * http://www.gnu.org/copyleft/gpl.html
27 *
28 * @file
29 * @author Kai Nissen
30 * @author Addshore
31 * @since 1.27
32 */
33
34class CategoryMembershipChange {
35
36    private const CATEGORY_ADDITION = 1;
37    private const CATEGORY_REMOVAL = -1;
38
39    /**
40     * @var string Current timestamp, set during CategoryMembershipChange::__construct()
41     */
42    private $timestamp;
43
44    /**
45     * @var Title Title instance of the categorized page
46     */
47    private $pageTitle;
48
49    /**
50     * @var RevisionRecord|null Latest revision of the categorized page
51     */
52    private $revision;
53
54    /**
55     * @var int
56     * Number of pages this WikiPage is embedded by
57     * Set by CategoryMembershipChange::checkTemplateLinks()
58     */
59    private $numTemplateLinks = 0;
60
61    /**
62     * @var callable|null
63     */
64    private $newForCategorizationCallback = null;
65
66    /** @var BacklinkCache */
67    private $backlinkCache;
68
69    /**
70     * @param Title $pageTitle Title instance of the categorized page
71     * @param BacklinkCache $backlinkCache
72     * @param RevisionRecord|null $revision Latest revision of the categorized page.
73     */
74    public function __construct(
75        Title $pageTitle, BacklinkCache $backlinkCache, RevisionRecord $revision = null
76    ) {
77        $this->pageTitle = $pageTitle;
78        $this->revision = $revision;
79        if ( $revision === null ) {
80            $this->timestamp = wfTimestampNow();
81        } else {
82            $this->timestamp = $revision->getTimestamp();
83        }
84        $this->newForCategorizationCallback = [ RecentChange::class, 'newForCategorization' ];
85        $this->backlinkCache = $backlinkCache;
86    }
87
88    /**
89     * Overrides the default new for categorization callback
90     * This is intended for use while testing and will fail if MW_PHPUNIT_TEST is not defined.
91     *
92     * @param callable $callback
93     * @see RecentChange::newForCategorization for callback signiture
94     */
95    public function overrideNewForCategorizationCallback( callable $callback ) {
96        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
97            throw new LogicException( 'Cannot override newForCategorization callback in operation.' );
98        }
99        $this->newForCategorizationCallback = $callback;
100    }
101
102    /**
103     * Determines the number of template links for recursive link updates
104     */
105    public function checkTemplateLinks() {
106        $this->numTemplateLinks = $this->backlinkCache->getNumLinks( 'templatelinks' );
107    }
108
109    /**
110     * Create a recentchanges entry for category additions
111     *
112     * @param Title $categoryTitle
113     */
114    public function triggerCategoryAddedNotification( Title $categoryTitle ) {
115        $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_ADDITION );
116    }
117
118    /**
119     * Create a recentchanges entry for category removals
120     *
121     * @param Title $categoryTitle
122     */
123    public function triggerCategoryRemovedNotification( Title $categoryTitle ) {
124        $this->createRecentChangesEntry( $categoryTitle, self::CATEGORY_REMOVAL );
125    }
126
127    /**
128     * Create a recentchanges entry using RecentChange::notifyCategorization()
129     *
130     * @param Title $categoryTitle
131     * @param int $type
132     */
133    private function createRecentChangesEntry( Title $categoryTitle, $type ) {
134        $this->notifyCategorization(
135            $this->timestamp,
136            $categoryTitle,
137            $this->getUser(),
138            $this->getChangeMessageText(
139                $type,
140                $this->pageTitle->getPrefixedText(),
141                $this->numTemplateLinks
142            ),
143            $this->pageTitle,
144            $this->getPreviousRevisionTimestamp(),
145            $this->revision,
146            $type === self::CATEGORY_ADDITION
147        );
148    }
149
150    /**
151     * @param string $timestamp Timestamp of the recent change to occur in TS_MW format
152     * @param Title $categoryTitle Title of the category a page is being added to or removed from
153     * @param UserIdentity|null $user User object of the user that made the change
154     * @param string $comment Change summary
155     * @param Title $pageTitle Title of the page that is being added or removed
156     * @param string $lastTimestamp Parent revision timestamp of this change in TS_MW format
157     * @param RevisionRecord|null $revision
158     * @param bool $added true, if the category was added, false for removed
159     */
160    private function notifyCategorization(
161        $timestamp,
162        Title $categoryTitle,
163        ?UserIdentity $user,
164        $comment,
165        Title $pageTitle,
166        $lastTimestamp,
167        $revision,
168        $added
169    ) {
170        $deleted = $revision ? $revision->getVisibility() & RevisionRecord::SUPPRESSED_USER : 0;
171        $newRevId = $revision ? $revision->getId() : 0;
172
173        /**
174         * T109700 - Default bot flag to true when there is no corresponding RC entry
175         * This means all changes caused by parser functions & Lua on reparse are marked as bot
176         * Also in the case no RC entry could be found due to replica DB lag
177         */
178        $bot = 1;
179        $lastRevId = 0;
180        $ip = '';
181
182        # If no revision is given, the change was probably triggered by parser functions
183        if ( $revision !== null ) {
184            $revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
185
186            $correspondingRc = $revisionStore->getRecentChange( $this->revision ) ??
187                $revisionStore->getRecentChange( $this->revision, IDBAccessObject::READ_LATEST );
188            if ( $correspondingRc !== null ) {
189                $bot = $correspondingRc->getAttribute( 'rc_bot' ) ?: 0;
190                $ip = $correspondingRc->getAttribute( 'rc_ip' ) ?: '';
191                $lastRevId = $correspondingRc->getAttribute( 'rc_last_oldid' ) ?: 0;
192            }
193        }
194
195        /** @var RecentChange $rc */
196        $rc = ( $this->newForCategorizationCallback )(
197            $timestamp,
198            $categoryTitle,
199            $user,
200            $comment,
201            $pageTitle,
202            $lastRevId,
203            $newRevId,
204            $lastTimestamp,
205            $bot,
206            $ip,
207            $deleted,
208            $added
209        );
210        $rc->save();
211    }
212
213    /**
214     * Get the user associated with this change.
215     *
216     * If there is no revision associated with the change and thus no editing user
217     * fallback to a default.
218     *
219     * False will be returned if the user name specified in the
220     * 'autochange-username' message is invalid.
221     *
222     * @return UserIdentity|null
223     */
224    private function getUser(): ?UserIdentity {
225        if ( $this->revision ) {
226            $user = $this->revision->getUser( RevisionRecord::RAW );
227            if ( $user ) {
228                return $user;
229            }
230        }
231
232        $username = wfMessage( 'autochange-username' )->inContentLanguage()->text();
233
234        $user = User::newSystemUser( $username );
235        if ( $user && !$user->isRegistered() ) {
236            $user->addToDatabase();
237        }
238
239        return $user ?: null;
240    }
241
242    /**
243     * Returns the change message according to the type of category membership change
244     *
245     * The message keys created in this method may be one of:
246     * - recentchanges-page-added-to-category
247     * - recentchanges-page-added-to-category-bundled
248     * - recentchanges-page-removed-from-category
249     * - recentchanges-page-removed-from-category-bundled
250     *
251     * @param int $type may be CategoryMembershipChange::CATEGORY_ADDITION
252     * or CategoryMembershipChange::CATEGORY_REMOVAL
253     * @param string $prefixedText result of Title::->getPrefixedText()
254     * @param int $numTemplateLinks
255     *
256     * @return string
257     */
258    private function getChangeMessageText( $type, $prefixedText, $numTemplateLinks ) {
259        $array = [
260            self::CATEGORY_ADDITION => 'recentchanges-page-added-to-category',
261            self::CATEGORY_REMOVAL => 'recentchanges-page-removed-from-category',
262        ];
263
264        $msgKey = $array[$type];
265
266        if ( intval( $numTemplateLinks ) > 0 ) {
267            $msgKey .= '-bundled';
268        }
269
270        return wfMessage( $msgKey, $prefixedText )->inContentLanguage()->text();
271    }
272
273    /**
274     * Returns the timestamp of the page's previous revision or null if the latest revision
275     * does not refer to a parent revision
276     *
277     * @return null|string
278     */
279    private function getPreviousRevisionTimestamp() {
280        $rl = MediaWikiServices::getInstance()->getRevisionLookup();
281        $latestRev = $rl->getRevisionByTitle( $this->pageTitle );
282        if ( $latestRev ) {
283            $previousRev = $rl->getPreviousRevision( $latestRev );
284            if ( $previousRev ) {
285                return $previousRev->getTimestamp();
286            }
287        }
288        return null;
289    }
290
291}