Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.55% covered (warning)
63.55%
129 / 203
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResultScroller
63.55% covered (warning)
63.55%
129 / 203
63.16% covered (warning)
63.16%
12 / 19
307.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addData
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 ensureRoomInSessionStorage
12.50% covered (danger)
12.50%
1 / 8
0.00% covered (danger)
0.00%
0 / 1
21.75
 scrollOnCurrentPage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fetchPreviousPage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 fetchNextPage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 scrollToPreviousPage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 scrollToNextPage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 scrollToFirstRecord
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 scrollToLastRecord
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getFirstRecordId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getLastPageNumber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLastRecordId
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getScrollData
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 buildScrollDataArray
72.50% covered (warning)
72.50%
29 / 40
0.00% covered (danger)
0.00%
0 / 1
26.51
 fetchPage
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 restoreSearch
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
12
 rememberSearch
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * Class for managing "next" and "previous" navigation within result sets.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010
9 * Copyright (C) The National Library of Finland 2023
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Controller_Plugins
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org/wiki/development Wiki
30 */
31
32namespace VuFind\Controller\Plugin;
33
34use Exception;
35use Laminas\Mvc\Controller\Plugin\AbstractPlugin;
36use Laminas\Session\Container as SessionContainer;
37use VuFind\Db\Service\SearchServiceInterface;
38use VuFind\RecordDriver\AbstractBase as BaseRecord;
39use VuFind\Search\Base\Results;
40use VuFind\Search\Memory as SearchMemory;
41use VuFind\Search\Results\PluginManager as ResultsManager;
42
43use function count;
44use function is_array;
45
46/**
47 * Class for managing "next" and "previous" navigation within result sets.
48 *
49 * @category VuFind
50 * @package  Controller_Plugins
51 * @author   Demian Katz <demian.katz@villanova.edu>
52 * @author   Ere Maijala <ere.maijala@helsinki.fi>
53 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
54 * @link     https://vufind.org/wiki/development Wiki
55 */
56class ResultScroller extends AbstractPlugin
57{
58    /**
59     * Maximum number of last searches to track
60     *
61     * @var int
62     */
63    public const LAST_SEARCH_LIMIT = 10;
64
65    /**
66     * Is scroller enabled?
67     *
68     * @var bool
69     */
70    protected $enabled;
71
72    /**
73     * Session data used by scroller
74     *
75     * @var SessionContainer
76     */
77    protected $session;
78
79    /**
80     * Results manager
81     *
82     * @var ResultsManager
83     */
84    protected $resultsManager;
85
86    /**
87     * Search memory
88     *
89     * @var SearchMemory
90     */
91    protected $searchMemory;
92
93    /**
94     * Currently active scroll data
95     *
96     * @var \stdClass
97     */
98    protected $data = null;
99
100    /**
101     * Constructor. Create a new search result scroller.
102     *
103     * @param SessionContainer $session Session container
104     * @param ResultsManager   $rm      Results manager
105     * @param SearchMemory     $sm      Search memory
106     * @param bool             $enabled Is the scroller enabled?
107     */
108    public function __construct(
109        SessionContainer $session,
110        ResultsManager $rm,
111        SearchMemory $sm,
112        $enabled = true
113    ) {
114        $this->enabled = $enabled;
115        $this->session = $session;
116        $this->resultsManager = $rm;
117        $this->searchMemory = $sm;
118    }
119
120    /**
121     * Initialize this result set scroller. This should only be called
122     * prior to displaying the results of a new search.
123     *
124     * @param Results $searchObject The search object that was used to execute the
125     * last search.
126     *
127     * @return bool True if enabled and initialized with results, false otherwise.
128     */
129    public function init($searchObject)
130    {
131        // Do nothing if disabled or search is empty:
132        if (!$this->enabled || $searchObject->getResultTotal() <= 0) {
133            return false;
134        }
135
136        // Save the details of this search in the session:
137        $this->addData($searchObject);
138        return (bool)$this->session->s[$searchObject->getSearchId()]->currIds;
139    }
140
141    /**
142     * Add data to session for a search
143     *
144     * @param Results $searchObject Search object
145     *
146     * @return void
147     */
148    protected function addData(Results $searchObject): void
149    {
150        $data = new \stdClass();
151        $data->page = $searchObject->getParams()->getPage();
152        $data->limit = $searchObject->getParams()->getLimit();
153        $data->sort = $searchObject->getParams()->getSort();
154        $data->total = $searchObject->getResultTotal();
155        $data->firstlast = $searchObject->getOptions()->recordFirstLastNavigationEnabled();
156
157        // save the IDs of records on the current page to the session
158        // so we can "slide" from one record to the next/previous records
159        // spanning 2 consecutive pages
160        $data->currIds = $this->fetchPage($searchObject);
161
162        // Store last access time for eviction
163        $data->lastAccessTime = time();
164
165        if (!isset($this->session->s)) {
166            $this->session->s = [];
167        }
168
169        $this->ensureRoomInSessionStorage();
170        $this->session->s[$searchObject->getSearchId()] = $data;
171    }
172
173    /**
174     * Make room for a new entry in the session storage as necessary
175     *
176     * @return void
177     */
178    protected function ensureRoomInSessionStorage(): void
179    {
180        // Evict oldest entry if storage is full:
181        while (count($this->session->s) >= static::LAST_SEARCH_LIMIT) {
182            $oldest = null;
183            $oldestTime = null;
184            foreach ($this->session->s as $id => $search) {
185                if (null === $oldest || $search->lastAccessTime < $oldestTime) {
186                    $oldest = $id;
187                    $oldestTime = $search->lastAccessTime;
188                }
189            }
190            unset($this->session->s[$oldest]);
191        }
192    }
193
194    /**
195     * Return a modified results array to help scroll the user through the current
196     * page of results
197     *
198     * @param array $retVal Return values (in progress)
199     * @param int   $pos    Current position within current page
200     *
201     * @return array
202     */
203    protected function scrollOnCurrentPage($retVal, $pos)
204    {
205        $retVal['previousRecord'] = $this->data->currIds[$pos - 1];
206        $retVal['nextRecord'] = $this->data->currIds[$pos + 1];
207        // and we're done
208        return $retVal;
209    }
210
211    /**
212     * Return a modified results array for the case where the user is on the cusp of
213     * the previous page of results
214     *
215     * @param array   $retVal     Return values (in progress)
216     * @param Results $lastSearch Representation of last search
217     * @param int     $pos        Current position within current
218     * page
219     * @param int     $count      Size of current page of results
220     *
221     * @return array
222     */
223    protected function fetchPreviousPage($retVal, $lastSearch, $pos, $count)
224    {
225        // if the current page is NOT the first page, and
226        // the previous page has not been fetched before, then
227        // fetch the previous page
228        if ($this->data->page > 1 && $this->data->prevIds == null) {
229            $this->data->prevIds = $this->fetchPage(
230                $lastSearch,
231                $this->data->page - 1
232            );
233        }
234
235        // if there is something on the previous page, then the previous
236        // record is the last record on the previous page
237        if (!empty($this->data->prevIds)) {
238            $retVal['previousRecord']
239                = $this->data->prevIds[count($this->data->prevIds) - 1];
240        }
241
242        // if it is not the last record on the current page, then
243        // we also have a next record on the current page
244        if ($pos < $count - 1) {
245            $retVal['nextRecord'] = $this->data->currIds[$pos + 1];
246        }
247
248        // and we're done
249        return $retVal;
250    }
251
252    /**
253     * Return a modified results array for the case where the user is on the cusp of
254     * the next page of results
255     *
256     * @param array   $retVal     Return values (in progress)
257     * @param Results $lastSearch Representation of last search
258     * @param int     $pos        Current position within current
259     * page
260     *
261     * @return array
262     */
263    protected function fetchNextPage($retVal, $lastSearch, $pos)
264    {
265        // if the current page is NOT the last page, and the next page has not been
266        // fetched, then fetch the next page
267        if (
268            $this->data->page < ceil($this->data->total / $this->data->limit)
269            && $this->data->nextIds == null
270        ) {
271            $this->data->nextIds = $this->fetchPage(
272                $lastSearch,
273                $this->data->page + 1
274            );
275        }
276
277        // if there is something on the next page, then the next
278        // record is the first record on the next page
279        if (is_array($this->data->nextIds) && count($this->data->nextIds) > 0) {
280            $retVal['nextRecord'] = $this->data->nextIds[0];
281        }
282
283        // if it is not the first record on the current page, then
284        // we also have a previous record on the current page
285        if ($pos > 0) {
286            $retVal['previousRecord'] = $this->data->currIds[$pos - 1];
287        }
288
289        // and we're done
290        return $retVal;
291    }
292
293    /**
294     * Return a modified results array for the case where we need to retrieve data
295     * from the previous page of results
296     *
297     * @param array   $retVal     Return values (in progress)
298     * @param Results $lastSearch Representation of last search
299     * @param int     $pos        Current position within
300     * previous page
301     *
302     * @return array
303     */
304    protected function scrollToPreviousPage($retVal, $lastSearch, $pos)
305    {
306        // decrease the page in the session because
307        // we're now sliding into the previous page
308        // (-- doesn't work on ArrayObjects)
309        $this->data->page = $this->data->page - 1;
310
311        // shift pages to the right
312        $tmp = $this->data->currIds;
313        $this->data->currIds = $this->data->prevIds;
314        $this->data->nextIds = $tmp;
315        $this->data->prevIds = null;
316
317        // now we can set the previous/next record
318        if ($pos > 0) {
319            $retVal['previousRecord']
320                = $this->data->currIds[$pos - 1];
321        }
322        $retVal['nextRecord'] = $this->data->nextIds[0];
323
324        // recalculate the current position
325        $retVal['currentPosition']
326            = ($this->data->page - 1)
327            * $this->data->limit + $pos + 1;
328
329        // update the search URL in the session
330        $lastSearch->getParams()->setPage($this->data->page);
331        $this->rememberSearch($lastSearch);
332
333        // and we're done
334        return $retVal;
335    }
336
337    /**
338     * Return a modified results array for the case where we need to retrieve data
339     * from the next page of results
340     *
341     * @param array   $retVal     Return values (in progress)
342     * @param Results $lastSearch Representation of last search
343     * @param int     $pos        Current position within next
344     * page
345     *
346     * @return array
347     */
348    protected function scrollToNextPage($retVal, $lastSearch, $pos)
349    {
350        // increase the page in the session because
351        // we're now sliding into the next page
352        // (++ doesn't work on ArrayObjects)
353        $this->data->page = $this->data->page + 1;
354
355        // shift pages to the left
356        $tmp = $this->data->currIds;
357        $this->data->currIds = $this->data->nextIds;
358        $this->data->prevIds = $tmp;
359        $this->data->nextIds = null;
360
361        // now we can set the previous/next record
362        $retVal['previousRecord']
363            = $this->data->prevIds[count($this->data->prevIds) - 1];
364        if ($pos < count($this->data->currIds) - 1) {
365            $retVal['nextRecord'] = $this->data->currIds[$pos + 1];
366        }
367
368        // recalculate the current position
369        $retVal['currentPosition']
370            = ($this->data->page - 1)
371            * $this->data->limit + $pos + 1;
372
373        // update the search URL in the session
374        $lastSearch->getParams()->setPage($this->data->page);
375        $this->rememberSearch($lastSearch);
376
377        // and we're done
378        return $retVal;
379    }
380
381    /**
382     * Return a modified results array for the case where we need to retrieve data
383     * from the the first page of results
384     *
385     * @param array   $retVal     Return values (in progress)
386     * @param Results $lastSearch Representation of last search
387     *
388     * @return array
389     */
390    protected function scrollToFirstRecord($retVal, $lastSearch)
391    {
392        // Set page in session to First Page
393        $this->data->page = 1;
394        // update the search URL in the session
395        $lastSearch->getParams()->setPage($this->data->page);
396        $this->rememberSearch($lastSearch);
397
398        // update current, next and prev Ids
399        $this->data->currIds = $this->fetchPage($lastSearch, $this->data->page);
400        $this->data->nextIds = $this->fetchPage($lastSearch, $this->data->page + 1);
401        $this->data->prevIds = null;
402
403        // now we can set the previous/next record
404        $retVal['previousRecord'] = null;
405        $retVal['nextRecord'] = $this->data->currIds[1] ?? null;
406        // cover extremely unlikely edge case -- page size of 1:
407        if (null === $retVal['nextRecord'] && isset($this->data->nextIds[0])) {
408            $retVal['nextRecord'] = $this->data->nextIds[0];
409        }
410
411        // recalculate the current position
412        $retVal['currentPosition'] = 1;
413
414        // and we're done
415        return $retVal;
416    }
417
418    /**
419     * Return a modified results array for the case where we need to retrieve data
420     * from the the last page of results
421     *
422     * @param array   $retVal     Return values (in progress)
423     * @param Results $lastSearch Representation of last search
424     *
425     * @return array
426     */
427    protected function scrollToLastRecord($retVal, $lastSearch)
428    {
429        // Set page in session to Last Page
430        $this->data->page = $this->getLastPageNumber();
431        // update the search URL in the session
432        $lastSearch->getParams()->setPage($this->data->page);
433        $this->rememberSearch($lastSearch);
434
435        // update current, next and prev Ids
436        $this->data->currIds = $this->fetchPage($lastSearch, $this->data->page);
437        $this->data->prevIds = $this->fetchPage($lastSearch, $this->data->page - 1);
438        $this->data->nextIds = null;
439
440        // recalculate the current position
441        $retVal['currentPosition'] = $this->data->total;
442
443        // now we can set the previous/next record
444        $retVal['nextRecord'] = null;
445        if (count($this->data->currIds) > 1) {
446            $pos = count($this->data->currIds) - 2;
447            $retVal['previousRecord'] = $this->data->currIds[$pos];
448        } elseif (count($this->data->prevIds) > 0) {
449            $prevPos = count($this->data->prevIds) - 1;
450            $retVal['previousRecord'] = $this->data->prevIds[$prevPos];
451        }
452
453        // and we're done
454        return $retVal;
455    }
456
457    /**
458     * Get the ID of the first record in the result set.
459     *
460     * @param Results $lastSearch Representation of last search
461     *
462     * @return string
463     */
464    protected function getFirstRecordId($lastSearch)
465    {
466        if (!isset($this->data->firstId)) {
467            $firstPage = $this->fetchPage($lastSearch, 1);
468            $this->data->firstId = $firstPage[0];
469        }
470        return $this->data->firstId;
471    }
472
473    /**
474     * Calculate the last page number in the result set.
475     *
476     * @return int
477     */
478    protected function getLastPageNumber()
479    {
480        return ceil($this->data->total / $this->data->limit);
481    }
482
483    /**
484     * Get the ID of the last record in the result set.
485     *
486     * @param Results $lastSearch Representation of last search
487     *
488     * @return string
489     */
490    protected function getLastRecordId($lastSearch)
491    {
492        if (!isset($this->data->lastId)) {
493            $results = $this->fetchPage($lastSearch, $this->getLastPageNumber());
494            $this->data->lastId = array_pop($results);
495        }
496        return $this->data->lastId;
497    }
498
499    /**
500     * Get the previous/next record in the last search
501     * result set relative to the current one, also return
502     * the position of the current record in the result set.
503     * Return array('previousRecord'=>previd, 'nextRecord'=>nextid,
504     * 'currentPosition'=>number, 'resultTotal'=>number).
505     *
506     * @param BaseRecord $driver Driver for the record currently being displayed
507     *
508     * @return array
509     */
510    public function getScrollData($driver)
511    {
512        $retVal = [
513            'firstRecord' => null, 'lastRecord' => null,
514            'previousRecord' => null, 'nextRecord' => null,
515            'currentPosition' => null, 'resultTotal' => null,
516        ];
517
518        $searchId = $this->searchMemory->getLastSearchId();
519        // Process scroll data only if enabled and data exists:
520        if (
521            !$this->enabled || !$searchId || !isset($this->session->s[$searchId])
522            || !($lastSearch = $this->restoreSearch($searchId))
523        ) {
524            return $retVal;
525        }
526        $this->data = $this->session->s[$searchId];
527        // Get results:
528        $result = $this->buildScrollDataArray($retVal, $driver, $lastSearch);
529        // Touch and update session with any changes:
530        $this->data->lastAccessTime = time();
531        $this->session->s[$searchId] = $this->data;
532
533        return $result;
534    }
535
536    /**
537     * Build and return the scroll data array
538     *
539     * @param array      $retVal     Return values (in progress)
540     * @param BaseRecord $driver     Driver for the record currently being displayed
541     * @param Results    $lastSearch Representation of last search
542     *
543     * @return array
544     */
545    protected function buildScrollDataArray(
546        array $retVal,
547        BaseRecord $driver,
548        Results $lastSearch
549    ): array {
550        // Make sure expected data elements are populated:
551        if (!isset($this->data->prevIds)) {
552            $this->data->prevIds = null;
553        }
554        if (!isset($this->data->nextIds)) {
555            $this->data->nextIds = null;
556        }
557
558        // Store total result set size:
559        $retVal['resultTotal'] = $this->data->total ?? 0;
560
561        // Set first and last record IDs
562        if ($this->data->firstlast) {
563            $retVal['firstRecord'] = $this->getFirstRecordId($lastSearch);
564            $retVal['lastRecord'] = $this->getLastRecordId($lastSearch);
565        }
566
567        // build a full ID string using the driver:
568        $id = $driver->getSourceIdentifier() . '|' . $driver->getUniqueId();
569
570        // find where this record is in the current result page
571        $pos = is_array($this->data->currIds)
572            ? array_search($id, $this->data->currIds)
573            : false;
574        if ($pos !== false) {
575            // OK, found this record in the current result page
576            // calculate its position relative to the result set
577            $retVal['currentPosition']
578                = ($this->data->page - 1) * $this->data->limit + $pos + 1;
579
580            // count how many records in the current result page
581            $count = count($this->data->currIds);
582            if ($pos > 0 && $pos < $count - 1) {
583                // the current record is somewhere in the middle of the current
584                // page, ie: not first or last
585                return $this->scrollOnCurrentPage($retVal, $pos);
586            } elseif ($pos == 0) {
587                // this record is first record on the current page
588                return $this
589                    ->fetchPreviousPage($retVal, $lastSearch, $pos, $count);
590            } elseif ($pos == $count - 1) {
591                // this record is last record on the current page
592                return $this->fetchNextPage($retVal, $lastSearch, $pos);
593            }
594        } else {
595            // the current record is not on the current page
596            // if there is something on the previous page
597            if (!empty($this->data->prevIds)) {
598                // check if current record is on the previous page
599                $pos = is_array($this->data->prevIds)
600                    ? array_search($id, $this->data->prevIds) : false;
601                if ($pos !== false) {
602                    return $this
603                        ->scrollToPreviousPage($retVal, $lastSearch, $pos);
604                }
605            }
606            // if there is something on the next page
607            if (!empty($this->data->nextIds)) {
608                // check if current record is on the next page
609                $pos = is_array($this->data->nextIds)
610                    ? array_search($id, $this->data->nextIds) : false;
611                if ($pos !== false) {
612                    return $this->scrollToNextPage($retVal, $lastSearch, $pos);
613                }
614            }
615            if ($this->data->firstlast) {
616                if ($id == $retVal['firstRecord']) {
617                    return $this->scrollToFirstRecord($retVal, $lastSearch);
618                }
619                if ($id == $retVal['lastRecord']) {
620                    return $this->scrollToLastRecord($retVal, $lastSearch);
621                }
622            }
623        }
624
625        return $retVal;
626    }
627
628    /**
629     * Fetch the given page of results from the given search object and
630     * return the IDs of the records in an array.
631     *
632     * @param object $searchObject The search object to use to execute the search
633     * @param int    $page         The page number to fetch (null for current)
634     *
635     * @return array
636     */
637    protected function fetchPage($searchObject, $page = null)
638    {
639        if (null !== $page) {
640            $searchObject->getParams()->setPage($page);
641            $searchObject->performAndProcessSearch();
642        }
643
644        $retVal = [];
645        foreach ($searchObject->getResults() as $record) {
646            if (!($record instanceof BaseRecord)) {
647                return false;
648            }
649            $retVal[]
650                = $record->getSourceIdentifier() . '|' . $record->getUniqueId();
651        }
652        return $retVal;
653    }
654
655    /**
656     * Restore a saved search.
657     *
658     * @param int $searchId Search ID
659     *
660     * @return ?Results
661     */
662    protected function restoreSearch(int $searchId): ?Results
663    {
664        $searchService = $this->getController()->getDbService(SearchServiceInterface::class);
665        $row = $searchService->getSearchByIdAndOwner(
666            $searchId,
667            $this->session->getManager()->getId(),
668            null
669        );
670        if (!empty($row)) {
671            $search = $row->getSearchObject()?->deminify($this->resultsManager);
672            if (!$search) {
673                throw new Exception("Problem getting search object from search {$row->getId()}.");
674            }
675            // The saved search does not remember its original limit or sort;
676            // we should reapply them from the session data:
677            $search->getParams()->setLimit(
678                $this->session->s[$searchId]->limit ?? null
679            );
680            $search->getParams()->setSort(
681                $this->session->s[$searchId]->sort ?? null
682            );
683            return $search;
684        }
685        return null;
686    }
687
688    /**
689     * Update the remembered "last search" in the session.
690     *
691     * @param Results $search Search object to remember.
692     *
693     * @return void
694     */
695    protected function rememberSearch($search)
696    {
697        $baseUrl = $this->getController()->url()->fromRoute(
698            $search->getOptions()->getSearchAction()
699        );
700        $this->searchMemory->rememberSearch(
701            $baseUrl . $search->getUrlQuery()->getParams(false),
702            $search->getSearchId()
703        );
704    }
705}