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\RecentChanges\ChangesList;
14use MediaWiki\Title\Title;
15use MediaWiki\Utils\MWTimestamp;
16use OOUI\IconWidget;
17
18class TemplateHelper {
19
20    /**
21     * @var string
22     */
23    protected $templateDir;
24
25    /**
26     * @var callable[]
27     */
28    protected $renderers;
29
30    /**
31     * @var bool Always compile template files
32     */
33    protected $forceRecompile = false;
34
35    /**
36     * @param string $templateDir
37     * @param bool $forceRecompile
38     */
39    public function __construct( $templateDir, $forceRecompile = false ) {
40        $this->templateDir = $templateDir;
41        $this->forceRecompile = $forceRecompile;
42    }
43
44    /**
45     * Constructs the location of the source handlebars template
46     * and the compiled php code that goes with it.
47     *
48     * @param string $templateName
49     *
50     * @return string[]
51     * @throws FlowException Disallows upwards directory traversal via $templateName
52     */
53    public function getTemplateFilenames( $templateName ) {
54        // Prevent upwards directory traversal using same methods as Title::secureAndSplit,
55        // which is implemented in MediaWikiTitleCodec::splitTitleString.
56        if (
57            str_contains( $templateName, '.' ) &&
58            (
59                $templateName === '.' || $templateName === '..' ||
60                str_starts_with( $templateName, './' ) ||
61                str_starts_with( $templateName, '../' ) ||
62                str_contains( $templateName, '/./' ) ||
63                str_contains( $templateName, '/../' ) ||
64                str_ends_with( $templateName, '/.' ) ||
65                str_ends_with( $templateName, '/..' )
66            )
67        ) {
68            throw new FlowException( "Malformed \$templateName: $templateName" );
69        }
70
71        return [
72            'template' => "{$this->templateDir}/{$templateName}.handlebars",
73            'compiled' => "{$this->templateDir}/compiled/{$templateName}.handlebars.php",
74        ];
75    }
76
77    /**
78     * Returns a given template function if found, otherwise throws an exception.
79     *
80     * @param string $templateName
81     *
82     * @return callable
83     * @throws FlowException
84     * @throws \Exception
85     */
86    public function getTemplate( $templateName ) {
87        if ( isset( $this->renderers[$templateName] ) ) {
88            return $this->renderers[$templateName];
89        }
90
91        $filenames = $this->getTemplateFilenames( $templateName );
92
93        if ( $this->forceRecompile ) {
94            if ( !file_exists( $filenames['template'] ) ) {
95                throw new FlowException( "Could not locate template: {$filenames['template']}" );
96            }
97
98            $code = self::compile( file_get_contents( $filenames['template'] ), $this->templateDir );
99
100            if ( !$code ) {
101                throw new FlowException( "Failed to compile template '$templateName'." );
102            }
103            $success = file_put_contents( $filenames['compiled'], '<?php ' . $code );
104
105            // failed to recompile template (OS permissions?); unless the
106            // content hasn't changes, throw an exception!
107            if ( !$success && file_get_contents( $filenames['compiled'] ) !== $code ) {
108                throw new FlowException( "Failed to save updated compiled template '$templateName'" );
109            }
110        }
111
112        /** @var callable $renderer */
113        $renderer = require $filenames['compiled'];
114        $this->renderers[$templateName] = static function ( $args, array $scopes = [] ) use ( $templateName, $renderer ) {
115            return $renderer( $args, $scopes );
116        };
117        return $this->renderers[$templateName];
118    }
119
120    /**
121     * @param string $code Handlebars code
122     * @param string $templateDir Directory templates are stored in
123     *
124     * @return string PHP code
125     * @suppress PhanTypeMismatchArgument
126     */
127    public static function compile( $code, $templateDir ) {
128        return LightnCandy::compile(
129            $code,
130            [
131                'flags' => LightnCandy::FLAG_ERROR_EXCEPTION
132                    | LightnCandy::FLAG_EXTHELPER
133                    | LightnCandy::FLAG_SPVARS
134                    | LightnCandy::FLAG_HANDLEBARS
135                    | LightnCandy::FLAG_RUNTIMEPARTIAL,
136                'partialresolver' => static function ( $context, $name ) use ( $templateDir ) {
137                    $filename = "$templateDir/$name.partial.handlebars";
138                    if ( file_exists( $filename ) ) {
139                        return file_get_contents( $filename );
140                    }
141                    return null;
142                },
143                'helpers' => [
144                    'l10n' => 'Flow\TemplateHelper::l10n',
145                    'uuidTimestamp' => 'Flow\TemplateHelper::uuidTimestamp',
146                    'timestamp' => 'Flow\TemplateHelper::timestampHelper',
147                    'html' => 'Flow\TemplateHelper::htmlHelper',
148                    'block' => 'Flow\TemplateHelper::block',
149                    'post' => 'Flow\TemplateHelper::post',
150                    'historyTimestamp' => 'Flow\TemplateHelper::historyTimestamp',
151                    'historyDescription' => 'Flow\TemplateHelper::historyDescription',
152                    'showCharacterDifference' => 'Flow\TemplateHelper::showCharacterDifference',
153                    'l10nParse' => 'Flow\TemplateHelper::l10nParse',
154                    'l10nParseFlowTermsOfUse' => 'Flow\TemplateHelper::l10nParseFlowTermsOfUse',
155                    'diffRevision' => 'Flow\TemplateHelper::diffRevision',
156                    'diffUndo' => 'Flow\TemplateHelper::diffUndo',
157                    'moderationAction' => 'Flow\TemplateHelper::moderationAction',
158                    'concat' => 'Flow\TemplateHelper::concat',
159                    'linkWithReturnTo' => 'Flow\TemplateHelper::linkWithReturnTo',
160                    'escapeContent' => 'Flow\TemplateHelper::escapeContent',
161                    'enablePatrollingLink' => 'Flow\TemplateHelper::enablePatrollingLink',
162                    'oouify' => 'Flow\TemplateHelper::oouify',
163                    'getSaveOrPublishMessage' => 'Flow\TemplateHelper::getSaveOrPublishMessage',
164                    'eachPost' => 'Flow\TemplateHelper::eachPost',
165                    'ifAnonymous' => 'Flow\TemplateHelper::ifAnonymous',
166                    'ifCond' => 'Flow\TemplateHelper::ifCond',
167                    'tooltip' => 'Flow\TemplateHelper::tooltip',
168                    'progressiveEnhancement' => 'Flow\TemplateHelper::progressiveEnhancement',
169                ],
170            ]
171        );
172    }
173
174    /**
175     * Returns HTML for a given template by calling the template function with the given args.
176     *
177     * @param string $templateName
178     * @param array $args
179     * @param array $scopes
180     *
181     * @return string
182     */
183    public static function processTemplate( $templateName, array $args, array $scopes = [] ) {
184        // Undesirable, but lightncandy helpers have to be static methods
185        /** @var TemplateHelper $lightncandy */
186        $lightncandy = Container::get( 'lightncandy' );
187        $template = $lightncandy->getTemplate( $templateName );
188        // @todo ugly hack...remove someday.  Requires switching to newest version
189        // of lightncandy which supports recursive partial templates.
190        if ( !array_key_exists( 'rootBlock', $args ) ) {
191            $args['rootBlock'] = $args;
192        }
193        return $template( $args, $scopes );
194    }
195
196    // Helpers
197
198    /**
199     * Generates a timestamp using the UUID, then calls the timestamp helper with it.
200     *
201     * @param string $uuid
202     *
203     * @return SafeString|null
204     */
205    public static function uuidTimestamp( $uuid ) {
206        $obj = UUID::create( $uuid );
207        if ( !$obj ) {
208            return null;
209        }
210
211        // timestamp helper expects ms timestamp
212        $timestamp = (int)$obj->getTimestampObj()->getTimestamp() * 1000;
213        return self::timestamp( $timestamp );
214    }
215
216    /**
217     * @param string $timestamp
218     *
219     * @return SafeString|null
220     */
221    public static function timestampHelper( $timestamp ) {
222        return self::timestamp( (int)$timestamp );
223    }
224
225    /**
226     * @param int $timestamp milliseconds since the unix epoch
227     *
228     * @return SafeString|null
229     */
230    protected static function timestamp( $timestamp ) {
231        global $wgLang;
232
233        if ( !$timestamp ) {
234            return null;
235        }
236
237        // source timestamps are in ms
238        $timestamp /= 1000;
239        $ts = new MWTimestamp( $timestamp );
240
241        return new SafeString( self::processTemplate(
242            'timestamp',
243            [
244                'time_iso' => $timestamp,
245                'time_ago' => $wgLang->getHumanTimestamp( $ts ),
246                'time_readable' => $wgLang->userTimeAndDate(
247                    $timestamp,
248                    RequestContext::getMain()->getUser()
249                ),
250                'guid' => null, // generated client-side
251            ]
252        ) );
253    }
254
255    /**
256     * @param string $html
257     *
258     * @return SafeString
259     */
260    public static function htmlHelper( $html ) {
261        return new SafeString( $html ?? 'undefined' );
262    }
263
264    /**
265     * @param array $block
266     *
267     * @return SafeString
268     */
269    public static function block( $block ) {
270        $template = "flow_block_" . $block['type'];
271        if ( $block['block-action-template'] ) {
272            $template .= '_' . $block['block-action-template'];
273        }
274        return new SafeString( self::processTemplate(
275            $template,
276            $block
277        ) );
278    }
279
280    /**
281     * @param array $context The 'this' value of the calling context
282     * @param mixed $postIds List of ids (roots)
283     * @param array $options blockhelper specific invocation options
284     *
285     * @return null|string HTML
286     * @throws FlowException When callbacks are not Closure instances
287     */
288    public static function eachPost( array $context, $postIds, array $options ) {
289        /** @var callable $inverse */
290        $inverse = $options['inverse'] ?? null;
291        /** @var callable $fn */
292        $fn = $options['fn'];
293
294        if ( $postIds && !is_array( $postIds ) ) {
295            $postIds = [ $postIds ];
296        } elseif ( $postIds === [] ) {
297            // Failure callback, if any
298            if ( !$inverse ) {
299                return null;
300            }
301            if ( !$inverse instanceof Closure ) {
302                throw new FlowException( 'Invalid inverse callback, expected Closure' );
303            }
304            return $inverse( $options['cx'], [] );
305        } else {
306            return null;
307        }
308
309        if ( !$fn instanceof Closure ) {
310            throw new FlowException( 'Invalid callback, expected Closure' );
311        }
312        $html = [];
313        foreach ( $postIds as $id ) {
314            $revId = $context['posts'][$id][0] ?? '';
315
316            if ( !$revId || !isset( $context['revisions'][$revId] ) ) {
317                throw new FlowException( "Revision not available: $revId. Post ID: $id" );
318            }
319
320            // $fn is always safe return value, it's the inner template content.
321            $html[] = $fn( $context['revisions'][$revId] );
322        }
323
324        // Return the resulting HTML
325        return implode( '', $html );
326    }
327
328    /**
329     * Required to prevent recursion loop rendering nested posts
330     *
331     * @param array $rootBlock
332     * @param array $revision
333     *
334     * @return SafeString
335     */
336    public static function post( $rootBlock, $revision ) {
337        return new SafeString( self::processTemplate( 'flow_post', [
338            'revision' => $revision,
339            'rootBlock' => $rootBlock,
340        ] ) );
341    }
342
343    /**
344     * @param array $revision
345     *
346     * @return SafeString
347     */
348    public static function historyTimestamp( $revision ) {
349        $raw = false;
350        $formattedTime = $revision['dateFormats']['timeAndDate'];
351        $formattedTimeOutput = '';
352        $linkKeys = [ 'header-revision', 'topic-revision', 'post-revision', 'summary-revision' ];
353        foreach ( $linkKeys as $linkKey ) {
354            if ( isset( $revision['links'][$linkKey] ) ) {
355                $link = $revision['links'][$linkKey];
356                $formattedTimeOutput = Html::element(
357                    'a',
358                    [
359                        'href' => $link['url'],
360                        'title' => $link['title'],
361                    ],
362                    $formattedTime
363                );
364                $raw = true;
365                break;
366            }
367        }
368
369        if ( !$raw ) {
370            $formattedTimeOutput = htmlspecialchars( $formattedTime );
371        }
372
373        $class = [ 'mw-changeslist-date' ];
374        if ( $revision['isModeratedNotLocked'] ) {
375            $class[] = 'history-deleted';
376        }
377
378        return new SafeString(
379            '<span class="plainlinks">'
380            . Html::rawElement( 'span', [ 'class' => $class ], $formattedTimeOutput )
381            . '</span>'
382        );
383    }
384
385    /**
386     * @param array $revision
387     *
388     * @return SafeString|null
389     */
390    public static function historyDescription( $revision ) {
391        if ( !isset( $revision['properties']['_key'] ) ) {
392            return null;
393        }
394
395        $i18nKey = $revision['properties']['_key'];
396        unset( $revision['properties']['_key'] );
397
398        // $revision['properties'] contains the params for the i18n message, which are named,
399        // so we need array_values() to strip the names. They are in the correct order because
400        // RevisionFormatter::getDescriptionParams() uses a foreach loop to build this array
401        // from the i18n-params definition in FlowActions.php.
402        // A variety of the i18n history messages contain wikitext and require ->parse().
403        return new SafeString( wfMessage( $i18nKey, array_values( $revision['properties'] ) )->parse() );
404    }
405
406    /**
407     * @param string $old
408     * @param string $new
409     *
410     * @return SafeString
411     */
412    public static function showCharacterDifference( $old, $new ) {
413        return new SafeString( ChangesList::showCharacterDifference( (int)$old, (int)$new ) );
414    }
415
416    /**
417     * Creates a special script tag to be processed client-side. This contains extra template HTML, which allows
418     * the front-end to "progressively enhance" the page with more content which isn't needed in a non-JS state.
419     *
420     * @see FlowHandlebars.prototype.progressiveEnhancement in flow-handlebars.js for more details.
421     *
422     * @param array $options
423     *
424     * @return SafeString
425     */
426    public static function progressiveEnhancement( array $options ) {
427        $fn = $options['fn'];
428        $input = $options['hash'];
429        $insertionType = empty( $input['type'] ) ? 'insert' : htmlspecialchars( $input['type'] );
430        $target = empty( $input['target'] ) ? '' : 'data-target="' . htmlspecialchars( $input['target'] ) . '"';
431        $sectionId = empty( $input['id'] ) ? '' : 'id="' . htmlspecialchars( $input['id'] ) . '"';
432
433        return new SafeString(
434            '<script name="handlebars-template-progressive-enhancement"' .
435                ' type="text/x-handlebars-template-progressive-enhancement"' .
436                ' data-type="' . $insertionType . '"' .
437                ' ' . $target .
438                ' ' . $sectionId .
439            '>' .
440                // Replace the nested script tag with a placeholder tag for recursive progressiveEnhancement
441                str_replace( '</script>', '</flowprogressivescript>', $fn() ) .
442            '</script>'
443        );
444    }
445
446    /**
447     * A helper to output OOUI widgets.
448     *
449     * @param array ...$args one or more arguments, i18n key and parameters
450     * @return \OOUI\Widget|null
451     */
452    public static function oouify( ...$args ) {
453        $options = array_pop( $args );
454        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Only when $args is empty
455        $named = $options['hash'];
456
457        $widgetType = $named[ 'type' ];
458        $data = [];
459
460        $classes = [];
461        if ( isset( $named['classes'] ) ) {
462            $classes = explode( ' ', $named[ 'classes' ] );
463        }
464
465        // Push raw arguments
466        $data['args'] = $args;
467        $baseConfig = [
468            // 'infusable' => true,
469            'id' => $named[ 'name' ] ?? null,
470            'classes' => $classes,
471            'data' => $data
472        ];
473        $widget = null;
474        switch ( $widgetType ) {
475            case 'BoardDescriptionWidget':
476                $dataArgs = [
477                    'infusable' => false,
478                    'description' => $args[0],
479                    'editLink' => $args[1]
480                ];
481                $widget = new OOUI\BoardDescriptionWidget( $baseConfig + $dataArgs );
482                break;
483            case 'IconWidget':
484                $dataArgs = [
485                    'icon' => $args[0],
486                ];
487                $widget = new IconWidget( $baseConfig + $dataArgs );
488                break;
489        }
490
491        return $widget;
492    }
493
494    /**
495     * @param array ...$args one or more arguments, i18n key and parameters
496     *
497     * @return string Message output, using the 'text' format
498     */
499    public static function l10n( ...$args ) {
500        $options = array_pop( $args );
501        // @phan-suppress-next-line PhanParamTooFewUnpack
502        return wfMessage( ...$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        // @phan-suppress-next-line PhanParamTooFewUnpack
513        return new SafeString( wfMessage( ...$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}