Passed
Push — master ( 250650...eb8721 )
by MusikAnimal
10:40
created

AppExtension::quote()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3.7085

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 2
nop 0
dl 0
loc 11
ccs 4
cts 7
cp 0.5714
crap 3.7085
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * This file contains only the AppExtension class.
4
 */
5
6
declare(strict_types = 1);
7
8
namespace AppBundle\Twig;
9
10
use AppBundle\Helper\I18nHelper;
11
use AppBundle\Model\Edit;
12
use AppBundle\Model\Project;
13
use AppBundle\Model\User;
14
use AppBundle\Repository\ProjectRepository;
15
use DateTime;
16
use Doctrine\DBAL\Connection;
17
use Symfony\Component\DependencyInjection\ContainerInterface;
18
use Symfony\Component\HttpFoundation\RequestStack;
19
use Symfony\Component\HttpFoundation\Session\SessionInterface;
20
use Twig\Extension\AbstractExtension;
21
use Twig\TwigFilter;
22
use Twig\TwigFunction;
23
24
/**
25
 * Twig functions and filters for XTools.
26
 */
27
class AppExtension extends AbstractExtension
28
{
29
    /** @var ContainerInterface The application's container interface. */
30
    protected $container;
31
32
    /** @var RequestStack The request stack. */
33
    protected $requestStack;
34
35
    /** @var SessionInterface User's current session. */
36
    protected $session;
37
38
    /** @var I18nHelper For i18n and l10n. */
39
    protected $i18n;
40
41
    /** @var float Duration of the current HTTP request in seconds. */
42
    protected $requestTime;
43
44
    /**
45
     * Constructor, with the I18nHelper through dependency injection.
46
     * @param ContainerInterface $container
47
     * @param RequestStack $requestStack
48
     * @param SessionInterface $session
49
     * @param I18nHelper $i18n
50
     */
51 27
    public function __construct(
52
        ContainerInterface $container,
53
        RequestStack $requestStack,
54
        SessionInterface $session,
55
        I18nHelper $i18n
56
    ) {
57 27
        $this->container = $container;
58 27
        $this->requestStack = $requestStack;
59 27
        $this->session = $session;
60 27
        $this->i18n = $i18n;
61 27
    }
62
63
    /*********************************** FUNCTIONS ***********************************/
64
65
    /**
66
     * Get all functions that this class provides.
67
     * @return TwigFunction[]
68
     * @codeCoverageIgnore
69
     */
70
    public function getFunctions(): array
71
    {
72
        return [
73
            new TwigFunction('request_time', [$this, 'requestTime']),
74
            new TwigFunction('memory_usage', [$this, 'requestMemory']),
75
            new TwigFunction('msgIfExists', [$this, 'msgIfExists'], ['is_safe' => ['html']]),
76
            new TwigFunction('msgExists', [$this, 'msgExists'], ['is_safe' => ['html']]),
77
            new TwigFunction('msg', [$this, 'msg'], ['is_safe' => ['html']]),
78
            new TwigFunction('lang', [$this, 'getLang']),
79
            new TwigFunction('langName', [$this, 'getLangName']),
80
            new TwigFunction('fallbackLangs', [$this, 'getFallbackLangs']),
81
            new TwigFunction('allLangs', [$this, 'getAllLangs']),
82
            new TwigFunction('isRTL', [$this, 'isRTL']),
83
            new TwigFunction('shortHash', [$this, 'gitShortHash']),
84
            new TwigFunction('hash', [$this, 'gitHash']),
85
            new TwigFunction('releaseDate', [$this, 'gitDate']),
86
            new TwigFunction('enabled', [$this, 'toolEnabled']),
87
            new TwigFunction('tools', [$this, 'tools']),
88
            new TwigFunction('color', [$this, 'getColorList']),
89
            new TwigFunction('chartColor', [$this, 'chartColor']),
90
            new TwigFunction('isSingleWiki', [$this, 'isSingleWiki']),
91
            new TwigFunction('getReplagThreshold', [$this, 'getReplagThreshold']),
92
            new TwigFunction('isWMFLabs', [$this, 'isWMFLabs']),
93
            new TwigFunction('replag', [$this, 'replag']),
94
            new TwigFunction('quote', [$this, 'quote']),
95
            new TwigFunction('bugReportURL', [$this, 'bugReportURL']),
96
            new TwigFunction('logged_in_user', [$this, 'loggedInUser']),
97
            new TwigFunction('isUserAnon', [$this, 'isUserAnon']),
98
            new TwigFunction('nsName', [$this, 'nsName']),
99
            new TwigFunction('titleWithNs', [$this, 'titleWithNs']),
100
            new TwigFunction('formatDuration', [$this, 'formatDuration']),
101
            new TwigFunction('numberFormat', [$this, 'numberFormat']),
102
            new TwigFunction('buildQuery', [$this, 'buildQuery']),
103
        ];
104
    }
105
106
    /**
107
     * Get the duration of the current HTTP request in seconds.
108
     * @return float
109
     * Untestable since there is no request stack in the tests.
110
     * @codeCoverageIgnore
111
     */
112
    public function requestTime(): float
113
    {
114
        if (!isset($this->requestTime)) {
115
            $this->requestTime = microtime(true) - $this->getRequest()->server->get('REQUEST_TIME_FLOAT');
116
        }
117
118
        return $this->requestTime;
119
    }
120
121
    /**
122
     * Get the formatted real memory usage.
123
     * @return float
124
     */
125 12
    public function requestMemory(): float
126
    {
127 12
        $mem = memory_get_usage(false);
128 12
        $div = pow(1024, 2);
129 12
        return $mem / $div;
130
    }
131
132
    /**
133
     * Get an i18n message.
134
     * @param string $message
135
     * @param string[] $vars
136
     * @return string|null
137
     */
138 11
    public function msg(string $message = '', array $vars = []): ?string
139
    {
140 11
        return $this->i18n->msg($message, $vars);
141
    }
142
143
    /**
144
     * See if a given i18n message exists.
145
     * @param string $message The message.
146
     * @param string[] $vars
147
     * @return bool
148
     */
149
    public function msgExists(?string $message, array $vars = []): bool
150
    {
151
        return $this->i18n->msgExists($message, $vars);
152
    }
153
154
    /**
155
     * Get an i18n message if it exists, otherwise just get the message key.
156
     * @param string $message
157
     * @param string[] $vars
158
     * @return string
159
     */
160
    public function msgIfExists(?string $message, array $vars = []): string
161
    {
162
        return $this->i18n->msgIfExists($message, $vars);
163
    }
164
165
    /**
166
     * Get the current language code.
167
     * @return string
168
     */
169 12
    public function getLang(): string
170
    {
171 12
        return $this->i18n->getLang();
172
    }
173
174
    /**
175
     * Get the current language name (defaults to 'English').
176
     * @return string
177
     */
178 12
    public function getLangName(): string
179
    {
180 12
        return $this->i18n->getLangName();
181
    }
182
183
    /**
184
     * Get the fallback languages for the current language, so we know what to load with jQuery.i18n.
185
     * @return string[]
186
     */
187 11
    public function getFallbackLangs(): array
188
    {
189 11
        return $this->i18n->getFallbacks();
190
    }
191
192
    /**
193
     * Get all available languages in the i18n directory
194
     * @return string[] Associative array of langKey => langName
195
     */
196 12
    public function getAllLangs(): array
197
    {
198 12
        return $this->i18n->getAllLangs();
199
    }
200
201
    /**
202
     * Whether the current language is right-to-left.
203
     * @param string|null $lang Optionally provide a specific lanuage code.
204
     * @return bool
205
     */
206 12
    public function isRTL(?string $lang = null): bool
207
    {
208 12
        return $this->i18n->isRTL($lang);
209
    }
210
211
    /**
212
     * Get the short hash of the currently checked-out Git commit.
213
     * @return string
214
     */
215 11
    public function gitShortHash(): string
216
    {
217 11
        return exec('git rev-parse --short HEAD');
218
    }
219
220
    /**
221
     * Get the full hash of the currently checkout-out Git commit.
222
     * @return string
223
     */
224 12
    public function gitHash(): string
225
    {
226 12
        return exec('git rev-parse HEAD');
227
    }
228
229
    /**
230
     * Get the date of the HEAD commit.
231
     * @return string
232
     */
233 2
    public function gitDate(): string
234
    {
235 2
        $date = new DateTime(exec('git show -s --format=%ci'));
236 2
        return $this->dateFormat($date, 'yyyy-MM-dd');
237
    }
238
239
    /**
240
     * Check whether a given tool is enabled.
241
     * @param string $tool The short name of the tool.
242
     * @return bool
243
     */
244 11
    public function toolEnabled(string $tool = 'index'): bool
245
    {
246 11
        $param = false;
247 11
        if ($this->container->hasParameter("enable.$tool")) {
248 11
            $param = boolval($this->container->getParameter("enable.$tool"));
249
        }
250 11
        return $param;
251
    }
252
253
    /**
254
     * Get a list of the short names of all tools.
255
     * @return string[]
256
     */
257 1
    public function tools(): array
258
    {
259 1
        $retVal = [];
260 1
        if ($this->container->hasParameter('tools')) {
261 1
            $retVal = $this->container->getParameter('tools');
262
        }
263 1
        return $retVal;
264
    }
265
266
    /**
267
     * Get a list of namespace colours (one or all).
268
     * @param int|false $num The NS ID to get. False to get the full list.
269
     * @return string|string[] Color or all all colors indexed by namespace ID.
270
     */
271
    public static function getColorList($num = false)
272
    {
273
        $colors = [
274
            0 => '#FF5555',
275
            1 => '#55FF55',
276
            2 => '#FFEE22',
277
            3 => '#FF55FF',
278
            4 => '#5555FF',
279
            5 => '#55FFFF',
280
            6 => '#C00000',
281
            7 => '#0000C0',
282
            8 => '#008800',
283
            9 => '#00C0C0',
284
            10 => '#FFAFAF',
285
            11 => '#808080',
286
            12 => '#00C000',
287
            13 => '#404040',
288
            14 => '#C0C000',
289
            15 => '#C000C0',
290
            90 => '#991100',
291
            91 => '#99FF00',
292
            92 => '#000000',
293
            93 => '#777777',
294
            100 => '#75A3D1',
295
            101 => '#A679D2',
296
            102 => '#660000',
297
            103 => '#000066',
298
            104 => '#FAFFAF',
299
            105 => '#408345',
300
            106 => '#5c8d20',
301
            107 => '#e1711d',
302
            108 => '#94ef2b',
303
            109 => '#756a4a',
304
            110 => '#6f1dab',
305
            111 => '#301e30',
306
            112 => '#5c9d96',
307
            113 => '#a8cd8c',
308
            114 => '#f2b3f1',
309
            115 => '#9b5828',
310
            116 => '#002288',
311
            117 => '#0000CC',
312
            118 => '#99FFFF',
313
            119 => '#99BBFF',
314
            120 => '#FF99FF',
315
            121 => '#CCFFFF',
316
            122 => '#CCFF00',
317
            123 => '#CCFFCC',
318
            200 => '#33FF00',
319
            201 => '#669900',
320
            202 => '#666666',
321
            203 => '#999999',
322
            204 => '#FFFFCC',
323
            205 => '#FF00CC',
324
            206 => '#FFFF00',
325
            207 => '#FFCC00',
326
            208 => '#FF0000',
327
            209 => '#FF6600',
328
            250 => '#6633CC',
329
            251 => '#6611AA',
330
            252 => '#66FF99',
331
            253 => '#66FF66',
332
            446 => '#06DCFB',
333
            447 => '#892EE4',
334
            460 => '#99FF66',
335
            461 => '#99CC66',
336
            470 => '#CCCC33',
337
            471 => '#CCFF33',
338
            480 => '#6699FF',
339
            481 => '#66FFFF',
340
            484 => '#07C8D6',
341
            485 => '#2AF1FF',
342
            486 => '#79CB21',
343
            487 => '#80D822',
344
            490 => '#995500',
345
            491 => '#998800',
346
            710 => '#FFCECE',
347
            711 => '#FFC8F2',
348
            828 => '#F7DE00',
349
            829 => '#BABA21',
350
            866 => '#FFFFFF',
351
            867 => '#FFCCFF',
352
            1198 => '#FF34B3',
353
            1199 => '#8B1C62',
354
            2300 => '#A900B8',
355
            2301 => '#C93ED6',
356
            2302 => '#8A09C1',
357
            2303 => '#974AB8',
358
            2600 => '#000000',
359
        ];
360
361
        if (false === $num) {
362
            return $colors;
363
        } elseif (isset($colors[$num])) {
364
            return $colors[$num];
365
        } else {
366
            // Default to grey.
367
            return '#CCC';
368
        }
369
    }
370
371
    /**
372
     * Get color-blind friendly colors for use in charts
373
     * @param int $num Index of color
374
     * @return string RGBA color (so you can more easily adjust the opacity)
375
     */
376
    public function chartColor(int $num): string
377
    {
378
        $colors = [
379
            'rgba(171, 212, 235, 1)',
380
            'rgba(178, 223, 138, 1)',
381
            'rgba(251, 154, 153, 1)',
382
            'rgba(253, 191, 111, 1)',
383
            'rgba(202, 178, 214, 1)',
384
            'rgba(207, 182, 128, 1)',
385
            'rgba(141, 211, 199, 1)',
386
            'rgba(252, 205, 229, 1)',
387
            'rgba(255, 247, 161, 1)',
388
            'rgba(252, 146, 114, 1)',
389
            'rgba(217, 217, 217, 1)',
390
        ];
391
392
        return $colors[$num % count($colors)];
393
    }
394
395
    /**
396
     * Whether XTools is running in single-project mode.
397
     * @return bool
398
     */
399 8
    public function isSingleWiki(): bool
400
    {
401 8
        $param = true;
402 8
        if ($this->container->hasParameter('app.single_wiki')) {
403 8
            $param = boolval($this->container->getParameter('app.single_wiki'));
404
        }
405 8
        return $param;
406
    }
407
408
    /**
409
     * Get the database replication-lag threshold.
410
     * @return int
411
     */
412
    public function getReplagThreshold(): int
413
    {
414
        $param = 30;
415
        if ($this->container->hasParameter('app.replag_threshold')) {
416
            $param = $this->container->getParameter('app.replag_threshold');
417
        }
418
        return $param;
419
    }
420
421
    /**
422
     * Whether XTools is running in WMF Labs mode.
423
     * @return bool
424
     */
425 11
    public function isWMFLabs(): bool
426
    {
427 11
        $param = false;
428 11
        if ($this->container->hasParameter('app.is_labs')) {
429 11
            $param = boolval($this->container->getParameter('app.is_labs'));
430
        }
431 11
        return $param;
432
    }
433
434
    /**
435
     * The current replication lag.
436
     * @return int
437
     * @codeCoverageIgnore
438
     */
439
    public function replag(): int
440
    {
441
        $retVal = 0;
442
443
        if ($this->isWMFLabs()) {
444
            $project = $this->getRequest()->get('project');
445
446
            if (!isset($project)) {
447
                $project = 'enwiki';
448
            }
449
450
            $dbName = ProjectRepository::getProject($project, $this->container)
451
                ->getDatabaseName();
452
453
            $sql = "SELECT lag FROM `heartbeat_p`.`heartbeat` h
454
                    RIGHT JOIN `meta_p`.`wiki` w ON concat(h.shard, \".labsdb\")=w.slice
455
                    WHERE dbname LIKE :project LIMIT 1";
456
457
            /** @var Connection $conn */
458
            $conn = $this->container->get('doctrine')->getManager('replicas')->getConnection();
459
460
            // Prepare the query and execute
461
            $resultQuery = $conn->prepare($sql);
462
            $resultQuery->bindParam('project', $dbName);
463
            $resultQuery->execute();
464
465
            if (0 == $resultQuery->errorCode()) {
466
                $results = $resultQuery->fetchAll();
467
468
                if (isset($results[0]['lag'])) {
469
                    $retVal = $results[0]['lag'];
470
                }
471
            }
472
        }
473
474
        return (int)$retVal;
475
    }
476
477
    /**
478
     * Get a random quote for the footer
479
     * @return string
480
     */
481 11
    public function quote(): string
482
    {
483
        // Don't show if Quote is turned off, but always show for Labs
484
        // (so quote is in footer but not in nav).
485 11
        $isLabs = $this->container->getParameter('app.is_labs');
486 11
        if (!$isLabs && !$this->container->getParameter('enable.Quote')) {
487 11
            return '';
488
        }
489
        $quotes = $this->container->getParameter('quotes');
490
        $id = array_rand($quotes);
491
        return $quotes[$id];
492
    }
493
494
    /**
495
     * Get the currently logged in user's details.
496
     * @return string[]|object|null
497
     */
498 11
    public function loggedInUser()
499
    {
500 11
        return $this->container->get('session')->get('logged_in_user');
501
    }
502
503
    /*********************************** FILTERS ***********************************/
504
505
    /**
506
     * Get all filters for this extension.
507
     * @return TwigFilter[]
508
     * @codeCoverageIgnore
509
     */
510
    public function getFilters(): array
511
    {
512
        return [
513
            new TwigFilter('ucfirst', [$this, 'capitalizeFirst']),
514
            new TwigFilter('percent_format', [$this, 'percentFormat']),
515
            new TwigFilter('diff_format', [$this, 'diffFormat'], ['is_safe' => ['html']]),
516
            new TwigFilter('num_format', [$this, 'numberFormat']),
517
            new TwigFilter('date_format', [$this, 'dateFormat']),
518
            new TwigFilter('wikify', [$this, 'wikify']),
519
        ];
520
    }
521
522
    /**
523
     * Format a number based on language settings.
524
     * @param int|float $number
525
     * @param int $decimals Number of decimals to format to.
526
     * @return string
527
     */
528 14
    public function numberFormat($number, $decimals = 0): string
529
    {
530 14
        return $this->i18n->numberFormat($number, $decimals);
531
    }
532
533
    /**
534
     * Localize the given date based on language settings.
535
     * @param string|int|DateTime $datetime
536
     * @param string $pattern Format according to this ICU date format.
537
     * @see http://userguide.icu-project.org/formatparse/datetime
538
     * @return string
539
     */
540 13
    public function dateFormat($datetime, $pattern = 'yyyy-MM-dd HH:mm'): string
541
    {
542 13
        return $this->i18n->dateFormat($datetime, $pattern);
543
    }
544
545
    /**
546
     * Convert raw wikitext to HTML-formatted string.
547
     * @param string $str
548
     * @param Project $project
549
     * @return string
550
     */
551 1
    public function wikify(string $str, Project $project): string
552
    {
553 1
        return Edit::wikifyString($str, $project);
554
    }
555
556
    /**
557
     * Mysteriously missing Twig helper to capitalize only the first character.
558
     * E.g. used for table headings for translated messages
559
     * @param string $str The string
560
     * @return string The string, capitalized
561
     */
562 6
    public function capitalizeFirst(string $str): string
563
    {
564 6
        return ucfirst($str);
565
    }
566
567
    /**
568
     * Format a given number or fraction as a percentage.
569
     * @param int|float $numerator Numerator or single fraction if denominator is ommitted.
570
     * @param int $denominator Denominator.
571
     * @param integer $precision Number of decimal places to show.
572
     * @return string Formatted percentage.
573
     */
574 1
    public function percentFormat($numerator, ?int $denominator = null, int $precision = 1): string
575
    {
576 1
        return $this->i18n->percentFormat($numerator, $denominator, $precision);
577
    }
578
579
    /**
580
     * Helper to return whether the given user is an anonymous (logged out) user.
581
     * @param User|string $user User object or username as a string.
582
     * @return bool
583
     */
584 1
    public function isUserAnon($user): bool
585
    {
586 1
        if ($user instanceof User) {
587 1
            $username = $user->getUsername();
588
        } else {
589 1
            $username = $user;
590
        }
591
592 1
        return (bool)filter_var($username, FILTER_VALIDATE_IP);
593
    }
594
595
    /**
596
     * Helper to properly translate a namespace name.
597
     * @param int|string $namespace Namespace key as a string or ID.
598
     * @param string[] $namespaces List of available namespaces as retrieved from Project::getNamespaces().
599
     * @return string Namespace name
600
     */
601
    public function nsName($namespace, array $namespaces): string
602
    {
603
        if ('all' === $namespace) {
604
            return $this->i18n->msg('all');
605
        } elseif ('0' === $namespace || 0 === $namespace || 'Main' === $namespace) {
606
            return $this->i18n->msg('mainspace');
607
        } else {
608
            return $namespaces[$namespace] ?? $this->i18n->msg('unknown');
609
        }
610
    }
611
612
    /**
613
     * Given a page title and namespace, generate the full page title.
614
     * @param string $title
615
     * @param int $namespace
616
     * @param array $namespaces
617
     * @return string
618
     */
619
    public function titleWithNs(string $title, int $namespace, array $namespaces): string
620
    {
621
        if (0 === $namespace) {
622
            return $title;
623
        }
624
        return $this->nsName($namespace, $namespaces).':'.$title;
625
    }
626
627
    /**
628
     * Format a given number as a diff, colouring it green if it's positive, red if negative, gary if zero
629
     * @param int $size Diff size
630
     * @return string Markup with formatted number
631
     */
632 1
    public function diffFormat(int $size): string
633
    {
634 1
        if ($size < 0) {
635 1
            $class = 'diff-neg';
636 1
        } elseif ($size > 0) {
637 1
            $class = 'diff-pos';
638
        } else {
639 1
            $class = 'diff-zero';
640
        }
641
642 1
        $size = $this->numberFormat($size);
643
644 1
        return "<span class='$class'".
645 1
            ($this->i18n->isRTL() ? " dir='rtl'" : '').
646 1
            ">$size</span>";
647
    }
648
649
    /**
650
     * Format a time duration as humanized string.
651
     * @param int $seconds Number of seconds.
652
     * @param bool $translate Used for unit testing. Set to false to return
653
     *   the value and i18n key, instead of the actual translation.
654
     * @return string|mixed[] Examples: '30 seconds', '2 minutes', '15 hours', '500 days',
655
     *   or [30, 'num-seconds'] (etc.) if $translate is false.
656
     */
657 1
    public function formatDuration(int $seconds, bool $translate = true)
658
    {
659 1
        [$val, $key] = $this->getDurationMessageKey($seconds);
660
661 1
        if ($translate) {
662
            return $this->numberFormat($val).' '.$this->i18n->msg("num-$key", [$val]);
663
        } else {
664 1
            return [$this->numberFormat($val), "num-$key"];
665
        }
666
    }
667
668
    /**
669
     * Given a time duration in seconds, generate a i18n message key and value.
670
     * @param int $seconds Number of seconds.
671
     * @return array<integer|string> [int - message value, string - message key]
672
     */
673 1
    private function getDurationMessageKey(int $seconds)
674
    {
675
        /** @var int $val Value to show in message */
676 1
        $val = $seconds;
677
678
        /** @var string $key Unit of time, used in the key for the i18n message */
679 1
        $key = 'seconds';
680
681 1
        if ($seconds >= 86400) {
682
            // Over a day
683 1
            $val = (int) floor($seconds / 86400);
684 1
            $key = 'days';
685 1
        } elseif ($seconds >= 3600) {
686
            // Over an hour, less than a day
687 1
            $val = (int) floor($seconds / 3600);
688 1
            $key = 'hours';
689 1
        } elseif ($seconds >= 60) {
690
            // Over a minute, less than an hour
691 1
            $val = (int) floor($seconds / 60);
692 1
            $key = 'minutes';
693
        }
694
695 1
        return [$val, $key];
696
    }
697
698
    /**
699
     * Build URL query string from given params.
700
     * @param string[] $params
701
     * @return string
702
     */
703 1
    public function buildQuery(array $params): string
704
    {
705 1
        return is_array($params) ? http_build_query($params) : '';
0 ignored issues
show
introduced by
The condition is_array($params) is always true.
Loading history...
706
    }
707
708
    /**
709
     * Shorthand to get the current request from the request stack.
710
     * @return \Symfony\Component\HttpFoundation\Request
711
     * There is no request stack in the tests.
712
     * @codeCoverageIgnore
713
     */
714
    private function getRequest(): \Symfony\Component\HttpFoundation\Request
715
    {
716
        return $this->container->get('request_stack')->getCurrentRequest();
717
    }
718
}
719