Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChangesListFormatter
0.00% covered (danger)
0.00%
0 / 102
0.00% covered (danger)
0.00%
0 / 8
702
0.00% covered (danger)
0.00%
0 / 1
 getHistoryType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 format
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 getEditSummary
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getTitleLink
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
42
 getTimestampLink
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getLogTextLinks
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 getFlags
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 formatFlags
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace Flow\Formatter;
4
5use ChangesList;
6use Flow\Conversion\Utils;
7use Flow\Data\Listener\RecentChangesListener;
8use Flow\Exception\FlowException;
9use Flow\Exception\PermissionException;
10use Flow\Model\Anchor;
11use Flow\Model\UUID;
12use IContextSource;
13use MediaWiki\MediaWikiServices;
14
15class ChangesListFormatter extends AbstractFormatter {
16    protected function getHistoryType() {
17        return 'recentchanges';
18    }
19
20    /**
21     * @param RecentChangesRow $row
22     * @param IContextSource $ctx
23     * @param bool $linkOnly
24     * @return string|false Output line, or false on failure
25     * @throws FlowException
26     */
27    public function format( RecentChangesRow $row, IContextSource $ctx, $linkOnly = false ) {
28        $this->serializer->setIncludeHistoryProperties( true );
29        $this->serializer->setIncludeContent( false );
30
31        $data = $this->serializer->formatApi( $row, $ctx, 'recentchanges' );
32        if ( !$data ) {
33            return false;
34        }
35
36        if ( $linkOnly ) {
37            return $this->getTitleLink( $data, $row, $ctx );
38        }
39
40        // The ' . . ' text between elements
41        $separator = $this->changeSeparator();
42
43        $links = [];
44        $links[] = $this->getDiffAnchor( $data['links'], $ctx );
45        $links[] = $this->getHistAnchor( $data['links'], $ctx );
46
47        $description = $this->formatDescription( $data, $ctx );
48
49        $flags = $this->getFlags( $row, $ctx );
50
51        return $this->formatAnchorsAsPipeList( $links, $ctx ) .
52            $separator .
53            $this->formatFlags( $flags ) .
54            $this->getTitleLink( $data, $row, $ctx ) .
55            $ctx->msg( 'semicolon-separator' )->escaped() .
56            ' ' .
57            $this->formatTimestamp( $data, 'time' ) .
58            $separator .
59            ChangesList::showCharacterDifference(
60                $data['size']['old'],
61                $data['size']['new'],
62                $ctx
63            ) .
64            ( Utils::htmlToPlaintext( $description ) ? $separator . $description : '' ) .
65            $this->getEditSummary( $row, $ctx, $data );
66    }
67
68    /**
69     * @param RecentChangesRow $row
70     * @param IContextSource $ctx
71     * @param array $data
72     * @return string
73     */
74    public function getEditSummary( RecentChangesRow $row, IContextSource $ctx, array $data ) {
75        // Build description message, piggybacking on history i18n
76        $changeType = $data['changeType'];
77        $actions = $this->permissions->getActions();
78
79        $key = $actions->getValue( $changeType, 'history', 'i18n-message' );
80        // Find specialized message for summary
81        // i18n messages: flow-rev-message-new-post-recentchanges-summary,
82        // flow-rev-message-edit-post-recentchanges-summary
83        $msg = $ctx->msg( $key . '-' . $this->getHistoryType() . '-summary' );
84        if ( !$msg->exists() ) {
85            // No summary for this action
86            return '';
87        }
88
89        $msg = $msg->params( $this->getDescriptionParams( $data, $actions, $changeType ) );
90
91        // Below code is inspired by MediaWiki\CommentFormatter\CommentParser::doSectionLinks
92        $prefix = $ctx->msg( 'autocomment-prefix' )->inContentLanguage()->escaped();
93        $link = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink(
94            $row->workflow->getOwnerTitle(),
95            $ctx->getLanguage()->getArrow( 'backwards' ),
96            [],
97            []
98        );
99        $summary = '<span class="autocomment">' . $msg->text() . '</span>';
100
101        // '(' + '' + '←' + summary + ')'
102        $text = MediaWikiServices::getInstance()->getCommentFormatter()
103            ->formatBlock( $prefix . $link . $summary );
104
105        // MediaWiki\CommentFormatter\CommentFormatter::formatBlock escaped everything, but what we built was safe
106        // and should not be escaped so let's go back to decoded entities
107        return htmlspecialchars_decode( $text );
108    }
109
110    /**
111     * This overrides the default title link to include highlights for the posts
112     * that have not yet been seen.
113     *
114     * @param array $data
115     * @param FormatterRow $row
116     * @param IContextSource $ctx
117     * @return string
118     */
119    protected function getTitleLink( array $data, FormatterRow $row, IContextSource $ctx ) {
120        if ( !$row instanceof RecentChangesRow ) {
121            // actually, this should be typehint, but can't because this needs
122            // to match the parent's more generic typehint
123            return parent::getTitleLink( $data, $row, $ctx );
124        }
125
126        if ( !isset( $data['links']['topic'] ) || !$data['links']['topic'] instanceof Anchor ) {
127            // no valid title anchor (probably header entry)
128            return parent::getTitleLink( $data, $row, $ctx );
129        }
130
131        $watched = $row->recentChange->getAttribute( 'wl_notificationtimestamp' );
132        if ( is_bool( $watched ) ) {
133            // RC & watchlist share most code; the latter is unaware of when
134            // something was watched though, so we'll ignore that here
135            return parent::getTitleLink( $data, $row, $ctx );
136        }
137
138        if ( $watched === null ) {
139            // there is no data for unread posts - they've all been seen
140            return parent::getTitleLink( $data, $row, $ctx );
141        }
142
143        // get comparison UUID corresponding to this last watched timestamp
144        $uuid = UUID::getComparisonUUID( $watched );
145
146        // add highlight details to anchor
147        /** @var Anchor $anchor */
148        $anchor = clone $data['links']['topic'];
149        $anchor->query['fromnotif'] = '1';
150        $anchor->fragment = '#flow-post-' . $uuid->getAlphadecimal();
151        $data['links']['topic'] = $anchor;
152
153        // now pass it on to parent with the new, updated, link ;)
154        return parent::getTitleLink( $data, $row, $ctx );
155    }
156
157    /**
158     * @param RecentChangesRow $row
159     * @param IContextSource $ctx
160     * @return string
161     * @throws PermissionException
162     */
163    public function getTimestampLink( $row, $ctx ) {
164        $this->serializer->setIncludeHistoryProperties( true );
165        $this->serializer->setIncludeContent( false );
166
167        $data = $this->serializer->formatApi( $row, $ctx, 'recentchanges' );
168        if ( $data === false ) {
169            throw new PermissionException( 'Insufficient permissions for ' . $row->revision->getRevisionId()->getAlphadecimal() );
170        }
171
172        return $this->formatTimestamp( $data, 'time' );
173    }
174
175    /**
176     * @param RecentChangesRow $row
177     * @param IContextSource $ctx
178     * @param \RCCacheEntry[] $block
179     * @param array $links
180     * @return array|false Links array, or false on failure
181     * @throws FlowException
182     * @throws \Flow\Exception\InvalidInputException
183     */
184    public function getLogTextLinks( RecentChangesRow $row, IContextSource $ctx, array $block, array $links = [] ) {
185        $data = $this->serializer->formatApi( $row, $ctx, 'recentchanges' );
186        if ( !$data ) {
187            return false;
188        }
189
190        // Find the last (oldest) row in $block that is a Flow row. Note that there can be non-Flow
191        // things in $block (T228290).
192        $flowRows = array_filter( $block, static function ( $blockRow ) {
193            $source = $blockRow->getAttribute( 'rc_source' );
194            return $source === RecentChangesListener::SRC_FLOW ||
195                ( $source === null && $blockRow->getAttribute( 'rc_type' ) === RC_FLOW );
196        } );
197        $oldestRow = end( $flowRows ) ?? $row->recentChange;
198
199        $old = unserialize( $oldestRow->getAttribute( 'rc_params' ) );
200        $oldId = $old ? UUID::create( $old['flow-workflow-change']['revision'] ) : $row->revision->getRevisionId();
201
202        if ( isset( $data['links']['topic'] ) ) {
203            // add highlight details to anchor
204            // FIXME: This doesn't work well if the different rows in $block are for different topics
205            /** @var Anchor $anchor */
206            $anchor = clone $data['links']['topic'];
207            $anchor->query['fromnotif'] = '1';
208            $anchor->fragment = '#flow-post-' . $oldId->getAlphadecimal();
209        } elseif ( isset( $data['links']['workflow'] ) ) {
210            $anchor = $data['links']['workflow'];
211        } else {
212            // this will be caught and logged by the RC hook, it will not fatal the page.
213            throw new FlowException( "No anchor available for revision $oldId" );
214        }
215
216        $changes = count( $block );
217        // link text: "n changes"
218        $text = $ctx->msg( 'nchanges' )->numParams( $changes )->escaped();
219
220        // override total changes link
221        $links['total-changes'] = $anchor->toHtml( $text );
222
223        return $links;
224    }
225
226    /**
227     * @param RecentChangesRow $row
228     * @param IContextSource $ctx
229     * @return array
230     */
231    public function getFlags( RecentChangesRow $row, IContextSource $ctx ) {
232        return [
233            'newpage' => $row->isFirstReply && $row->revision->isFirstRevision(),
234            'minor' => false,
235            'unpatrolled' => ChangesList::isUnpatrolled( $row->recentChange, $ctx->getUser() ),
236            'bot' => false,
237        ];
238    }
239
240    /**
241     * @param array $flags
242     * @return string
243     */
244    protected function formatFlags( $flags ) {
245        $flagKeys = array_keys( array_filter( $flags ) );
246        if ( $flagKeys ) {
247            $formattedFlags = array_map( [ ChangesList::class, 'flag' ], $flagKeys );
248            return implode( ' ', $formattedFlags ) . ' ';
249        }
250        return '';
251    }
252}