Passed
Push — main ( ec4ebd...49d96d )
by MusikAnimal
04:21
created

EditCounterController::timecardIndexAction()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 0
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace App\Controller;
6
7
use App\Exception\XtoolsHttpException;
8
use App\Helper\I18nHelper;
9
use App\Model\EditCounter;
10
use App\Model\GlobalContribs;
11
use App\Model\UserRights;
12
use App\Repository\EditCounterRepository;
13
use App\Repository\EditRepository;
14
use App\Repository\GlobalContribsRepository;
15
use App\Repository\PageRepository;
16
use App\Repository\ProjectRepository;
17
use App\Repository\UserRepository;
18
use App\Repository\UserRightsRepository;
19
use GuzzleHttp\Client;
20
use Psr\Cache\CacheItemPoolInterface;
21
use Psr\Container\ContainerInterface;
22
use Symfony\Component\HttpFoundation\Cookie;
23
use Symfony\Component\HttpFoundation\JsonResponse;
24
use Symfony\Component\HttpFoundation\RedirectResponse;
25
use Symfony\Component\HttpFoundation\RequestStack;
26
use Symfony\Component\HttpFoundation\Response;
27
use Symfony\Component\Routing\Annotation\Route;
28
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
29
30
/**
31
 * Class EditCounterController
32
 */
33
class EditCounterController extends XtoolsController
34
{
35
    /**
36
     * Available statistic sections. These can be hand-picked on the index form so that you only get the data you
37
     * want and hence speed up the tool. Keys are the i18n messages (and DOM IDs), values are the action names.
38
     */
39
    private const AVAILABLE_SECTIONS = [
40
        'general-stats' => 'EditCounterGeneralStats',
41
        'namespace-totals' => 'EditCounterNamespaceTotals',
42
        'year-counts' => 'EditCounterYearCounts',
43
        'month-counts' => 'EditCounterMonthCounts',
44
        'timecard' => 'EditCounterTimecard',
45
        'top-edited-pages' => 'TopEditsResultNamespace',
46
        'rights-changes' => 'EditCounterRightsChanges',
47
    ];
48
49
    protected EditCounter $editCounter;
50
    protected EditCounterRepository $editCounterRepo;
51
    protected UserRights $userRights;
52
    protected UserRightsRepository $userRightsRepo;
53
54
    /** @var string[] Which sections to show. */
55
    protected array $sections;
56
57
    /**
58
     * @inheritDoc
59
     * @codeCoverageIgnore
60
     */
61
    public function getIndexRoute(): string
62
    {
63
        return 'EditCounter';
64
    }
65
66
    /**
67
     * EditCounterController constructor.
68
     * @param RequestStack $requestStack
69
     * @param ContainerInterface $container
70
     * @param CacheItemPoolInterface $cache
71
     * @param Client $guzzle
72
     * @param I18nHelper $i18n
73
     * @param ProjectRepository $projectRepo
74
     * @param UserRepository $userRepo
75
     * @param EditCounterRepository $editCounterRepo
76
     * @param UserRightsRepository $userRightsRepo
77
     * @param PageRepository $pageRepo
78
     */
79
    public function __construct(
80
        RequestStack $requestStack,
81
        ContainerInterface $container,
82
        CacheItemPoolInterface $cache,
83
        Client $guzzle,
84
        I18nHelper $i18n,
85
        ProjectRepository $projectRepo,
86
        UserRepository $userRepo,
87
        EditCounterRepository $editCounterRepo,
88
        UserRightsRepository $userRightsRepo,
89
        PageRepository $pageRepo
90
    ) {
91
        $this->editCounterRepo = $editCounterRepo;
92
        $this->userRightsRepo = $userRightsRepo;
93
        parent::__construct($requestStack, $container, $cache, $guzzle, $i18n, $projectRepo, $userRepo, $pageRepo);
94
    }
95
96
    /**
97
     * Causes the tool to redirect to the Simple Edit Counter if the user has too high of an edit count.
98
     * @inheritDoc
99
     * @codeCoverageIgnore
100
     */
101
    public function tooHighEditCountRoute(): string
102
    {
103
        return 'SimpleEditCounterResult';
104
    }
105
106
    /**
107
     * @inheritDoc
108
     * @codeCoverageIgnore
109
     */
110
    public function tooHighEditCountActionAllowlist(): array
111
    {
112
        return ['rightsChanges'];
113
    }
114
115
    /**
116
     * @inheritDoc
117
     * @codeCoverageIgnore
118
     */
119
    public function restrictedApiActions(): array
120
    {
121
        return ['monthCountsApi', 'timecardApi'];
122
    }
123
124
    /**
125
     * Every action in this controller (other than 'index') calls this first.
126
     * If a response is returned, the calling action is expected to return it.
127
     * @throws AccessDeniedException If attempting to access internal endpoint.
128
     * @throws XtoolsHttpException If an API request to restricted endpoint when user has not opted in.
129
     * @codeCoverageIgnore
130
     */
131
    protected function setUpEditCounter(): void
132
    {
133
        // Whether we're making a subrequest (the view makes a request to another action).
134
        // Subrequests to the same controller do not re-instantiate a new controller, and hence
135
        // this flag would not be set in XtoolsController::__construct(), so we must do it here as well.
136
        $this->isSubRequest = $this->request->get('htmlonly')
137
            || null !== $this->get('request_stack')->getParentRequest();
138
139
        // Return the EditCounter if we already have one.
140
        if (isset($this->editCounter)) {
141
            return;
142
        }
143
144
        // Will redirect to Simple Edit Counter if they have too many edits, as defined self::construct.
145
        $this->validateUser($this->user->getUsername());
0 ignored issues
show
Bug introduced by
The method getUsername() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

145
        $this->validateUser($this->user->/** @scrutinizer ignore-call */ getUsername());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
146
147
        // Store which sections of the Edit Counter they requested.
148
        $this->sections = $this->getRequestedSections();
149
150
        $this->userRights = new UserRights($this->userRightsRepo, $this->project, $this->user, $this->i18n);
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\UserRights::__construct() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

150
        $this->userRights = new UserRights($this->userRightsRepo, $this->project, /** @scrutinizer ignore-type */ $this->user, $this->i18n);
Loading history...
151
152
        // Instantiate EditCounter.
153
        $this->editCounter = new EditCounter(
154
            $this->editCounterRepo,
155
            $this->i18n,
156
            $this->userRights,
157
            $this->project,
158
            $this->user
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\EditCounter::__construct() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

158
            /** @scrutinizer ignore-type */ $this->user
Loading history...
159
        );
160
    }
161
162
    /**
163
     * The initial GET request that displays the search form.
164
     * @Route("/ec", name="EditCounter")
165
     * @Route("/ec/index.php", name="EditCounterIndexPhp")
166
     * @Route("/ec/{project}", name="EditCounterProject")
167
     * @return RedirectResponse|Response
168
     */
169
    public function indexAction()
170
    {
171
        if (isset($this->params['project']) && isset($this->params['username'])) {
172
            return $this->redirectFromSections();
173
        }
174
175
        $this->sections = $this->getRequestedSections(true);
176
177
        // Otherwise fall through.
178
        return $this->render('editCounter/index.html.twig', [
179
            'xtPageTitle' => 'tool-editcounter',
180
            'xtSubtitle' => 'tool-editcounter-desc',
181
            'xtPage' => 'EditCounter',
182
            'project' => $this->project,
183
            'sections' => $this->sections,
184
            'availableSections' => $this->getSectionNames(),
185
            'isAllSections' => $this->sections === $this->getSectionNames(),
186
        ]);
187
    }
188
189
    /**
190
     * Get the requested sections either from the URL, cookie, or the defaults (all sections).
191
     * @param bool $useCookies Whether or not to check cookies for the preferred sections.
192
     *   This option should not be true except on the index form.
193
     * @return array|mixed|string[]
194
     * @codeCoverageIgnore
195
     */
196
    private function getRequestedSections(bool $useCookies = false)
197
    {
198
        // Happens from sub-tool index pages, e.g. see self::generalStatsIndexAction().
199
        if (isset($this->sections)) {
200
            return $this->sections;
201
        }
202
203
        // Query param for sections gets priority.
204
        $sectionsQuery = $this->request->get('sections', '');
205
206
        // If not present, try the cookie, and finally the defaults (all sections).
207
        if ($useCookies && '' == $sectionsQuery) {
208
            $sectionsQuery = $this->request->cookies->get('XtoolsEditCounterOptions', '');
209
        }
210
211
        // Either a pipe-separated string or an array.
212
        $sections = is_array($sectionsQuery) ? $sectionsQuery : explode('|', $sectionsQuery);
213
214
        // Filter out any invalid section IDs.
215
        $sections = array_filter($sections, function ($section) {
216
            return in_array($section, $this->getSectionNames());
217
        });
218
219
        // Fallback for when no valid sections were requested or provided by the cookie.
220
        if (0 === count($sections)) {
221
            $sections = $this->getSectionNames();
222
        }
223
224
        return $sections;
225
    }
226
227
    /**
228
     * Get the names of the available sections.
229
     * @return string[]
230
     * @codeCoverageIgnore
231
     */
232
    private function getSectionNames(): array
233
    {
234
        return array_keys(self::AVAILABLE_SECTIONS);
235
    }
236
237
    /**
238
     * Redirect to the appropriate action based on what sections are being requested.
239
     * @return RedirectResponse
240
     * @codeCoverageIgnore
241
     */
242
    private function redirectFromSections(): RedirectResponse
243
    {
244
        $this->sections = $this->getRequestedSections();
245
246
        if (1 === count($this->sections)) {
247
            // Redirect to dedicated route.
248
            $response = $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params);
249
        } elseif ($this->sections === $this->getSectionNames()) {
250
            $response = $this->redirectToRoute('EditCounterResult', $this->params);
251
        } else {
252
            // Add sections to the params, which $this->generalUrl() will append to the URL.
253
            $this->params['sections'] = implode('|', $this->sections);
254
255
            // We want a pretty URL, with pipes | instead of the encoded value %7C
256
            $url = str_replace('%7C', '|', $this->generateUrl('EditCounterResult', $this->params));
257
258
            $response = $this->redirect($url);
259
        }
260
261
        // Save the preferred sections in a cookie.
262
        $response->headers->setCookie(
263
            new Cookie('XtoolsEditCounterOptions', implode('|', $this->sections))
264
        );
265
266
        return $response;
267
    }
268
269
    /**
270
     * Display all results.
271
     * @Route(
272
     *     "/ec/{project}/{username}",
273
     *     name="EditCounterResult",
274
     *     requirements={
275
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
276
     *     }
277
     * )
278
     * @return Response|RedirectResponse
279
     * @codeCoverageIgnore
280
     */
281
    public function resultAction()
282
    {
283
        $this->setUpEditCounter();
284
285
        if (1 === count($this->sections)) {
286
            // Redirect to dedicated route.
287
            return $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params);
288
        }
289
290
        $ret = [
291
            'xtTitle' => $this->user->getUsername() . ' - ' . $this->project->getTitle(),
292
            'xtPage' => 'EditCounter',
293
            'user' => $this->user,
294
            'project' => $this->project,
295
            'ec' => $this->editCounter,
296
            'sections' => $this->sections,
297
            'isAllSections' => $this->sections === $this->getSectionNames(),
298
        ];
299
300
        // Used when querying for global rights changes.
301
        if ($this->getParameter('app.is_wmf')) {
302
            $ret['metaProject'] = $this->projectRepo->getProject('metawiki');
303
        }
304
305
        return $this->getFormattedResponse('editCounter/result', $ret);
306
    }
307
308
    /**
309
     * Display the general statistics section.
310
     * @Route(
311
     *     "/ec-generalstats/{project}/{username}",
312
     *     name="EditCounterGeneralStats",
313
     *     requirements={
314
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
315
     *     }
316
     * )
317
     * @param GlobalContribsRepository $globalContribsRepo
318
     * @param EditRepository $editRepo
319
     * @return Response
320
     * @codeCoverageIgnore
321
     */
322
    public function generalStatsAction(
323
        GlobalContribsRepository $globalContribsRepo,
324
        EditRepository $editRepo
325
    ): Response {
326
        $this->setUpEditCounter();
327
328
        $globalContribs = new GlobalContribs(
329
            $globalContribsRepo,
330
            $this->pageRepo,
331
            $this->userRepo,
332
            $editRepo,
333
            $this->user
0 ignored issues
show
Bug introduced by
It seems like $this->user can also be of type null; however, parameter $user of App\Model\GlobalContribs::__construct() does only seem to accept App\Model\User, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

333
            /** @scrutinizer ignore-type */ $this->user
Loading history...
334
        );
335
        $ret = [
336
            'xtTitle' => $this->user->getUsername(),
337
            'xtPage' => 'EditCounter',
338
            'subtool_msg_key' => 'general-stats',
339
            'is_sub_request' => $this->isSubRequest,
340
            'user' => $this->user,
341
            'project' => $this->project,
342
            'ec' => $this->editCounter,
343
            'gc' => $globalContribs,
344
        ];
345
346
        // Output the relevant format template.
347
        return $this->getFormattedResponse('editCounter/general_stats', $ret);
348
    }
349
350
    /**
351
     * Search form for general stats.
352
     * @Route(
353
     *     "/ec-generalstats",
354
     *     name="EditCounterGeneralStatsIndex",
355
     *     requirements={
356
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
357
     *     }
358
     * )
359
     * @return Response
360
     */
361
    public function generalStatsIndexAction(): Response
362
    {
363
        $this->sections = ['general-stats'];
364
        return $this->indexAction();
365
    }
366
367
    /**
368
     * Display the namespace totals section.
369
     * @Route(
370
     *     "/ec-namespacetotals/{project}/{username}",
371
     *     name="EditCounterNamespaceTotals",
372
     *     requirements={
373
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
374
     *     }
375
     * )
376
     * @return Response
377
     * @codeCoverageIgnore
378
     */
379
    public function namespaceTotalsAction(): Response
380
    {
381
        $this->setUpEditCounter();
382
383
        $ret = [
384
            'xtTitle' => $this->user->getUsername(),
385
            'xtPage' => 'EditCounter',
386
            'subtool_msg_key' => 'namespace-totals',
387
            'is_sub_request' => $this->isSubRequest,
388
            'user' => $this->user,
389
            'project' => $this->project,
390
            'ec' => $this->editCounter,
391
        ];
392
393
        // Output the relevant format template.
394
        return $this->getFormattedResponse('editCounter/namespace_totals', $ret);
395
    }
396
397
    /**
398
     * Search form for namespace totals.
399
     * @Route("/ec-namespacetotals", name="EditCounterNamespaceTotalsIndex")
400
     * @return Response
401
     */
402
    public function namespaceTotalsIndexAction(): Response
403
    {
404
        $this->sections = ['namespace-totals'];
405
        return $this->indexAction();
406
    }
407
408
    /**
409
     * Display the timecard section.
410
     * @Route(
411
     *     "/ec-timecard/{project}/{username}",
412
     *     name="EditCounterTimecard",
413
     *     requirements={
414
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
415
     *     }
416
     * )
417
     * @return Response
418
     * @codeCoverageIgnore
419
     */
420
    public function timecardAction(): Response
421
    {
422
        $this->setUpEditCounter();
423
424
        $ret = [
425
            'xtTitle' => $this->user->getUsername(),
426
            'xtPage' => 'EditCounter',
427
            'subtool_msg_key' => 'timecard',
428
            'is_sub_request' => $this->isSubRequest,
429
            'user' => $this->user,
430
            'project' => $this->project,
431
            'ec' => $this->editCounter,
432
            'opted_in_page' => $this->getOptedInPage(),
433
        ];
434
435
        // Output the relevant format template.
436
        return $this->getFormattedResponse('editCounter/timecard', $ret);
437
    }
438
439
    /**
440
     * Search form for timecard.
441
     * @Route("/ec-timecard", name="EditCounterTimecardIndex")
442
     * @return Response
443
     */
444
    public function timecardIndexAction(): Response
445
    {
446
        $this->sections = ['timecard'];
447
        return $this->indexAction();
448
    }
449
450
    /**
451
     * Display the year counts section.
452
     * @Route(
453
     *     "/ec-yearcounts/{project}/{username}",
454
     *     name="EditCounterYearCounts",
455
     *     requirements={
456
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
457
     *     }
458
     * )
459
     * @return Response
460
     * @codeCoverageIgnore
461
     */
462
    public function yearCountsAction(): Response
463
    {
464
        $this->setUpEditCounter();
465
466
        $ret = [
467
            'xtTitle' => $this->user->getUsername(),
468
            'xtPage' => 'EditCounter',
469
            'subtool_msg_key' => 'year-counts',
470
            'is_sub_request' => $this->isSubRequest,
471
            'user' => $this->user,
472
            'project' => $this->project,
473
            'ec' => $this->editCounter,
474
        ];
475
476
        // Output the relevant format template.
477
        return $this->getFormattedResponse('editCounter/yearcounts', $ret);
478
    }
479
480
    /**
481
     * Search form for year counts.
482
     * @Route("/ec-yearcounts", name="EditCounterYearCountsIndex")
483
     * @return Response
484
     */
485
    public function yearCountsIndexAction(): Response
486
    {
487
        $this->sections = ['year-counts'];
488
        return $this->indexAction();
489
    }
490
491
    /**
492
     * Display the month counts section.
493
     * @Route(
494
     *     "/ec-monthcounts/{project}/{username}",
495
     *     name="EditCounterMonthCounts",
496
     *     requirements={
497
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
498
     *     }
499
     * )
500
     * @return Response
501
     * @codeCoverageIgnore
502
     */
503
    public function monthCountsAction(): Response
504
    {
505
        $this->setUpEditCounter();
506
507
        $ret = [
508
            'xtTitle' => $this->user->getUsername(),
509
            'xtPage' => 'EditCounter',
510
            'subtool_msg_key' => 'month-counts',
511
            'is_sub_request' => $this->isSubRequest,
512
            'user' => $this->user,
513
            'project' => $this->project,
514
            'ec' => $this->editCounter,
515
            'opted_in_page' => $this->getOptedInPage(),
516
        ];
517
518
        // Output the relevant format template.
519
        return $this->getFormattedResponse('editCounter/monthcounts', $ret);
520
    }
521
522
    /**
523
     * Search form for month counts.
524
     * @Route("/ec-monthcounts", name="EditCounterMonthCountsIndex")
525
     * @return Response
526
     */
527
    public function monthCountsIndexAction(): Response
528
    {
529
        $this->sections = ['month-counts'];
530
        return $this->indexAction();
531
    }
532
533
    /**
534
     * Display the user rights changes section.
535
     * @Route(
536
     *     "/ec-rightschanges/{project}/{username}",
537
     *     name="EditCounterRightsChanges",
538
     *     requirements={
539
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
540
     *     }
541
     * )
542
     * @return Response
543
     * @codeCoverageIgnore
544
     */
545
    public function rightsChangesAction(): Response
546
    {
547
        $this->setUpEditCounter();
548
549
        $ret = [
550
            'xtTitle' => $this->user->getUsername(),
551
            'xtPage' => 'EditCounter',
552
            'is_sub_request' => $this->isSubRequest,
553
            'user' => $this->user,
554
            'project' => $this->project,
555
            'ec' => $this->editCounter,
556
        ];
557
558
        if ($this->getParameter('app.is_wmf')) {
559
            $ret['metaProject'] = $this->projectRepo->getProject('metawiki');
560
        }
561
562
        // Output the relevant format template.
563
        return $this->getFormattedResponse('editCounter/rights_changes', $ret);
564
    }
565
566
    /**
567
     * Search form for rights changes.
568
     * @Route("/ec-rightschanges", name="EditCounterRightsChangesIndex")
569
     * @return Response
570
     */
571
    public function rightsChangesIndexAction(): Response
572
    {
573
        $this->sections = ['rights-changes'];
574
        return $this->indexAction();
575
    }
576
577
    /************************ API endpoints ************************/
578
579
    /**
580
     * Get various log counts for the user as JSON.
581
     * @Route(
582
     *     "/api/user/log_counts/{project}/{username}",
583
     *     name="UserApiLogCounts",
584
     *     requirements={
585
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
586
     *     }
587
     * )
588
     * @return JsonResponse
589
     * @codeCoverageIgnore
590
     */
591
    public function logCountsApiAction(): JsonResponse
592
    {
593
        $this->setUpEditCounter();
594
595
        return $this->getFormattedApiResponse([
596
            'log_counts' => $this->editCounter->getLogCounts(),
597
        ]);
598
    }
599
600
    /**
601
     * Get the namespace totals for the user as JSON.
602
     * @Route(
603
     *     "/api/user/namespace_totals/{project}/{username}",
604
     *     name="UserApiNamespaceTotals",
605
     *     requirements={
606
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
607
     *     }
608
     * )
609
     * @return JsonResponse
610
     * @codeCoverageIgnore
611
     */
612
    public function namespaceTotalsApiAction(): JsonResponse
613
    {
614
        $this->setUpEditCounter();
615
616
        return $this->getFormattedApiResponse([
617
            'namespace_totals' => (object)$this->editCounter->namespaceTotals(),
618
        ]);
619
    }
620
621
    /**
622
     * Get the month counts for the user as JSON.
623
     * @Route(
624
     *     "/api/user/month_counts/{project}/{username}",
625
     *     name="UserApiMonthCounts",
626
     *     requirements={
627
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
628
     *     }
629
     * )
630
     * @return JsonResponse
631
     * @codeCoverageIgnore
632
     */
633
    public function monthCountsApiAction(): JsonResponse
634
    {
635
        $this->setUpEditCounter();
636
637
        $ret = $this->editCounter->monthCounts();
638
639
        // Remove labels that are only needed by Twig views, and not consumers of the API.
640
        unset($ret['yearLabels']);
641
        unset($ret['monthLabels']);
642
643
        return $this->getFormattedApiResponse($ret);
644
    }
645
646
    /**
647
     * Get the timecard data as JSON.
648
     * @Route(
649
     *     "/api/user/timecard/{project}/{username}",
650
     *     name="UserApiTimeCard",
651
     *     requirements={
652
     *         "username" = "(ipr-.+\/\d+[^\/])|([^\/]+)",
653
     *     }
654
     * )
655
     * @return JsonResponse
656
     * @codeCoverageIgnore
657
     */
658
    public function timecardApiAction(): JsonResponse
659
    {
660
        $this->setUpEditCounter();
661
662
        return $this->getFormattedApiResponse([
663
            'timecard' => $this->editCounter->timeCard(),
664
        ]);
665
    }
666
}
667