Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.45% covered (danger)
18.45%
62 / 336
3.12% covered (danger)
3.12%
1 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateHelper
18.45% covered (danger)
18.45%
62 / 336
3.12% covered (danger)
3.12%
1 / 32
5632.93
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateFilenames
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
12.91
 getTemplate
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 compile
90.70% covered (success)
90.70%
39 / 43
0.00% covered (danger)
0.00%
0 / 1
2.00
 processTemplate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 uuidTimestamp
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 timestampHelper
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 timestamp
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 htmlHelper
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 block
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 eachPost
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 post
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 historyTimestamp
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 historyDescription
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 showCharacterDifference
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 progressiveEnhancement
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 oouify
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 l10n
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 l10nParse
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 l10nParseFlowTermsOfUse
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSaveOrPublishMessage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 diffRevision
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 diffUndo
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 moderationAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 concat
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 ifAnonymous
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 addReturnTo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 linkWithReturnTo
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 escapeContent
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 ifCond
54.55% covered (warning)
54.55%
12 / 22
0.00% covered (danger)
0.00%
0 / 1
25.52
 tooltip
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 enablePatrollingLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Flow;
4
5use Closure;
6use Flow\Exception\FlowException;
7use Flow\Model\UUID;
8use LightnCandy\LightnCandy;
9use LightnCandy\SafeString;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\Diff\DifferenceEngine;
12use MediaWiki\Html\Html;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\RecentChanges\ChangesList;
15use MediaWiki\Title\Title;
16use MediaWiki\Utils\MWTimestamp;
17use OOUI\IconWidget;
18
19class TemplateHelper {
20
21    /**
22     * @var string
23     */
24    protected $templateDir;
25
26    /**
27     * @var callable[]
28     */
29    protected $renderers;
30
31    /**
32     * @var bool Always compile template files
33     */
34    protected $forceRecompile = false;
35
36    /**
37     * @param string $templateDir
38     * @param bool $forceRecompile
39     */
40    public function __construct( $templateDir, $forceRecompile = false ) {
41        $this->templateDir = $templateDir;
42        $this->forceRecompile = $forceRecompile;
43    }
44
45    /**
46     * Constructs the location of the source handlebars template
47     * and the compiled php code that goes with it.
48     *
49     * @param string $templateName
50     *
51     * @return string[]
52     * @throws FlowException Disallows upwards directory traversal via $templateName
53     */
54    public function getTemplateFilenames( $templateName ) {
55        // Prevent upwards directory traversal using same methods as Title::secureAndSplit,
56        // which is implemented in MediaWikiTitleCodec::splitTitleString.
57        if (
58            str_contains( $templateName, '.' ) &&
59            (
60                $templateName === '.' || $templateName === '..' ||
61                str_starts_with( $templateName, './' ) ||
62                str_starts_with( $templateName, '../' ) ||
63                str_contains( $templateName, '/./' ) ||
64                str_contains( $templateName, '/../' ) ||
65                str_ends_with( $templateName, '/.' ) ||
66                str_ends_with( $templateName, '/..' )
67            )
68        ) {
69            throw new FlowException( "Malformed \$templateName: $templateName" );
70        }
71
72        return [
73            'template' => "{$this->templateDir}/{$templateName}.handlebars",
74            'compiled' => "{$this->templateDir}/compiled/{$templateName}.handlebars.php",
75        ];
76    }
77
78    /**
79     * Returns a given template function if found, otherwise throws an exception.
80     *
81     * @param string $templateName
82     *
83     * @return callable
84     * @throws FlowException
85     * @throws \Exception
86     */
87    public function getTemplate( $templateName ) {
88        if ( isset( $this->renderers[$templateName] ) ) {
89            return $this->renderers[$templateName];
90        }
91
92        $filenames = $this->getTemplateFilenames( $templateName );
93
94        if ( $this->forceRecompile ) {
95            if ( !file_exists( $filenames['template'] ) ) {
96                throw new FlowException( "Could not locate template: {$filenames['template']}" );
97            }
98
99            $code = self::compile( file_get_contents( $filenames['template'] ), $this->templateDir );
100
101            if ( !$code ) {
102                throw new FlowException( "Failed to compile template '$templateName'." );
103            }
104            $success = file_put_contents( $filenames['compiled'], '<?php ' . $code );
105
106            // failed to recompile template (OS permissions?); unless the
107            // content hasn't changes, throw an exception!
108            if ( !$success && file_get_contents( $filenames['compiled'] ) !== $code ) {
109                throw new FlowException( "Failed to save updated compiled template '$templateName'" );
110            }
111        }
112
113        /** @var callable $renderer */
114        $renderer = require $filenames['compiled'];
115        $this->renderers[$templateName] = static function ( $args, array $scopes = [] ) use ( $renderer ) {
116            return $renderer( $args, $scopes );
117        };
118        return $this->renderers[$templateName];
119    }
120
121    /**
122     * @param string $code Handlebars code
123     * @param string $templateDir Directory templates are stored in
124     *
125     * @return string PHP code
126     * @suppress PhanTypeMismatchArgument
127     */
128    public static function compile( $code, $templateDir ) {
129        return LightnCandy::compile(
130            $code,
131            [
132                'flags' => LightnCandy::FLAG_ERROR_EXCEPTION
133                    | LightnCandy::FLAG_EXTHELPER
134                    | LightnCandy::FLAG_SPVARS
135                    | LightnCandy::FLAG_HANDLEBARS
136                    | LightnCandy::FLAG_RUNTIMEPARTIAL,
137                'partialresolver' => static function ( $context, $name ) use ( $templateDir ) {
138                    $filename = "$templateDir/$name.partial.handlebars";
139                    if ( file_exists( $filename ) ) {
140                        return file_get_contents( $filename );
141                    }
142                    return null;
143                },
144                'helpers' => [
145                    'l10n' => 'Flow\TemplateHelper::l10n',
146                    'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
147                    'timestamp' => 'Flow\TemplateHelper::timestampHelper',
148                    'html' => 'Flow\TemplateHelper::htmlHelper',
149                    'block' => 'Flow\TemplateHelper::block',
150                    'post' => 'Flow\TemplateHelper::post',
151                    'historyTimestamp' => 'Flow\TemplateHelper::historyTimestamp',
152                    'historyDescription' => 'Flow\TemplateHelper::historyDescription',
153                    'showCharacterDifference' => 'Flow\TemplateHelper::showCharacterDifference',
154                    'l10nParse' => 'Flow\TemplateHelper::l10nParse',
155                    'l10nParseFlowTermsOfUse' => 'Flow\TemplateHelper::l10nParseFlowTermsOfUse',
156                    'diffRevision' => 'Flow\TemplateHelper::diffRevision',
157                    'diffUndo' => 'Flow\TemplateHelper::diffUndo',
158                    'moderationAction' => 'Flow\TemplateHelper::moderationAction',
159                    'concat' => 'Flow\TemplateHelper::concat',
160                    'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
161                    'escapeContent' => 'Flow\TemplateHelper::escapeContent',
162                    'enablePatrollingLink' => 'Flow\TemplateHelper::enablePatrollingLink',
163                    'oouify' => 'Flow\TemplateHelper::oouify',
164                    'getSaveOrPublishMessage' => 'Flow\TemplateHelper::getSaveOrPublishMessage',
165                    'eachPost' => 'Flow\TemplateHelper::eachPost',
166                    'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
167                    'ifCond' => 'Flow\TemplateHelper::ifCond',
168                    'tooltip' => 'Flow\TemplateHelper::tooltip',
169                    'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
170                ],
171            ]
172        );
173    }
174
175    /**
176     * Returns HTML for a given template by calling the template function with the given args.
177     *
178     * @param string $templateName
179     * @param array $args
180     * @param array $scopes
181     *
182     * @return string
183     */
184    public static function processTemplate( $templateName, array $args, array $scopes = [] ) {
185        // Undesirable, but lightncandy helpers have to be static methods
186        /** @var TemplateHelper $lightncandy */
187        $lightncandy = Container::get( 'lightncandy' );
188        $template = $lightncandy->getTemplate( $templateName );
189        // @todo ugly hack...remove someday.  Requires switching to newest version
190        // of lightncandy which supports recursive partial templates.
191        if ( !array_key_exists( 'rootBlock', $args ) ) {
192            $args['rootBlock'] = $args;
193        }
194        return $template( $args, $scopes );
195    }
196
197    // Helpers
198
199    /**
200     * Generates a timestamp using the UUID, then calls the timestamp helper with it.
201     *
202     * @param string $uuid
203     *
204     * @return SafeString|null
205     */
206    public static function uuidTimestamp( $uuid ) {
207        $obj = UUID::create( $uuid );
208        if ( !$obj ) {
209            return null;
210        }
211
212        // timestamp helper expects ms timestamp
213        $timestamp = (int)$obj->getTimestampObj()->getTimestamp() * 1000;
214        return self::timestamp( $timestamp );
215    }
216
217    /**
218     * @param string $timestamp
219     *
220     * @return SafeString|null
221     */
222    public static function timestampHelper( $timestamp ) {
223        return self::timestamp( (int)$timestamp );
224    }
225
226    /**
227     * @param int $timestamp milliseconds since the unix epoch
228     *
229     * @return SafeString|null
230     */
231    protected static function timestamp( $timestamp ) {
232        $lang = RequestContext::getMain()->getLanguage();
233
234        if ( !$timestamp ) {
235            return null;
236        }
237
238        // source timestamps are in ms
239        $timestamp /= 1000;
240        $ts = new MWTimestamp( $timestamp );
241
242        return new SafeString( self::processTemplate(
243            'timestamp',
244            [
245                'time_iso' => $timestamp,
246                'time_ago' => $lang->getHumanTimestamp( $ts ),
247                'time_readable' => $lang->userTimeAndDate(
248                    $timestamp,
249                    RequestContext::getMain()->getUser()
250                ),
251                'guid' => null, // generated client-side
252            ]
253        ) );
254    }
255
256    /**
257     * @param string $html
258     *
259     * @return SafeString
260     */
261    public static function htmlHelper( $html ) {
262        return new SafeString( $html ?? 'undefined' );
263    }
264
265    /**
266     * @param array $block
267     *
268     * @return SafeString
269     */
270    public static function block( $block ) {
271        $template = "flow_block_" . $block['type'];
272        if ( $block['block-action-template'] ) {
273            $template .= '_' . $block['block-action-template'];
274        }
275        return new SafeString( self::processTemplate(
276            $template,
277            $block
278        ) );
279    }
280
281    /**
282     * @param array $context The 'this' value of the calling context
283     * @param mixed $postIds List of ids (roots)
284     * @param array $options blockhelper specific invocation options
285     *
286     * @return null|string HTML
287     * @throws FlowException When callbacks are not Closure instances
288     */
289    public static function eachPost( array $context, $postIds, array $options ) {
290        /** @var callable $inverse */
291        $inverse = $options['inverse'] ?? null;
292        /** @var callable $fn */
293        $fn = $options['fn'];
294
295        if ( $postIds && !is_array( $postIds ) ) {
296            $postIds = [ $postIds ];
297        } elseif ( $postIds === [] ) {
298            // Failure callback, if any
299            if ( !$inverse ) {
300                return null;
301            }
302            if ( !$inverse instanceof Closure ) {
303                throw new FlowException( 'Invalid inverse callback, expected Closure' );
304            }
305            return $inverse( $options['cx'], [] );
306        } else {
307            return null;
308        }
309
310        if ( !$fn instanceof Closure ) {
311            throw new FlowException( 'Invalid callback, expected Closure' );
312        }
313        $html = [];
314        foreach ( $postIds as $id ) {
315            $revId = $context['posts'][$id][0] ?? '';
316
317            if ( !$revId || !isset( $context['revisions'][$revId] ) ) {
318                throw new FlowException( "Revision not available: $revId. Post ID: $id" );
319            }
320
321            // $fn is always safe return value, it's the inner template content.
322            $html[] = $fn( $context['revisions'][$revId] );
323        }
324
325        // Return the resulting HTML
326        return implode( '', $html );
327    }
328
329    /**
330     * Required to prevent recursion loop rendering nested posts
331     *
332     * @param array $rootBlock
333     * @param array $revision
334     *
335     * @return SafeString
336     */
337    public static function post( $rootBlock, $revision ) {
338        return new SafeString( self::processTemplate( 'flow_post', [
339            'revision' => $revision,
340            'rootBlock' => $rootBlock,
341        ] ) );
342    }
343
344    /**
345     * @param array $revision
346     *
347     * @return SafeString
348     */
349    public static function historyTimestamp( $revision ) {
350        $raw = false;
351        $formattedTime = $revision['dateFormats']['timeAndDate'];
352        $formattedTimeOutput = '';
353        $linkKeys = [ 'header-revision', 'topic-revision', 'post-revision', 'summary-revision' ];
354        foreach ( $linkKeys as $linkKey ) {
355            if ( isset( $revision['links'][$linkKey] ) ) {
356                $link = $revision['links'][$linkKey];
357                $formattedTimeOutput = Html::element(
358                    'a',
359                    [
360                        'href' => $link['url'],
361                        'title' => $link['title'],
362                    ],
363                    $formattedTime
364                );
365                $raw = true;
366                break;
367            }
368        }
369
370        if ( !$raw ) {
371            $formattedTimeOutput = htmlspecialchars( $formattedTime );
372        }
373
374        $class = [ 'mw-changeslist-date' ];
375        if ( $revision['isModeratedNotLocked'] ) {
376            $class[] = 'history-deleted';
377        }
378
379        return new SafeString(
380            '<span class="plainlinks">'
381            . Html::rawElement( 'span', [ 'class' => $class ], $formattedTimeOutput )
382            . '</span>'
383        );
384    }
385
386    /**
387     * @param array $revision
388     *
389     * @return SafeString|null
390     */
391    public static function historyDescription( $revision ) {
392        if ( !isset( $revision['properties']['_key'] ) ) {
393            return null;
394        }
395
396        $i18nKey = $revision['properties']['_key'];
397        unset( $revision['properties']['_key'] );
398
399        // $revision['properties'] contains the params for the i18n message, which are named,
400        // so we need array_values() to strip the names. They are in the correct order because
401        // RevisionFormatter::getDescriptionParams() uses a foreach loop to build this array
402        // from the i18n-params definition in FlowActions.php.
403        // A variety of the i18n history messages contain wikitext and require ->parse().
404        return new SafeString( wfMessage( $i18nKey, array_values( $revision['properties'] ) )->parse() );
405    }
406
407    /**
408     * @param string $old
409     * @param string $new
410     *
411     * @return SafeString
412     */
413    public static function showCharacterDifference( $old, $new ) {
414        $ctx = RequestContext::getMain();
415        return new SafeString( ChangesList::showCharacterDifference( (int)$old, (int)$new, $ctx ) );
416    }
417
418    /**
419     * Creates a special script tag to be processed client-side. This contains extra template HTML, which allows
420     * the front-end to "progressively enhance" the page with more content which isn't needed in a non-JS state.
421     *
422     * @see FlowHandlebars.prototype.progressiveEnhancement in flow-handlebars.js for more details.
423     *
424     * @param array $options
425     *
426     * @return SafeString
427     */
428    public static function progressiveEnhancement( array $options ) {
429        $fn = $options['fn'];
430        $input = $options['hash'];
431        $insertionType = empty( $input['type'] ) ? 'insert' : htmlspecialchars( $input['type'] );
432        $target = empty( $input['target'] ) ? '' : 'data-target="' . htmlspecialchars( $input['target'] ) . '"';
433        $sectionId = empty( $input['id'] ) ? '' : 'id="' . htmlspecialchars( $input['id'] ) . '"';
434
435        return new SafeString(
436            '<script name="handlebars-template-progressive-enhancement"' .
437                ' type="text/x-handlebars-template-progressive-enhancement"' .
438                ' data-type="' . $insertionType . '"' .
439                ' ' . $target .
440                ' ' . $sectionId .
441            '>' .
442                // Replace the nested script tag with a placeholder tag for recursive progressiveEnhancement
443                str_replace( '</script>', '</flowprogressivescript>', $fn() ) .
444            '</script>'
445        );
446    }
447
448    /**
449     * A helper to output OOUI widgets.
450     *
451     * @param array ...$args one or more arguments, i18n key and parameters
452     * @return \OOUI\Widget|null
453     */
454    public static function oouify( ...$args ) {
455        $options = array_pop( $args );
456        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Only when $args is empty
457        $named = $options['hash'];
458
459        $widgetType = $named[ 'type' ];
460        $data = [];
461
462        $classes = [];
463        if ( isset( $named['classes'] ) ) {
464            $classes = explode( ' ', $named[ 'classes' ] );
465        }
466
467        // Push raw arguments
468        $data['args'] = $args;
469        $baseConfig = [
470            // 'infusable' => true,
471            'id' => $named[ 'name' ] ?? null,
472            'classes' => $classes,
473            'data' => $data
474        ];
475        $widget = null;
476        switch ( $widgetType ) {
477            case 'BoardDescriptionWidget':
478                $dataArgs = [
479                    'infusable' => false,
480                    'description' => $args[0],
481                    'editLink' => $args[1]
482                ];
483                $widget = new OOUI\BoardDescriptionWidget( $baseConfig + $dataArgs );
484                break;
485            case 'IconWidget':
486                $dataArgs = [
487                    'icon' => $args[0],
488                ];
489                $widget = new IconWidget( $baseConfig + $dataArgs );
490                break;
491        }
492
493        return $widget;
494    }
495
496    /**
497     * @param array ...$args one or more arguments, i18n key and parameters
498     *
499     * @return string Message output, using the 'text' format
500     */
501    public static function l10n( ...$args ) {
502        $options = array_pop( $args );
503        // @phan-suppress-next-line PhanParamTooFewUnpack
504        return wfMessage( ...$args )->text();
505    }
506
507    /**
508     * @param array ...$args one or more arguments, i18n key and parameters
509     *
510     * @return SafeString HTML
511     */
512    public static function l10nParse( ...$args ) {
513        $options = array_pop( $args );
514        // @phan-suppress-next-line PhanParamTooFewUnpack
515        return new SafeString( wfMessage( ...$args )->parse() );
516    }
517
518    /**
519     * @param string $key
520     *
521     * @return SafeString HTML
522     */
523    public static function l10nParseFlowTermsOfUse( $key ) {
524        $context = RequestContext::getMain();
525        $config = MediaWikiServices::getInstance()->getMainConfig();
526        $messages = Hooks::getTermsOfUseMessagesParsed( $context, $config );
527        return new SafeString( $messages[ $key ] );
528    }
529
530    /**
531     * A helper to output whether a wiki is publish wiki or not
532     *
533     * @param array $options
534     * @return string Translated message string for either 'save' or 'publish'
535     *  version
536     */
537    public static function getSaveOrPublishMessage( array $options ) {
538        global $wgEditSubmitButtonLabelPublish;
539        $named = $options['hash'];
540
541        if ( !$named['save'] || !$named['publish'] ) {
542            throw new FlowException( "Missing an argument. Expected two message keys for 'save' and 'post'" );
543        }
544
545        $msg = $wgEditSubmitButtonLabelPublish ? $named['publish'] : $named['save'];
546
547        return wfMessage( $msg )->text();
548    }
549
550    /**
551     * @param array $data RevisionDiffViewFormatter::formatApi return value
552     *
553     * @return SafeString
554     */
555    public static function diffRevision( $data ) {
556        $differenceEngine = new DifferenceEngine();
557        $notice = '';
558        if ( $data['diff_content'] === '' ) {
559            $notice .= '<div class="mw-diff-empty">' .
560                wfMessage( 'diff-empty' )->parse() .
561                "</div>\n";
562        }
563        // Work around exception in DifferenceEngine::showDiffStyle() (T202454)
564        $out = RequestContext::getMain()->getOutput();
565        $out->addModules( 'mediawiki.diff' );
566        $out->addModuleStyles( 'mediawiki.diff.styles' );
567
568        $renderer = Container::get( 'lightncandy' )->getTemplate( 'flow_revision_diff_header' );
569
570        return new SafeString( $differenceEngine->addHeader(
571            $data['diff_content'],
572            $renderer( [
573                'old' => true,
574                'revision' => $data['old'],
575                'links' => $data['links'],
576            ] ),
577            $renderer( [
578                'new' => true,
579                'revision' => $data['new'],
580                'links' => $data['links'],
581            ] ),
582            // FIXME we should be passing in a multinotice for multi-rev diffs here
583            '',
584            $notice
585        ) );
586    }
587
588    public static function diffUndo( $diffContent ) {
589        $differenceEngine = new DifferenceEngine();
590        $notice = '';
591        if ( $diffContent === '' ) {
592            $notice = '<div class="mw-diff-empty">' .
593                wfMessage( 'diff-empty' )->parse() .
594                "</div>\n";
595        }
596        // Work around exception in DifferenceEngine::showDiffStyle() (T202454)
597        $out = RequestContext::getMain()->getOutput();
598        $out->addModules( 'mediawiki.diff' );
599        $out->addModuleStyles( 'mediawiki.diff.styles' );
600
601        return new SafeString( $differenceEngine->addHeader(
602            $diffContent,
603            wfMessage( 'flow-undo-latest-revision' )->parse(),
604            wfMessage( 'flow-undo-your-text' )->parse(),
605            // FIXME we should be passing in a multinotice for multi-rev diffs here
606            '',
607            $notice
608        ) );
609    }
610
611    /**
612     * @param array $actions
613     * @param string $moderationState
614     *
615     * @return string
616     */
617    public static function moderationAction( $actions, $moderationState ) {
618        return isset( $actions[$moderationState] ) ? $actions[$moderationState]['url'] : '';
619    }
620
621    /**
622     * @param string ...$args Expects one or more strings to join
623     *
624     * @return string all unnamed arguments joined together
625     */
626    public static function concat( ...$args ) {
627        $options = array_pop( $args );
628        return implode( '', $args );
629    }
630
631    /**
632     * Runs a callback when user is anonymous
633     *
634     * @param array $options which must contain fn and inverse key mapping to functions.
635     *
636     * @return mixed result of callback
637     * @throws FlowException Fails when callbacks are not Closure instances
638     */
639    public static function ifAnonymous( $options ) {
640        if ( !RequestContext::getMain()->getUser()->isRegistered() ) {
641            $fn = $options['fn'];
642            if ( !$fn instanceof Closure ) {
643                throw new FlowException( 'Expected callback to be Closuire instance' );
644            }
645        } elseif ( isset( $options['inverse'] ) ) {
646            $fn = $options['inverse'];
647            if ( !$fn instanceof Closure ) {
648                throw new FlowException( 'Expected inverse callback to be Closuire instance' );
649            }
650        } else {
651            return '';
652        }
653
654        return $fn();
655    }
656
657    /**
658     * Adds returnto parameter pointing to current page to existing URL
659     *
660     * @param string $url to modify
661     *
662     * @return string modified url
663     */
664    protected static function addReturnTo( $url ) {
665        $ctx = RequestContext::getMain();
666        $returnTo = $ctx->getTitle();
667        if ( !$returnTo ) {
668            return $url;
669        }
670        // We can't get only the query parameters from
671        $returnToQuery = $ctx->getRequest()->getQueryValues();
672
673        unset( $returnToQuery['title'] );
674
675        $args = [
676            'returnto' => $returnTo->getPrefixedURL(),
677        ];
678        if ( $returnToQuery ) {
679            $args['returntoquery'] = wfArrayToCgi( $returnToQuery );
680        }
681        return wfAppendQuery( $url, wfArrayToCgi( $args ) );
682    }
683
684    /**
685     * Adds returnto parameter pointing to given Title to an existing URL
686     *
687     * @param string $title
688     *
689     * @return string modified url
690     */
691    public static function linkWithReturnTo( $title ) {
692        $title = Title::newFromText( $title );
693        if ( !$title ) {
694            return '';
695        }
696        // FIXME: This should use local url to avoid redirects on mobile. See bug 66746.
697        $url = $title->getFullURL();
698
699        return self::addReturnTo( $url );
700    }
701
702    /**
703     * Accepts the contentType and content properties returned from the api
704     * for individual revisions and ensures that content is included in the
705     * final html page in an xss safe maner.
706     *
707     * It is expected that all content with contentType of html has been
708     * processed by parsoid and is safe for direct output into the document.
709     *
710     * @param string $contentType
711     * @param string $content
712     *
713     * @return string|SafeString
714     */
715    public static function escapeContent( $contentType, $content ) {
716        return in_array( $contentType, [ 'html', 'fixed-html', 'topic-title-html' ] ) ?
717            new SafeString( $content ) :
718            $content;
719    }
720
721    /**
722     * Only perform action when conditions match
723     *
724     * @param string $value
725     * @param string $operator e.g. 'or'
726     * @param string $value2 to compare with
727     * @param array $options lightncandy hbhelper options
728     *
729     * @return mixed result of callback
730     * @throws FlowException Fails when callbacks are not Closure instances
731     */
732    public static function ifCond( $value, $operator, $value2, array $options ) {
733        $doCallback = false;
734
735        // Perform operator
736        // FIXME: Rename to || to be consistent with other operators
737        if ( $operator === 'or' ) {
738            if ( $value || $value2 ) {
739                $doCallback = true;
740            }
741        } elseif ( $operator === '===' ) {
742            if ( $value === $value2 ) {
743                $doCallback = true;
744            }
745        } elseif ( $operator === '!==' ) {
746            if ( $value !== $value2 ) {
747                $doCallback = true;
748            }
749        } else {
750            return '';
751        }
752
753        if ( $doCallback ) {
754            $fn = $options['fn'];
755            if ( !$fn instanceof Closure ) {
756                throw new FlowException( 'Expected callback to be Closure instance' );
757            }
758            return $fn();
759        } elseif ( isset( $options['inverse'] ) ) {
760            $inverse = $options['inverse'];
761            if ( !$inverse instanceof Closure ) {
762                throw new FlowException( 'Expected inverse callback to be Closure instance' );
763            }
764            return $inverse();
765        } else {
766            return '';
767        }
768    }
769
770    /**
771     * @param array $options
772     *
773     * @return string tooltip
774     */
775    public static function tooltip( $options ) {
776        $fn = $options['fn'];
777        $params = $options['hash'];
778
779        return (
780            self::processTemplate( 'flow_tooltip', [
781                'positionClass' => $params['positionClass'] ? 'flow-ui-tooltip-' . $params['positionClass'] : null,
782                'contextClass' => $params['contextClass'] ? 'mw-ui-' . $params['contextClass'] : null,
783                'extraClass' => $params['extraClass'] ?: '',
784                'blockClass' => $params['isBlock'] ? 'flow-ui-tooltip-block' : null,
785                'content' => $fn(),
786            ] )
787        );
788    }
789
790    /**
791     * Enhance the patrolling link and protect it.
792     */
793    public static function enablePatrollingLink() {
794        $outputPage = RequestContext::getMain()->getOutput();
795
796        // Enhance the patrol link with ajax
797        // FIXME: This duplicates DifferenceEngine::markPatrolledLink.
798        $outputPage->setPreventClickjacking( true );
799        $outputPage->addModules( 'mediawiki.misc-authed-curate' );
800    }
801}