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