AppExtension::getFilters()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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