Passed
Pull Request — main (#442)
by MusikAnimal
08:21 queued 04:15
created

AppExtension::diffFormat()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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