Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
SaveHooks
100.00% covered (success)
100.00%
45 / 45
100.00% covered (success)
100.00%
5 / 5
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 onRecentChange_save
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onRevisionFromEditComplete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onManualLogEntryBeforePublish
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 onArticleRevisionVisibilitySet
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2namespace MediaWiki\Extension\Hashtags;
3
4use IDBAccessObject;
5use MediaWiki\ChangeTags\ChangeTagsStore;
6use MediaWiki\CommentFormatter\CommentParserFactory;
7use MediaWiki\Hook\ArticleRevisionVisibilitySetHook;
8use MediaWiki\Hook\ManualLogEntryBeforePublishHook;
9use MediaWiki\Hook\RecentChange_saveHook;
10use MediaWiki\Page\Hook\RevisionFromEditCompleteHook;
11use MediaWiki\Revision\RevisionLookup;
12use MediaWiki\Revision\RevisionRecord;
13use Wikimedia\Rdbms\IConnectionProvider;
14
15class SaveHooks implements
16    RevisionFromEditCompleteHook,
17    ManualLogEntryBeforePublishHook,
18    ArticleRevisionVisibilitySetHook,
19    RecentChange_saveHook
20{
21
22    private HashtagCommentParserFactory $cpFactory;
23    private ChangeTagsStore $changeTagsStore;
24    private IConnectionProvider $dbProvider;
25    private RevisionLookup $revisionLookup;
26    private TagCollector $tagCollector;
27
28    public function __construct(
29        CommentParserFactory $commentParserFactory,
30        ChangeTagsStore $changeTagsStore,
31        IConnectionProvider $dbProvider,
32        RevisionLookup $revisionLookup,
33        TagCollector $tagCollector
34    ) {
35        $this->cpFactory = $commentParserFactory;
36        $this->changeTagsStore = $changeTagsStore;
37        $this->dbProvider = $dbProvider;
38        $this->revisionLookup = $revisionLookup;
39        $this->tagCollector = $tagCollector;
40    }
41
42    // Previously we used onRecentChange_save, however onRevisionFromEditComplete
43    // combined with onManualLogEntryBeforePublish seems to cover more cases.
44
45    /**
46     * @inheritDoc
47     */
48    public function onRecentChange_save( $rc ) {
49        // This is unideal as it misses revisions with RC_SUPPRESS
50        // However is needed to work around T379152
51        $comment = $rc->getAttribute( "rc_comment" );
52        $newTags = $this->tagCollector->getTagsSeen( $this->cpFactory, $comment );
53        $rc->addTags( $newTags );
54    }
55
56    /**
57     * @note In certain cases, this hook ignores the $tags parameter. Most of those
58     *  cases are covered by onManualLogEntryBeforePublish.
59     * @inheritDoc
60     */
61    public function onRevisionFromEditComplete( $wikiPage, $rev, $originalRevId, $user, &$tags ) {
62        // FIXME, this hasn't been tested with i18n-ized edit summaries, and it is a bit
63        // unclear how they work.
64        $comment = $rev->getComment()->text;
65        $newTags = $this->tagCollector->getTagsSeen( $this->cpFactory, $comment );
66        $tags = array_merge( $tags, $newTags );
67    }
68
69    /**
70     * @inheritDoc
71     * @note This does not cover restricted logs or log entries
72     *  not published to normal RC feed or irc RC feed
73     */
74    public function onManualLogEntryBeforePublish( $logEntry ): void {
75        $comment = $logEntry->getComment();
76        $newTags = $this->tagCollector->getTagsSeen( $this->cpFactory, $comment );
77        // This will also add tags to the associated revision,
78        // including some cases that onRevisionFromEditComplete
79        // does not cover.
80        $logEntry->addTags( $newTags );
81    }
82
83    // FIXME: We need a solution for log entries being revdeleted/undeleted.
84
85    /**
86     * @inheritDoc
87     */
88    public function onArticleRevisionVisibilitySet( $title, $ids, $visibilityChangeMap ) {
89        foreach ( $visibilityChangeMap as $id => $change ) {
90            if (
91                ( $change['oldBits'] & RevisionRecord::DELETED_COMMENT ) === 0 &&
92                ( $change['newBits'] & RevisionRecord::DELETED_COMMENT ) !== 0
93            ) {
94                // We are deleting this comment
95                $rcId = null;
96                $existingTags = $this->changeTagsStore->getTagsWithData(
97                    $this->dbProvider->getPrimaryDatabase(),
98                    $rcId, /* rc id */
99                    $id /* rev id */
100                );
101                $tagsToRemove = array_filter(
102                    $existingTags,
103                    static function ( $key ) {
104                        return substr( $key, 0,
105                            strlen( HashtagCommentParser::HASHTAG_PREFIX )
106                        ) === HashtagCommentParser::HASHTAG_PREFIX;
107                    },
108                    ARRAY_FILTER_USE_KEY
109                );
110                $this->changeTagsStore->updateTags( [], array_keys( $tagsToRemove ), $rcId, $id );
111            } elseif (
112                ( $change['oldBits'] & RevisionRecord::DELETED_COMMENT ) !== 0 &&
113                ( $change['newBits'] & RevisionRecord::DELETED_COMMENT ) === 0
114            ) {
115                // We are undeleting this comment.
116                $rev = $this->revisionLookup->getRevisionById( $id, IDBAccessObject::READ_LATEST );
117                if ( $rev === null ) {
118                    // Maybe we should just log this and silently ignore (?)
119                    throw new \RuntimeException( "un-revdel on revision $id that does not exist" );
120                }
121                $comment = $rev->getComment();
122                if ( $comment === null ) {
123                    // It returns null if access control fails.
124                    // This generally should not happen.
125                    wfWarn( "Recently unrevdeleted comment for $id cannot be accessed" );
126                    return;
127                }
128                $newTags = $this->tagCollector->getTagsSeen( $this->cpFactory, $comment->text );
129                $rcId = null;
130                $this->changeTagsStore->updateTags( $newTags, [], $rcId, $id );
131            }
132        }
133    }
134}