Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.56% covered (danger)
18.56%
62 / 334
3.12% covered (danger)
3.12%
1 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
TemplateHelper
18.56% covered (danger)
18.56%
62 / 334
3.12% covered (danger)
3.12%
1 / 32
5610.47
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 / 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\Html\Html;
12use MediaWiki\MediaWikiServices;
13use MediaWiki\Title\Title;
14use MediaWiki\Utils\MWTimestamp;
15use OOUI\IconWidget;
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            str_contains( $templateName, '.' ) &&
57            (
58                $templateName === '.' || $templateName === '..' ||
59                str_starts_with( $templateName, './' ) ||
60                str_starts_with( $templateName, '../' ) ||
61                str_contains( $templateName, '/./' ) ||
62                str_contains( $templateName, '/../' ) ||
63                str_ends_with( $templateName, '/.' ) ||
64                str_ends_with( $templateName, '/..' )
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        // @phan-suppress-next-line PhanParamTooFewUnpack
501        return wfMessage( ...$args )->text();
502    }
503
504    /**
505     * @param array ...$args one or more arguments, i18n key and parameters
506     *
507     * @return SafeString HTML
508     */
509    public static function l10nParse( ...$args ) {
510        $options = array_pop( $args );
511        // @phan-suppress-next-line PhanParamTooFewUnpack
512        return new SafeString( wfMessage( ...$args )->parse() );
513    }
514
515    /**
516     * @param string $key
517     *
518     * @return SafeString HTML
519     */
520    public static function l10nParseFlowTermsOfUse( $key ) {
521        $context = RequestContext::getMain();
522        $config = MediaWikiServices::getInstance()->getMainConfig();
523        $messages = Hooks::getTermsOfUseMessagesParsed( $context, $config );
524        return new SafeString( $messages[ $key ] );
525    }
526
527    /**
528     * A helper to output whether a wiki is publish wiki or not
529     *
530     * @param array $options
531     * @return string Translated message string for either 'save' or 'publish'
532     *  version
533     */
534    public static function getSaveOrPublishMessage( array $options ) {
535        global $wgEditSubmitButtonLabelPublish;
536        $named = $options['hash'];
537
538        if ( !$named['save'] || !$named['publish'] ) {
539            throw new FlowException( "Missing an argument. Expected two message keys for 'save' and 'post'" );
540        }
541
542        $msg = $wgEditSubmitButtonLabelPublish ? $named['publish'] : $named['save'];
543
544        return wfMessage( $msg )->text();
545    }
546
547    /**
548     * @param array $data RevisionDiffViewFormatter::formatApi return value
549     *
550     * @return SafeString
551     */
552    public static function diffRevision( $data ) {
553        $differenceEngine = new \DifferenceEngine();
554        $notice = '';
555        if ( $data['diff_content'] === '' ) {
556            $notice .= '<div class="mw-diff-empty">' .
557                wfMessage( 'diff-empty' )->parse() .
558                "</div>\n";
559        }
560        // Work around exception in DifferenceEngine::showDiffStyle() (T202454)
561        $out = RequestContext::getMain()->getOutput();
562        $out->addModules( 'mediawiki.diff' );
563        $out->addModuleStyles( 'mediawiki.diff.styles' );
564
565        $renderer = Container::get( 'lightncandy' )->getTemplate( 'flow_revision_diff_header' );
566
567        return new SafeString( $differenceEngine->addHeader(
568            $data['diff_content'],
569            $renderer( [
570                'old' => true,
571                'revision' => $data['old'],
572                'links' => $data['links'],
573            ] ),
574            $renderer( [
575                'new' => true,
576                'revision' => $data['new'],
577                'links' => $data['links'],
578            ] ),
579            // FIXME we should be passing in a multinotice for multi-rev diffs here
580            '',
581            $notice
582        ) );
583    }
584
585    public static function diffUndo( $diffContent ) {
586        $differenceEngine = new \DifferenceEngine();
587        $notice = '';
588        if ( $diffContent === '' ) {
589            $notice = '<div class="mw-diff-empty">' .
590                wfMessage( 'diff-empty' )->parse() .
591                "</div>\n";
592        }
593        // Work around exception in DifferenceEngine::showDiffStyle() (T202454)
594        $out = RequestContext::getMain()->getOutput();
595        $out->addModules( 'mediawiki.diff' );
596        $out->addModuleStyles( 'mediawiki.diff.styles' );
597
598        return new SafeString( $differenceEngine->addHeader(
599            $diffContent,
600            wfMessage( 'flow-undo-latest-revision' )->parse(),
601            wfMessage( 'flow-undo-your-text' )->parse(),
602            // FIXME we should be passing in a multinotice for multi-rev diffs here
603            '',
604            $notice
605        ) );
606    }
607
608    /**
609     * @param array $actions
610     * @param string $moderationState
611     *
612     * @return string
613     */
614    public static function moderationAction( $actions, $moderationState ) {
615        return isset( $actions[$moderationState] ) ? $actions[$moderationState]['url'] : '';
616    }
617
618    /**
619     * @param string ...$args Expects one or more strings to join
620     *
621     * @return string all unnamed arguments joined together
622     */
623    public static function concat( ...$args ) {
624        $options = array_pop( $args );
625        return implode( '', $args );
626    }
627
628    /**
629     * Runs a callback when user is anonymous
630     *
631     * @param array $options which must contain fn and inverse key mapping to functions.
632     *
633     * @return mixed result of callback
634     * @throws FlowException Fails when callbacks are not Closure instances
635     */
636    public static function ifAnonymous( $options ) {
637        if ( !RequestContext::getMain()->getUser()->isRegistered() ) {
638            $fn = $options['fn'];
639            if ( !$fn instanceof Closure ) {
640                throw new FlowException( 'Expected callback to be Closuire instance' );
641            }
642        } elseif ( isset( $options['inverse'] ) ) {
643            $fn = $options['inverse'];
644            if ( !$fn instanceof Closure ) {
645                throw new FlowException( 'Expected inverse callback to be Closuire instance' );
646            }
647        } else {
648            return '';
649        }
650
651        return $fn();
652    }
653
654    /**
655     * Adds returnto parameter pointing to current page to existing URL
656     *
657     * @param string $url to modify
658     *
659     * @return string modified url
660     */
661    protected static function addReturnTo( $url ) {
662        $ctx = RequestContext::getMain();
663        $returnTo = $ctx->getTitle();
664        if ( !$returnTo ) {
665            return $url;
666        }
667        // We can't get only the query parameters from
668        $returnToQuery = $ctx->getRequest()->getQueryValues();
669
670        unset( $returnToQuery['title'] );
671
672        $args = [
673            'returnto' => $returnTo->getPrefixedURL(),
674        ];
675        if ( $returnToQuery ) {
676            $args['returntoquery'] = wfArrayToCgi( $returnToQuery );
677        }
678        return wfAppendQuery( $url, wfArrayToCgi( $args ) );
679    }
680
681    /**
682     * Adds returnto parameter pointing to given Title to an existing URL
683     *
684     * @param string $title
685     *
686     * @return string modified url
687     */
688    public static function linkWithReturnTo( $title ) {
689        $title = Title::newFromText( $title );
690        if ( !$title ) {
691            return '';
692        }
693        // FIXME: This should use local url to avoid redirects on mobile. See bug 66746.
694        $url = $title->getFullURL();
695
696        return self::addReturnTo( $url );
697    }
698
699    /**
700     * Accepts the contentType and content properties returned from the api
701     * for individual revisions and ensures that content is included in the
702     * final html page in an xss safe maner.
703     *
704     * It is expected that all content with contentType of html has been
705     * processed by parsoid and is safe for direct output into the document.
706     *
707     * @param string $contentType
708     * @param string $content
709     *
710     * @return string|SafeString
711     */
712    public static function escapeContent( $contentType, $content ) {
713        return in_array( $contentType, [ 'html', 'fixed-html', 'topic-title-html' ] ) ?
714            new SafeString( $content ) :
715            $content;
716    }
717
718    /**
719     * Only perform action when conditions match
720     *
721     * @param string $value
722     * @param string $operator e.g. 'or'
723     * @param string $value2 to compare with
724     * @param array $options lightncandy hbhelper options
725     *
726     * @return mixed result of callback
727     * @throws FlowException Fails when callbacks are not Closure instances
728     */
729    public static function ifCond( $value, $operator, $value2, array $options ) {
730        $doCallback = false;
731
732        // Perform operator
733        // FIXME: Rename to || to be consistent with other operators
734        if ( $operator === 'or' ) {
735            if ( $value || $value2 ) {
736                $doCallback = true;
737            }
738        } elseif ( $operator === '===' ) {
739            if ( $value === $value2 ) {
740                $doCallback = true;
741            }
742        } elseif ( $operator === '!==' ) {
743            if ( $value !== $value2 ) {
744                $doCallback = true;
745            }
746        } else {
747            return '';
748        }
749
750        if ( $doCallback ) {
751            $fn = $options['fn'];
752            if ( !$fn instanceof Closure ) {
753                throw new FlowException( 'Expected callback to be Closure instance' );
754            }
755            return $fn();
756        } elseif ( isset( $options['inverse'] ) ) {
757            $inverse = $options['inverse'];
758            if ( !$inverse instanceof Closure ) {
759                throw new FlowException( 'Expected inverse callback to be Closure instance' );
760            }
761            return $inverse();
762        } else {
763            return '';
764        }
765    }
766
767    /**
768     * @param array $options
769     *
770     * @return string tooltip
771     */
772    public static function tooltip( $options ) {
773        $fn = $options['fn'];
774        $params = $options['hash'];
775
776        return (
777            self::processTemplate( 'flow_tooltip', [
778                'positionClass' => $params['positionClass'] ? 'flow-ui-tooltip-' . $params['positionClass'] : null,
779                'contextClass' => $params['contextClass'] ? 'mw-ui-' . $params['contextClass'] : null,
780                'extraClass' => $params['extraClass'] ?: '',
781                'blockClass' => $params['isBlock'] ? 'flow-ui-tooltip-block' : null,
782                'content' => $fn(),
783            ] )
784        );
785    }
786
787    /**
788     * Enhance the patrolling link and protect it.
789     */
790    public static function enablePatrollingLink() {
791        $outputPage = RequestContext::getMain()->getOutput();
792
793        // Enhance the patrol link with ajax
794        // FIXME: This duplicates DifferenceEngine::markPatrolledLink.
795        $outputPage->setPreventClickjacking( true );
796        $outputPage->addModules( 'mediawiki.misc-authed-curate' );
797    }
798}