Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
131 / 131
100.00% covered (success)
100.00%
44 / 44
CRAP
100.00% covered (success)
100.00%
1 / 1
Pager
100.00% covered (success)
100.00%
131 / 131
100.00% covered (success)
100.00%
44 / 44
77
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getItemsPerPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setItemsPerPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTotalItems
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTotalItems
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setView
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getView
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getViews
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSurround
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSurround
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setQuery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getQuery
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAllowedQueries
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 prepareUrl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 setUrl
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getCurrentPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentPageUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFirstPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFirstPageUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLastPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLastPageUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPreviousPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousPageUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNextPage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setNextPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNextPageUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPreviousPagesUrls
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getNextPagesUrls
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 get
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getWithUrl
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 renderShort
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setDefaultView
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultView
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitizePageNumber
n/a
0 / 0
n/a
0 / 0
4
 sanitize
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Pagination Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Pagination;
11
12use Framework\Helpers\Isolation;
13use Framework\HTTP\URL;
14use Framework\Language\Language;
15use InvalidArgumentException;
16use JetBrains\PhpStorm\ArrayShape;
17use JetBrains\PhpStorm\Deprecated;
18use JetBrains\PhpStorm\Pure;
19use JsonSerializable;
20use LogicException;
21use Stringable;
22
23/**
24 * Class Pager.
25 *
26 * @package pagination
27 */
28class Pager implements JsonSerializable, Stringable
29{
30    protected int $currentPage;
31    protected ?int $previousPage = null;
32    protected ?int $nextPage = null;
33    protected int $lastPage;
34    protected int $itemsPerPage;
35    protected int $totalItems;
36    protected int $surround = 2;
37    /**
38     * @var array<string,string>
39     */
40    protected array $views = [
41        // HTML Head
42        'head' => __DIR__ . '/Views/head.php',
43        // HTTP Header
44        'header' => __DIR__ . '/Views/header.php',
45        // HTML Previous and Next
46        'pager' => __DIR__ . '/Views/pagination-short.php',
47        // HTML Full
48        'pagination' => __DIR__ . '/Views/pagination.php',
49        'pagination-short' => __DIR__ . '/Views/pagination-short.php',
50        // Bootstrap 5
51        'bootstrap' => __DIR__ . '/Views/bootstrap.php',
52        'bootstrap-short' => __DIR__ . '/Views/bootstrap-short.php',
53        'bootstrap5' => __DIR__ . '/Views/bootstrap.php',
54        'bootstrap5-short' => __DIR__ . '/Views/bootstrap-short.php',
55        // Bulma 0
56        'bulma' => __DIR__ . '/Views/bulma.php',
57        'bulma-short' => __DIR__ . '/Views/bulma-short.php',
58        // Foundation 6
59        'foundation' => __DIR__ . '/Views/foundation.php',
60        'foundation-short' => __DIR__ . '/Views/foundation-short.php',
61        'foundation6' => __DIR__ . '/Views/foundation.php',
62        'foundation6-short' => __DIR__ . '/Views/foundation-short.php',
63        // Materialize 1
64        'materialize' => __DIR__ . '/Views/materialize.php',
65        'materialize-short' => __DIR__ . '/Views/materialize-short.php',
66        'materialize1' => __DIR__ . '/Views/materialize.php',
67        'materialize1-short' => __DIR__ . '/Views/materialize-short.php',
68        // Primer 20
69        'primer' => __DIR__ . '/Views/primer.php',
70        'primer-short' => __DIR__ . '/Views/primer-short.php',
71        'primer20' => __DIR__ . '/Views/primer.php',
72        'primer20-short' => __DIR__ . '/Views/primer-short.php',
73        // Semantic UI 2
74        'semantic-ui' => __DIR__ . '/Views/semantic-ui.php',
75        'semantic-ui-short' => __DIR__ . '/Views/semantic-ui-short.php',
76        'semantic-ui2' => __DIR__ . '/Views/semantic-ui.php',
77        'semantic-ui2-short' => __DIR__ . '/Views/semantic-ui-short.php',
78        // Tailwind CSS 3
79        'tailwind' => __DIR__ . '/Views/tailwind.php',
80        'tailwind-short' => __DIR__ . '/Views/tailwind-short.php',
81        'tailwind3' => __DIR__ . '/Views/tailwind.php',
82        'tailwind3-short' => __DIR__ . '/Views/tailwind-short.php',
83        // W3.CSS 4
84        'w3' => __DIR__ . '/Views/w3.php',
85        'w3-short' => __DIR__ . '/Views/w3-short.php',
86        'w34' => __DIR__ . '/Views/w3.php',
87        'w34-short' => __DIR__ . '/Views/w3-short.php',
88    ];
89    protected string $defaultView = 'pagination';
90    protected URL $url;
91    protected string $oldUrl;
92    protected string $query = 'page';
93    protected Language $language;
94
95    /**
96     * Pager constructor.
97     *
98     * @param int|string $currentPage
99     * @param int|string $itemsPerPage
100     * @param int $totalItems
101     * @param Language|null $language Language instance
102     * @param string|null $url
103     */
104    public function __construct(
105        int | string $currentPage,
106        int | string $itemsPerPage,
107        int $totalItems,
108        Language $language = null,
109        string $url = null
110    ) {
111        if ($language) {
112            $this->setLanguage($language);
113        }
114        $this->setCurrentPage(static::sanitize($currentPage))
115            ->setItemsPerPage(static::sanitize($itemsPerPage))
116            ->setTotalItems($totalItems);
117        $lastPage = (int) \ceil($this->getTotalItems() / $this->getItemsPerPage());
118        $this->setLastPage(static::sanitize($lastPage));
119        if ($this->getCurrentPage() > 1) {
120            if ($this->getCurrentPage() - 1 <= $this->getLastPage()) {
121                $this->setPreviousPage($this->getCurrentPage() - 1);
122            } elseif ($this->getLastPage() > 1) {
123                $this->setPreviousPage($this->getLastPage());
124            }
125        }
126        if ($this->getCurrentPage() < $this->getLastPage()) {
127            $this->setNextPage($this->getCurrentPage() + 1);
128        }
129        isset($url) ? $this->setUrl($url) : $this->prepareUrl();
130    }
131
132    public function __toString() : string
133    {
134        return $this->render();
135    }
136
137    /**
138     * @since 3.5
139     *
140     * @return int
141     */
142    public function getItemsPerPage() : int
143    {
144        return $this->itemsPerPage;
145    }
146
147    /**
148     * @since 3.5
149     *
150     * @param int $itemsPerPage
151     *
152     * @return static
153     */
154    protected function setItemsPerPage(int $itemsPerPage) : static
155    {
156        $this->itemsPerPage = $itemsPerPage;
157        return $this;
158    }
159
160    /**
161     * @since 3.5
162     *
163     * @return int
164     */
165    public function getTotalItems() : int
166    {
167        return $this->totalItems;
168    }
169
170    /**
171     * @since 3.5
172     *
173     * @param int $totalItems
174     *
175     * @return static
176     */
177    protected function setTotalItems(int $totalItems) : static
178    {
179        $this->totalItems = $totalItems;
180        return $this;
181    }
182
183    /**
184     * @param Language|null $language
185     *
186     * @return static
187     */
188    public function setLanguage(Language $language = null) : static
189    {
190        $this->language = $language ?? new Language();
191        $this->language->addDirectory(__DIR__ . '/Languages');
192        return $this;
193    }
194
195    /**
196     * @return Language
197     */
198    public function getLanguage() : Language
199    {
200        if ( ! isset($this->language)) {
201            $this->setLanguage();
202        }
203        return $this->language;
204    }
205
206    /**
207     * @param string $name
208     * @param string $filepath
209     *
210     * @return static
211     */
212    public function setView(string $name, string $filepath) : static
213    {
214        if ( ! \is_file($filepath)) {
215            throw new InvalidArgumentException('Invalid Pager view filepath: ' . $filepath);
216        }
217        $this->views[$name] = $filepath;
218        return $this;
219    }
220
221    /**
222     * Get a view filepath.
223     *
224     * @param string $name The view name. Default names are: head, header, pager and pagination
225     *
226     * @return string
227     */
228    public function getView(string $name) : string
229    {
230        if (empty($this->views[$name])) {
231            throw new InvalidArgumentException('Pager view not found: ' . $name);
232        }
233        return $this->views[$name];
234    }
235
236    /**
237     * @return array<string,string>
238     */
239    #[Pure]
240    public function getViews() : array
241    {
242        return $this->views;
243    }
244
245    /**
246     * @return int
247     */
248    #[Pure]
249    public function getSurround() : int
250    {
251        return $this->surround;
252    }
253
254    /**
255     * @param int $surround
256     *
257     * @return static
258     */
259    public function setSurround(int $surround) : static
260    {
261        $this->surround = $surround < 0 ? 0 : $surround;
262        return $this;
263    }
264
265    /**
266     * @param string $query
267     *
268     * @return static
269     */
270    public function setQuery(string $query = 'page') : static
271    {
272        $this->query = $query;
273        return $this;
274    }
275
276    #[Pure]
277    public function getQuery() : string
278    {
279        return $this->query;
280    }
281
282    /**
283     * @param array<int,string> $allowed
284     *
285     * @return static
286     */
287    public function setAllowedQueries(array $allowed) : static
288    {
289        $this->setUrl($this->oldUrl, $allowed);
290        return $this;
291    }
292
293    protected function prepareUrl() : static
294    {
295        $scheme = ((isset($_SERVER['REQUEST_SCHEME']) && $_SERVER['REQUEST_SCHEME'] === 'https')
296            || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'))
297            ? 'https'
298            : 'http';
299        $this->setUrl($scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
300        return $this;
301    }
302
303    /**
304     * @param string|URL $currentPageUrl
305     * @param array<int,string> $allowedQueries
306     *
307     * @return static
308     */
309    public function setUrl(string | URL $currentPageUrl, array $allowedQueries = []) : static
310    {
311        $currentPageUrl = $currentPageUrl instanceof URL
312            ? clone $currentPageUrl
313            : new URL($currentPageUrl);
314        $this->oldUrl = $currentPageUrl->toString();
315        $allowedQueries[] = $this->getQuery();
316        $currentPageUrl->setQuery($currentPageUrl->getQuery() ?? '', $allowedQueries);
317        $this->url = $currentPageUrl;
318        return $this;
319    }
320
321    public function getUrl() : URL
322    {
323        return $this->url;
324    }
325
326    public function getPageUrl(?int $page) : ?string
327    {
328        if ($page === null || $page === 0) {
329            return null;
330        }
331        return $this->url->addQuery($this->getQuery(), $page)->toString();
332    }
333
334    public function getCurrentPage() : int
335    {
336        return $this->currentPage;
337    }
338
339    /**
340     * @since 3.5
341     *
342     * @param int $currentPage
343     *
344     * @return static
345     */
346    protected function setCurrentPage(int $currentPage) : static
347    {
348        $this->currentPage = $currentPage;
349        return $this;
350    }
351
352    public function getCurrentPageUrl() : string
353    {
354        return $this->getPageUrl($this->currentPage);
355    }
356
357    #[Pure]
358    public function getFirstPage() : int
359    {
360        return 1;
361    }
362
363    public function getFirstPageUrl() : string
364    {
365        return $this->getPageUrl($this->getFirstPage());
366    }
367
368    #[Pure]
369    public function getLastPage() : int
370    {
371        return $this->lastPage;
372    }
373
374    /**
375     * @since 3.5
376     *
377     * @param int $lastPage
378     *
379     * @return static
380     */
381    protected function setLastPage(int $lastPage) : static
382    {
383        $this->lastPage = $lastPage;
384        return $this;
385    }
386
387    public function getLastPageUrl() : string
388    {
389        return $this->getPageUrl($this->getLastPage());
390    }
391
392    #[Pure]
393    public function getPreviousPage() : ?int
394    {
395        return $this->previousPage;
396    }
397
398    /**
399     * @since 3.5
400     *
401     * @param int $previousPage
402     *
403     * @return static
404     */
405    protected function setPreviousPage(int $previousPage) : static
406    {
407        $this->previousPage = $previousPage;
408        return $this;
409    }
410
411    public function getPreviousPageUrl() : ?string
412    {
413        return $this->getPageUrl($this->getPreviousPage());
414    }
415
416    #[Pure]
417    public function getNextPage() : ?int
418    {
419        return $this->nextPage;
420    }
421
422    /**
423     * @since 3.5
424     *
425     * @param int $nextPage
426     *
427     * @return static
428     */
429    protected function setNextPage(int $nextPage) : static
430    {
431        $this->nextPage = $nextPage;
432        return $this;
433    }
434
435    public function getNextPageUrl() : ?string
436    {
437        return $this->getPageUrl($this->getNextPage());
438    }
439
440    /**
441     * @return array<int,string>
442     */
443    public function getPreviousPagesUrls() : array
444    {
445        $urls = [];
446        if ($this->currentPage > 1 && $this->currentPage <= $this->lastPage) {
447            $range = \range($this->currentPage - $this->surround, $this->currentPage - 1);
448            foreach ($range as $page) {
449                if ($page < 1) {
450                    continue;
451                }
452                $urls[$page] = $this->getPageUrl($page);
453            }
454        }
455        return $urls;
456    }
457
458    /**
459     * @return array<int,string>
460     */
461    public function getNextPagesUrls() : array
462    {
463        $urls = [];
464        if ($this->currentPage < $this->lastPage) {
465            $range = \range($this->currentPage + 1, $this->currentPage + $this->surround);
466            foreach ($range as $page) {
467                if ($page > $this->lastPage) {
468                    break;
469                }
470                $urls[$page] = $this->getPageUrl($page);
471            }
472        }
473        return $urls;
474    }
475
476    /**
477     * @return array<string,int|null>
478     */
479    #[ArrayShape([
480        'self' => 'int',
481        'first' => 'int',
482        'prev' => 'int|null',
483        'next' => 'int|null',
484        'last' => 'int',
485    ])]
486    #[Pure]
487    public function get() : array
488    {
489        return [
490            'self' => $this->getCurrentPage(),
491            'first' => $this->getFirstPage(),
492            'prev' => $this->getPreviousPage(),
493            'next' => $this->getNextPage(),
494            'last' => $this->getLastPage(),
495        ];
496    }
497
498    /**
499     * @return array<string,string|null>
500     */
501    #[ArrayShape([
502        'self' => 'string',
503        'first' => 'string',
504        'prev' => 'string|null',
505        'next' => 'string|null',
506        'last' => 'string',
507    ])]
508    public function getWithUrl() : array
509    {
510        return [
511            'self' => $this->getCurrentPageUrl(),
512            'first' => $this->getFirstPageUrl(),
513            'prev' => $this->getPreviousPageUrl(),
514            'next' => $this->getNextPageUrl(),
515            'last' => $this->getLastPageUrl(),
516        ];
517    }
518
519    /**
520     * @param string|null $view
521     *
522     * @return string
523     */
524    public function render(string $view = null) : string
525    {
526        $filename = $this->getView($view ?? $this->getDefaultView());
527        \ob_start();
528        Isolation::require($filename, ['pager' => $this]);
529        return (string) \ob_get_clean();
530    }
531
532    public function renderShort() : string
533    {
534        $view = $this->getDefaultView();
535        if ( ! \str_ends_with($view, '-short')) {
536            $view .= '-short';
537        }
538        return $this->render($view);
539    }
540
541    public function setDefaultView(string $defaultView) : static
542    {
543        if ( ! \array_key_exists($defaultView, $this->views)) {
544            throw new LogicException(
545                'Default view "' . $defaultView . '" is not a valid value'
546            );
547        }
548        $this->defaultView = $defaultView;
549        return $this;
550    }
551
552    #[Pure]
553    public function getDefaultView() : string
554    {
555        return $this->defaultView;
556    }
557
558    /**
559     * @return array<string,string|null>
560     */
561    #[ArrayShape([
562        'self' => 'string',
563        'first' => 'string',
564        'prev' => 'string|null',
565        'next' => 'string|null',
566        'last' => 'string',
567    ])]
568    public function jsonSerialize() : array
569    {
570        return $this->getWithUrl();
571    }
572
573    /**
574     * @param int|string $number
575     *
576     * @return int
577     *
578     * @deprecated Use sanitize method
579     *
580     * @codeCoverageIgnore
581     */
582    #[Deprecated(
583        reason: 'since version 3.1, use sanitize() instead',
584        replacement: '%class%::sanitize(%parameter0%)'
585    )]
586    public static function sanitizePageNumber(int | string $number) : int
587    {
588        \trigger_error(
589            'Method ' . __METHOD__ . ' is deprecated',
590            \E_USER_DEPRECATED
591        );
592        $number = $number < 1 || ! \is_numeric($number) ? 1 : $number;
593        return $number > \PHP_INT_MAX ? \PHP_INT_MAX : (int) $number;
594    }
595
596    /**
597     * Sanitize a number.
598     *
599     * @param mixed $number
600     *
601     * @return int
602     */
603    #[Pure]
604    public static function sanitize(mixed $number) : int
605    {
606        if ( ! \is_numeric($number)) {
607            return 1;
608        }
609        if ($number < 1) {
610            return 1;
611        }
612        return $number >= \PHP_INT_MAX ? \PHP_INT_MAX : (int) $number;
613    }
614}