Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.79% covered (danger)
5.79%
27 / 466
13.89% covered (danger)
13.89%
5 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Server
5.79% covered (danger)
5.79%
27 / 466
13.89% covered (danger)
13.89%
5 / 36
23204.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 setRecordLinkerHelper
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setRecordFormatter
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUTCDateTime
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getResponse
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
61.73
 attachDeleted
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 attachRecordHeader
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getVuFindMetadata
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
2
 attachNonDeleted
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
182
 getRecord
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 hasParam
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 identify
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 supportsVuFindMetadata
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 initializeMetadataFormats
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getMetadataFormats
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 initializeSettings
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
132
 listMetadataFormats
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
110
 listRecords
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
306
 listSets
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
 listRecordsGetDeleted
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 listRecordsGetDeletedCount
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 listRecordsGetNonDeleted
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 listRecordsGetParams
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
306
 isBadDate
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
132
 dateTimeCreationSuccessful
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 listRecordsValidateDates
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 loadRecord
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 loadResumptionToken
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeDate
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 prefixID
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 saveResumptionToken
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 showError
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 createResponse
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 stripID
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 unexpectedError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * OAI Server class
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 * Copyright (C) The National Library of Finland 2018-2019.
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  OAI_Server
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\OAI;
33
34use SimpleXMLElement;
35use VuFind\Db\Entity\ChangeTrackerEntityInterface;
36use VuFind\Db\Service\ChangeTrackerServiceInterface;
37use VuFind\Db\Service\OaiResumptionServiceInterface;
38use VuFind\Exception\RecordMissing as RecordMissingException;
39use VuFind\SimpleXML;
40use VuFindApi\Formatter\RecordFormatter;
41
42use function count;
43use function in_array;
44use function intval;
45use function strlen;
46
47/**
48 * OAI Server class
49 *
50 * This class provides OAI server functionality.
51 *
52 * @category VuFind
53 * @package  OAI_Server
54 * @author   Demian Katz <demian.katz@villanova.edu>
55 * @author   Ere Maijala <ere.maijala@helsinki.fi>
56 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
57 * @link     https://vufind.org/wiki/development Wiki
58 */
59class Server
60{
61    /**
62     * Repository base URL
63     *
64     * @var string
65     */
66    protected $baseURL;
67
68    /**
69     * Base URL of host containing VuFind.
70     *
71     * @var string
72     */
73    protected $baseHostURL;
74
75    /**
76     * Incoming request parameters
77     *
78     * @var array
79     */
80    protected $params;
81
82    /**
83     * Search object class to use
84     *
85     * @var string
86     */
87    protected $searchClassId = 'Solr';
88
89    /**
90     * What Solr core are we serving up?
91     *
92     * @var string
93     */
94    protected $core = 'biblio';
95
96    /**
97     * ISO-8601 date format
98     *
99     * @var string
100     */
101    protected $iso8601 = 'Y-m-d\TH:i:s\Z';
102
103    /**
104     * Records per page in lists
105     *
106     * @var int
107     */
108    protected $pageSize = 100;
109
110    /**
111     * Solr field for set membership
112     *
113     * @var string
114     */
115    protected $setField = null;
116
117    /**
118     * Supported metadata formats
119     *
120     * @var array
121     */
122    protected $metadataFormats = [];
123
124    /**
125     * Namespace used for ID prefixing (if any)
126     *
127     * @var string
128     */
129    protected $idNamespace = null;
130
131    /**
132     * Repository name used in "Identify" response
133     *
134     * @var string
135     */
136    protected $repositoryName = 'VuFind';
137
138    /**
139     * Earliest datestamp used in "Identify" response
140     *
141     * @var string
142     */
143    protected $earliestDatestamp = '2000-01-01T00:00:00Z';
144
145    /**
146     * Admin email used in "Identify" response
147     *
148     * @var string
149     */
150    protected $adminEmail;
151
152    /**
153     * Record link helper (optional)
154     *
155     * @var \VuFind\View\Helper\Root\RecordLinker
156     */
157    protected $recordLinkerHelper = null;
158
159    /**
160     * Set queries
161     *
162     * @var array
163     */
164    protected $setQueries = [];
165
166    /*
167     * Default query used when a set is not specified
168     *
169     * @var string
170     */
171    protected $defaultQuery = '';
172
173    /*
174     * Record formatter
175     *
176     * @var RecordFormatter
177     */
178    protected $recordFormatter = null;
179
180    /**
181     * Fields to return when the 'vufind' format is requested. Empty array means the
182     * format is disabled.
183     *
184     * @var array
185     */
186    protected $vufindApiFields = [];
187
188    /**
189     * Filter queries specific to the requested record format
190     *
191     * @var array
192     */
193    protected $recordFormatFilters = [];
194
195    /**
196     * Limit on display of deleted records (in days); older deleted records will not
197     * be returned by the server. Set to null for no limit.
198     *
199     * @var int
200     */
201    protected $deleteLifetime = null;
202
203    /**
204     * Should we use cursorMarks for Solr retrieval? Normally this is the best
205     * option, but it is incompatible with some other Solr features and may need
206     * to be disabled in rare circumstances (e.g. when using field collapsing/
207     * result grouping).
208     *
209     * @var bool
210     */
211    protected $useCursorMark = true;
212
213    /**
214     * Constructor
215     *
216     * @param \VuFind\Search\Results\PluginManager $resultsManager    Search manager for retrieving records
217     * @param \VuFind\Record\Loader                $recordLoader      Record loader
218     * @param ChangeTrackerServiceInterface        $trackerService    ChangeTracker Service
219     * @param OaiResumptionServiceInterface        $resumptionService Database service for resumption tokens
220     */
221    public function __construct(
222        protected \VuFind\Search\Results\PluginManager $resultsManager,
223        protected \VuFind\Record\Loader $recordLoader,
224        protected ChangeTrackerServiceInterface $trackerService,
225        protected OaiResumptionServiceInterface $resumptionService
226    ) {
227    }
228
229    /**
230     * Initialize settings
231     *
232     * @param \Laminas\Config\Config $config  VuFind configuration
233     * @param string                 $baseURL The base URL for the OAI server
234     * @param array                  $params  The incoming OAI-PMH parameters (i.e.
235     * $_GET)
236     *
237     * @return void
238     */
239    public function init(\Laminas\Config\Config $config, $baseURL, array $params)
240    {
241        $this->baseURL = $baseURL;
242        $parts = parse_url($baseURL);
243        $this->baseHostURL = $parts['scheme'] . '://' . $parts['host'];
244        if (isset($parts['port'])) {
245            $this->baseHostURL .= $parts['port'];
246        }
247        $this->params = $params;
248        $this->initializeSettings($config); // Load config.ini settings
249    }
250
251    /**
252     * Add a record linker helper (optional -- allows enhancement of some metadata
253     * with VuFind-specific links).
254     *
255     * @param \VuFind\View\Helper\Root\RecordLinker $helper Helper to set
256     *
257     * @return void
258     */
259    public function setRecordLinkerHelper($helper)
260    {
261        $this->recordLinkerHelper = $helper;
262    }
263
264    /**
265     * Add a record formatter (optional -- allows the vufind record format to be
266     * returned).
267     *
268     * @param RecordFormatter $formatter Record formatter
269     *
270     * @return void
271     */
272    public function setRecordFormatter($formatter)
273    {
274        $this->recordFormatter = $formatter;
275        // Reset metadata formats so they can be reinitialized; the formatter
276        // may enable additional options.
277        $this->metadataFormats = [];
278    }
279
280    /**
281     * Get the current UTC date/time in ISO 8601 format.
282     *
283     * @param string $time Time string to represent as UTC (default = 'now')
284     *
285     * @return string
286     */
287    protected function getUTCDateTime($time = 'now')
288    {
289        // All times must be in UTC, so translate the current time to the
290        // appropriate time zone:
291        $utc = new \DateTime($time, new \DateTimeZone('UTC'));
292        return date_format($utc, $this->iso8601);
293    }
294
295    /**
296     * Respond to the OAI-PMH request.
297     *
298     * @return string
299     */
300    public function getResponse()
301    {
302        if (!$this->hasParam('verb')) {
303            return $this->showError('badVerb', 'Missing Verb Argument');
304        } else {
305            switch ($this->params['verb']) {
306                case 'GetRecord':
307                    return $this->getRecord();
308                case 'Identify':
309                    return $this->identify();
310                case 'ListIdentifiers':
311                case 'ListRecords':
312                    return $this->listRecords($this->params['verb']);
313                case 'ListMetadataFormats':
314                    return $this->listMetadataFormats();
315                case 'ListSets':
316                    return $this->listSets();
317                default:
318                    return $this->showError('badVerb', 'Illegal OAI Verb');
319            }
320        }
321    }
322
323    /**
324     * Assign necessary interface variables to display a deleted record.
325     *
326     * @param SimpleXMLElement             $xml           XML to update
327     * @param ChangeTrackerEntityInterface $trackerEntity ChangeTracker entity
328     * @param bool                         $headerOnly    Only attach the header?
329     *
330     * @return void
331     */
332    protected function attachDeleted($xml, $trackerEntity, $headerOnly = false)
333    {
334        // Deleted records only have a header, no metadata. However, depending
335        // on the context we are attaching them, they may or may not need a
336        // <record> tag wrapping the header.
337        $record = $headerOnly ? $xml : $xml->addChild('record');
338        $this->attachRecordHeader(
339            $record,
340            $this->prefixID($trackerEntity->getId()),
341            date($this->iso8601, $trackerEntity->getDeleted()->getTimestamp()),
342            [],
343            'deleted'
344        );
345    }
346
347    /**
348     * Attach a record header to an XML document.
349     *
350     * @param SimpleXMLElement $xml    XML to update
351     * @param string           $id     Record id
352     * @param string           $date   Record modification date
353     * @param array            $sets   Set(s) containing record
354     * @param string           $status Record status code
355     *
356     * @return void
357     */
358    protected function attachRecordHeader(
359        $xml,
360        $id,
361        $date,
362        $sets = [],
363        $status = ''
364    ) {
365        $header = $xml->addChild('header');
366        if (!empty($status)) {
367            $header['status'] = $status;
368        }
369        $header->identifier = $id;
370        $header->datestamp = $date;
371        foreach ($sets as $set) {
372            $header->addChild('setSpec', htmlspecialchars($set));
373        }
374    }
375
376    /**
377     * Support method for attachNonDeleted() to build the VuFind metadata for
378     * a record driver.
379     *
380     * @param object $record A record driver object
381     *
382     * @return string
383     */
384    protected function getVuFindMetadata($record)
385    {
386        // Root node
387        $recordDoc = new \DOMDocument();
388        $vufindFormat = $this->getMetadataFormats()['oai_vufind_json'];
389        $rootNode = $recordDoc->createElementNS(
390            $vufindFormat['namespace'],
391            'oai_vufind_json:record'
392        );
393        $rootNode->setAttribute(
394            'xmlns:xsi',
395            'http://www.w3.org/2001/XMLSchema-instance'
396        );
397        $rootNode->setAttribute(
398            'xsi:schemaLocation',
399            $vufindFormat['namespace'] . ' ' . $vufindFormat['schema']
400        );
401        $recordDoc->appendChild($rootNode);
402
403        // Add oai_dc part
404        $oaiDc = new \DOMDocument();
405        $oaiDc->loadXML(
406            $record->getXML('oai_dc', $this->baseHostURL, $this->recordLinkerHelper)
407        );
408        $rootNode->appendChild(
409            $recordDoc->importNode($oaiDc->documentElement, true)
410        );
411
412        // Add VuFind metadata
413        $records = $this->recordFormatter->format(
414            [$record],
415            $this->vufindApiFields
416        );
417        $metadataNode = $recordDoc->createElementNS(
418            $vufindFormat['namespace'],
419            'oai_vufind_json:metadata'
420        );
421        $metadataNode->setAttribute('type', 'application/json');
422        $metadataNode->appendChild(
423            $recordDoc->createCDATASection(json_encode($records[0]))
424        );
425        $rootNode->appendChild($metadataNode);
426
427        return $recordDoc->saveXML();
428    }
429
430    /**
431     * Attach a non-deleted record to an XML document.
432     *
433     * @param SimpleXMLElement $container  XML container for new record
434     * @param object           $record     A record driver object
435     * @param string           $format     Metadata format to obtain (false for none)
436     * @param bool             $headerOnly Only attach the header?
437     * @param string           $set        Currently active set
438     *
439     * @return bool
440     */
441    protected function attachNonDeleted(
442        $container,
443        $record,
444        $format,
445        $headerOnly = false,
446        $set = ''
447    ) {
448        // Get the XML (and display an error if it is unsupported):
449        if ($format === false) {
450            $xml = '';      // no metadata if in header-only mode!
451        } elseif ('oai_vufind_json' === $format && $this->supportsVuFindMetadata()) {
452            $xml = $this->getVuFindMetadata($record);   // special case
453        } else {
454            $xml = $record
455                ->getXML($format, $this->baseHostURL, $this->recordLinkerHelper);
456            if ($xml === false) {
457                return false;
458            }
459        }
460
461        // Headers should be returned only if the metadata format matching
462        // the supplied metadataPrefix is available.
463        // If RecordDriver returns nothing, skip this record.
464        if (empty($xml)) {
465            return true;
466        }
467
468        // Check for sets:
469        $fields = $record->getRawData();
470        if (null !== $this->setField && !empty($fields[$this->setField])) {
471            $sets = (array)$fields[$this->setField];
472        } else {
473            $sets = [];
474        }
475        if (!empty($set)) {
476            $sets = array_unique(array_merge($sets, [$set]));
477        }
478
479        // Get modification date:
480        $date = $record->getLastIndexed();
481        if (empty($date)) {
482            $date = $this->getUTCDateTime('now');
483        }
484
485        // Set up header (inside or outside a <record> container depending on
486        // preferences):
487        $recXml = $headerOnly ? $container : $container->addChild('record');
488        $this->attachRecordHeader(
489            $recXml,
490            $this->prefixID($record->getUniqueID()),
491            $date,
492            $sets
493        );
494
495        // Inject metadata if necessary:
496        if (!$headerOnly && !empty($xml)) {
497            $metadata = $recXml->addChild('metadata');
498            SimpleXML::appendElement($metadata, $xml);
499        }
500
501        return true;
502    }
503
504    /**
505     * Respond to a GetRecord request.
506     *
507     * @return string
508     */
509    protected function getRecord()
510    {
511        // Validate parameters
512        if (!$this->hasParam('metadataPrefix')) {
513            return $this->showError('badArgument', 'Missing Metadata Prefix');
514        }
515        if (!$this->hasParam('identifier')) {
516            return $this->showError('badArgument', 'Missing Identifier');
517        }
518
519        // Start building response
520        $response = $this->createResponse();
521        $xml = $response->addChild('GetRecord');
522
523        // Retrieve the record from the index
524        if ($record = $this->loadRecord($this->params['identifier'])) {
525            $success = $this->attachNonDeleted(
526                $xml,
527                $record,
528                $this->params['metadataPrefix']
529            );
530            if (!$success) {
531                return $this->showError('cannotDisseminateFormat', 'Unknown Format');
532            }
533        } else {
534            // No record in index -- is this deleted?
535
536            $row = $this->trackerService->getChangeTrackerEntity(
537                $this->core,
538                $this->stripID($this->params['identifier'])
539            );
540            if (!empty($row) && !empty($row->getDeleted())) {
541                $this->attachDeleted($xml, $row);
542            } else {
543                // Not deleted and not found in index -- error!
544                return $this->showError('idDoesNotExist', 'Unknown Record');
545            }
546        }
547
548        // Display the record:
549        return $response->asXML();
550    }
551
552    /**
553     * Was the specified parameter provided?
554     *
555     * @param string $param Name of the parameter to check.
556     *
557     * @return bool         True if parameter is set and non-empty.
558     */
559    protected function hasParam($param)
560    {
561        return isset($this->params[$param]) && !empty($this->params[$param]);
562    }
563
564    /**
565     * Respond to an Identify request:
566     *
567     * @return string
568     */
569    protected function identify()
570    {
571        $response = $this->createResponse();
572        $xml = $response->addChild('Identify');
573        $xml->repositoryName = $this->repositoryName;
574        $xml->baseURL = $this->baseURL;
575        $xml->protocolVersion = '2.0';
576        $xml->adminEmail = $this->adminEmail;
577        $xml->earliestDatestamp = $this->earliestDatestamp;
578        $xml->deletedRecord = 'transient';
579        $xml->granularity = 'YYYY-MM-DDThh:mm:ssZ';
580        if (!empty($this->idNamespace)) {
581            $id = $xml->addChild('description')->addChild(
582                'oai-identifier',
583                null,
584                'http://www.openarchives.org/OAI/2.0/oai-identifier'
585            );
586            $id->addAttribute(
587                'xsi:schemaLocation',
588                'http://www.openarchives.org/OAI/2.0/oai-identifier '
589                . 'http://www.openarchives.org/OAI/2.0/oai-identifier.xsd',
590                'http://www.w3.org/2001/XMLSchema-instance'
591            );
592            $id->scheme = 'oai';
593            $id->repositoryIdentifier = $this->idNamespace;
594            $id->delimiter = ':';
595            $id->sampleIdentifier = 'oai:' . $this->idNamespace . ':123456';
596        }
597
598        return $response->asXML();
599    }
600
601    /**
602     * Does the current configuration support the VuFind metadata format (using
603     * the API's record formatter.
604     *
605     * @return bool
606     */
607    protected function supportsVuFindMetadata()
608    {
609        return !empty($this->vufindApiFields) && null !== $this->recordFormatter;
610    }
611
612    /**
613     * Initialize data about metadata formats. (This is called on demand and is
614     * defined as a separate method to allow easy override by child classes).
615     *
616     * @return void
617     */
618    protected function initializeMetadataFormats()
619    {
620        $this->metadataFormats['oai_dc'] = [
621            'schema' => 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd',
622            'namespace' => 'http://www.openarchives.org/OAI/2.0/oai_dc/'];
623        $this->metadataFormats['marc21'] = [
624            'schema' => 'http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd',
625            'namespace' => 'http://www.loc.gov/MARC21/slim'];
626
627        if ($this->supportsVuFindMetadata()) {
628            $this->metadataFormats['oai_vufind_json'] = [
629                'schema' => 'https://vufind.org/xsd/oai_vufind_json-1.0.xsd',
630                'namespace' => 'http://vufind.org/oai_vufind_json-1.0',
631            ];
632        } else {
633            unset($this->metadataFormats['oai_vufind_json']);
634        }
635    }
636
637    /**
638     * Get metadata formats; initialize the list if necessary.
639     *
640     * @return array
641     */
642    protected function getMetadataFormats()
643    {
644        if (empty($this->metadataFormats)) {
645            $this->initializeMetadataFormats();
646        }
647        return $this->metadataFormats;
648    }
649
650    /**
651     * Load data from the OAI section of config.ini. (This is called by the
652     * constructor and is only a separate method to allow easy override by child
653     * classes).
654     *
655     * @param \Laminas\Config\Config $config VuFind configuration
656     *
657     * @return void
658     */
659    protected function initializeSettings(\Laminas\Config\Config $config)
660    {
661        // Override default repository name if configured:
662        if (isset($config->OAI->repository_name)) {
663            $this->repositoryName = $config->OAI->repository_name;
664        }
665
666        // Override default ID namespace if configured:
667        if (isset($config->OAI->identifier)) {
668            $this->idNamespace = $config->OAI->identifier;
669        }
670
671        // Override page size if configured:
672        if (isset($config->OAI->page_size)) {
673            $this->pageSize = $config->OAI->page_size;
674        }
675
676        // Use either OAI-specific or general email address; we must have SOMETHING.
677        $this->adminEmail = $config->OAI->admin_email ?? $config->Site->email;
678
679        // Use a Solr field to determine sets, if configured:
680        if (isset($config->OAI->set_field)) {
681            $this->setField = $config->OAI->set_field;
682        }
683
684        // Initialize custom sets queries:
685        if (isset($config->OAI->set_query)) {
686            $this->setQueries = $config->OAI->set_query->toArray();
687        }
688
689        // Use a default query, if configured:
690        if (isset($config->OAI->default_query)) {
691            $this->defaultQuery = $config->OAI->default_query;
692        }
693
694        // Initialize VuFind API format fields:
695        $this->vufindApiFields = array_filter(
696            explode(
697                ',',
698                $config->OAI->vufind_api_format_fields ?? ''
699            )
700        );
701
702        // Initialize filters specific to requested metadataPrefix:
703        if (isset($config->OAI->record_format_filters)) {
704            $this->recordFormatFilters
705                = $config->OAI->record_format_filters->toArray();
706        }
707
708        // Initialize delete lifetime, if set:
709        if (isset($config->OAI->delete_lifetime)) {
710            $this->deleteLifetime = intval($config->OAI->delete_lifetime);
711        }
712
713        // Change cursormark behavior if necessary:
714        $cursor = $config->OAI->use_cursor ?? true;
715        if (!$cursor || strtolower($cursor) === 'false') {
716            $this->useCursorMark = false;
717        }
718    }
719
720    /**
721     * Respond to a ListMetadataFormats request.
722     *
723     * @return string
724     */
725    protected function listMetadataFormats()
726    {
727        // If a specific ID was provided, try to load the related record; otherwise,
728        // set $record to false so we know it is a generic request.
729        if (isset($this->params['identifier'])) {
730            if (!($record = $this->loadRecord($this->params['identifier']))) {
731                return $this->showError('idDoesNotExist', 'Unknown Record');
732            }
733        } else {
734            $record = false;
735        }
736
737        // Loop through all available metadata formats and see if they apply in
738        // the current context (all apply if $record is false, since that
739        // means that no specific record ID was requested; otherwise, they only
740        // apply if the current record driver supports them):
741        $response = $this->createResponse();
742        $xml = $response->addChild('ListMetadataFormats');
743        foreach ($this->getMetadataFormats() as $prefix => $details) {
744            if (
745                $record === false
746                || $record->getXML($prefix) !== false
747                || ('oai_vufind_json' === $prefix && $this->supportsVuFindMetadata())
748            ) {
749                $node = $xml->addChild('metadataFormat');
750                $node->metadataPrefix = $prefix;
751                if (isset($details['schema'])) {
752                    $node->schema = $details['schema'];
753                }
754                if (isset($details['namespace'])) {
755                    $node->metadataNamespace = $details['namespace'];
756                }
757            }
758        }
759
760        // Display the response:
761        return $response->asXML();
762    }
763
764    /**
765     * Respond to a ListIdentifiers or ListRecords request (the $verb parameter
766     * determines the exact format of the response).
767     *
768     * @param string $verb 'ListIdentifiers' or 'ListRecords'
769     *
770     * @return string
771     */
772    protected function listRecords($verb = 'ListRecords')
773    {
774        // Load and validate parameters; if an Exception is thrown, we need to parse
775        // and output an appropriate error.
776        try {
777            $params = $this->listRecordsGetParams();
778        } catch (\Exception $e) {
779            $parts = explode(':', $e->getMessage(), 2);
780            if (count($parts) != 2) {
781                throw $e;
782            }
783            return $this->showError($parts[0], $parts[1]);
784        }
785
786        // Normalize the provided dates into Unix timestamps. Depending on whether
787        // they come from the OAI-PMH request or the database, the format may be
788        // slightly different; this ensures they are reduced to a consistent value!
789        $from = $this->normalizeDate($params['from']);
790        $until = $this->normalizeDate($params['until'], '23:59:59');
791        if (!$this->listRecordsValidateDates($from, $until)) {
792            return;
793        }
794
795        // Copy the cursor from the parameters so we can track our current position
796        // separately from our initial position!
797        $currentCursor = $params['cursor'];
798
799        $response = $this->createResponse();
800        $xml = $response->addChild($verb);
801
802        // The verb determines whether we're returning headers only or full records:
803        $headersOnly = ($verb != 'ListRecords');
804
805        // Apply the delete lifetime limit to the from date if necessary:
806        $deleteCutoff = $this->deleteLifetime
807            ? strtotime('-' . $this->deleteLifetime . ' days') : 0;
808        $deleteFrom = ($deleteCutoff < $from) ? $from : $deleteCutoff;
809
810        // Get deleted records in the requested range (if applicable):
811        $deletedCount = $this->listRecordsGetDeletedCount($deleteFrom, $until);
812        if ($deletedCount > 0 && $currentCursor < $deletedCount) {
813            $deleted = $this
814                ->listRecordsGetDeleted($deleteFrom, $until, $currentCursor);
815            foreach ($deleted as $current) {
816                $this->attachDeleted($xml, $current, $headersOnly);
817                $currentCursor++;
818            }
819        }
820
821        // Figure out how many non-deleted records we need to display:
822        $recordLimit = ($params['cursor'] + $this->pageSize) - $currentCursor;
823        // Depending on cursormark mode, we either need to get the latest mark or
824        // else calculate a Solr offset.
825        if ($this->useCursorMark) {
826            $offset = $cursorMark = $params['cursorMark'] ?? '';
827        } else {
828            $cursorMark = ''; // always empty for checks below
829            $offset = ($currentCursor >= $deletedCount)
830                ? $currentCursor - $deletedCount : 0;
831        }
832        $format = $params['metadataPrefix'];
833
834        // Get non-deleted records from the Solr index:
835        $set = $params['set'] ?? '';
836        $result = $this->listRecordsGetNonDeleted(
837            $from,
838            $until,
839            $offset,
840            $recordLimit,
841            $format,
842            $set
843        );
844        $nonDeletedCount = $result->getResultTotal();
845        foreach ($result->getResults() as $doc) {
846            $this->attachNonDeleted($xml, $doc, $format, $headersOnly, $set);
847            $currentCursor++;
848        }
849        // We only need a cursor mark if we fetched results from Solr; if our
850        // $recordLimit is 0, it means that we're still in the process of
851        // retrieving deleted records, and we're only hitting Solr to obtain a
852        // total record count. Therefore, we don't want to change the cursor
853        // mark yet, or it will break pagination of deleted records.
854        $nextCursorMark = $recordLimit > 0 ? $result->getCursorMark() : '';
855
856        // If our cursor didn't reach the last record, we need a resumption token!
857        $listSize = $deletedCount + $nonDeletedCount;
858        if (
859            $listSize > $currentCursor
860            && ('' === $cursorMark || $nextCursorMark !== $cursorMark)
861        ) {
862            $this->saveResumptionToken(
863                $xml,
864                $params,
865                $currentCursor,
866                $listSize,
867                $nextCursorMark
868            );
869        } elseif ($params['cursor'] > 0) {
870            // If we reached the end of the list but there is more than one page, we
871            // still need to display an empty <resumptionToken> tag:
872            $token = $xml->addChild('resumptionToken');
873            $token->addAttribute('completeListSize', $listSize);
874            $token->addAttribute('cursor', $params['cursor']);
875        }
876
877        return $response->asXML();
878    }
879
880    /**
881     * Respond to a ListSets request.
882     *
883     * @return string
884     */
885    protected function listSets()
886    {
887        // Resumption tokens are not currently supported for this verb:
888        if ($this->hasParam('resumptionToken')) {
889            return $this->showError(
890                'badResumptionToken',
891                'Invalid resumption token'
892            );
893        }
894
895        // If no set field is enabled, we can't provide a set list:
896        if (null === $this->setField && empty($this->setQueries)) {
897            return $this->showError('noSetHierarchy', 'Sets not supported');
898        }
899
900        // Begin building XML:
901        $response = $this->createResponse();
902        $xml = $response->addChild('ListSets');
903
904        // Load set field if applicable:
905        if (null !== $this->setField) {
906            // If we got this far, we can load all available set values. For now,
907            // we'll assume that this list is short enough to load in one response;
908            // it may be necessary to implement a resumption token mechanism if this
909            // proves not to be the case:
910            $results = $this->resultsManager->get($this->searchClassId);
911            try {
912                $facets = $results->getFullFieldFacets([$this->setField]);
913            } catch (\Exception $e) {
914                $facets = null;
915            }
916            if (empty($facets) || !isset($facets[$this->setField]['data']['list'])) {
917                $this->unexpectedError('Cannot find sets');
918            }
919
920            // Extract facet values from the Solr response:
921            foreach ($facets[$this->setField]['data']['list'] as $x) {
922                $set = $xml->addChild('set');
923                $set->setSpec = $x['value'];
924                $set->setName = $x['displayText'];
925            }
926        }
927
928        // Iterate over custom sets:
929        if (!empty($this->setQueries)) {
930            foreach ($this->setQueries as $setName => $solrQuery) {
931                $set = $xml->addChild('set');
932                $set->setName = $set->setSpec = $setName;
933                $set->setDescription = $solrQuery;
934            }
935        }
936
937        // Display the list:
938        return $response->asXML();
939    }
940
941    /**
942     * Get an object containing the next page of deleted records from the specified
943     * date range.
944     *
945     * @param int $from          Start date.
946     * @param int $until         End date.
947     * @param int $currentCursor Offset into result set
948     *
949     * @return ChangeTrackerEntityInterface[]
950     */
951    protected function listRecordsGetDeleted($from, $until, $currentCursor)
952    {
953        return $this->trackerService->getDeletedEntities(
954            $this->core,
955            \DateTime::createFromFormat('U', $from),
956            \DateTime::createFromFormat('U', $until),
957            $currentCursor,
958            $this->pageSize
959        );
960    }
961
962    /**
963     * Get a count of all deleted records in the specified date range.
964     *
965     * @param int $from  Start date.
966     * @param int $until End date.
967     *
968     * @return int
969     */
970    protected function listRecordsGetDeletedCount($from, $until)
971    {
972        return $this->trackerService->getDeletedCount(
973            $this->core,
974            \DateTime::createFromFormat('U', $from),
975            \DateTime::createFromFormat('U', $until)
976        );
977    }
978
979    /**
980     * Get an array of information on non-deleted records in the specified range.
981     *
982     * @param int    $from   Start date.
983     * @param int    $until  End date.
984     * @param mixed  $offset Solr offset, or cursorMark for the position in the full
985     * result list (depending on settings).
986     * @param int    $limit  Max number of full records to return.
987     * @param string $format Requested record format
988     * @param string $set    Set to limit to (empty string for none).
989     *
990     * @return \VuFind\Search\Base\Results Search result object.
991     */
992    protected function listRecordsGetNonDeleted(
993        $from,
994        $until,
995        $offset,
996        $limit,
997        $format,
998        $set = ''
999    ) {
1000        // Set up search parameters:
1001        $results = $this->resultsManager->get($this->searchClassId);
1002        $params = $results->getParams();
1003        $params->setLimit($limit);
1004        $params->getOptions()->disableHighlighting();
1005        $params->getOptions()->spellcheckEnabled(false);
1006        $params->setSort('last_indexed asc, id asc', true);
1007
1008        // Construct a range query based on last indexed time:
1009        $params->setOverrideQuery(
1010            'last_indexed:[' . date($this->iso8601, $from) . ' TO '
1011            . date($this->iso8601, $until) . ']'
1012        );
1013
1014        // Apply filters as needed.
1015        if (!empty($set)) {
1016            if (isset($this->setQueries[$set])) {
1017                // Put parentheses around the query so that it does not get
1018                // parsed as a simple field:value filter.
1019                $params->addFilter('(' . $this->setQueries[$set] . ')');
1020            } elseif (null !== $this->setField) {
1021                $params->addFilter(
1022                    $this->setField . ':"' . addcslashes($set, '"') . '"'
1023                );
1024            }
1025        } elseif ($this->defaultQuery) {
1026            // Put parentheses around the query so that it does not get
1027            // parsed as a simple field:value filter.
1028            $params->addFilter('(' . $this->defaultQuery . ')');
1029        }
1030
1031        if (!empty($this->recordFormatFilters[$format])) {
1032            $params->addFilter($this->recordFormatFilters[$format]);
1033        }
1034
1035        // Perform a Solr search:
1036        if ($this->useCursorMark) {
1037            $results->overrideStartRecord(1);
1038            $results->setCursorMark($offset);
1039        } else {
1040            $results->overrideStartRecord($offset + 1);
1041        }
1042
1043        // Return our results:
1044        return $results;
1045    }
1046
1047    /**
1048     * Get parameters for use in the listRecords method.
1049     *
1050     * @throws \Exception
1051     * @return mixed Array of parameters or false on error
1052     */
1053    protected function listRecordsGetParams()
1054    {
1055        // If we received a resumption token, use it to override any existing
1056        // parameters or fail if it is invalid.
1057        if (!empty($this->params['resumptionToken'])) {
1058            $params = $this->loadResumptionToken($this->params['resumptionToken']);
1059            if ($params === false) {
1060                throw new \Exception(
1061                    'badResumptionToken:Invalid or expired resumption token'
1062                );
1063            }
1064
1065            // Merge restored parameters with incoming parameters:
1066            $params = array_merge($params, $this->params);
1067        } else {
1068            // No resumption token?  Use the provided parameters:
1069            $params = $this->params;
1070
1071            // Make sure we don't act on any user-provided cursor settings; this
1072            // value should only be set in association with resumption tokens!
1073            $params['cursor'] = 0;
1074
1075            // Set default date range if not already provided:
1076            if (empty($params['from'])) {
1077                $params['from'] = $this->earliestDatestamp;
1078                if (
1079                    !empty($params['until'])
1080                    && strlen($params['from']) > strlen($params['until'])
1081                ) {
1082                    $params['from'] = substr($params['from'], 0, 10);
1083                }
1084            }
1085            if (empty($params['until'])) {
1086                $params['until'] = $this->getUTCDateTime('now +1 day');
1087                if (strlen($params['until']) > strlen($params['from'])) {
1088                    $params['until'] = substr($params['until'], 0, 10);
1089                }
1090            }
1091            if ($this->isBadDate($params['from'], $params['until'])) {
1092                throw new \Exception('badArgument:Bad Date Format');
1093            }
1094        }
1095
1096        // If no set field is configured and a set parameter comes in, we have a
1097        // problem:
1098        if (
1099            null === $this->setField && empty($this->setQueries)
1100            && !empty($params['set'])
1101        ) {
1102            throw new \Exception('noSetHierarchy:Sets not supported');
1103        }
1104
1105        // Validate set parameter:
1106        if (
1107            !empty($params['set']) && null === $this->setField
1108            && !isset($this->setQueries[$params['set']])
1109        ) {
1110            throw new \Exception('badArgument:Invalid set specified');
1111        }
1112
1113        if (!isset($params['metadataPrefix'])) {
1114            throw new \Exception('badArgument:Missing metadataPrefix');
1115        }
1116
1117        // Validate requested metadata format:
1118        $prefixes = array_keys($this->getMetadataFormats());
1119        if (!in_array($params['metadataPrefix'], $prefixes)) {
1120            throw new \Exception('cannotDisseminateFormat:Unknown Format');
1121        }
1122
1123        return $params;
1124    }
1125
1126    /**
1127     * Validate the from and until parameters for the listRecords method.
1128     *
1129     * @param int $from  String for start date.
1130     * @param int $until String for end date.
1131     *
1132     * @return bool      True if invalid, false if not.
1133     */
1134    protected function isBadDate($from, $until)
1135    {
1136        $dt = \DateTime::createFromFormat('Y-m-d', substr($until, 0, 10));
1137        if (!$this->dateTimeCreationSuccessful($dt)) {
1138            return true;
1139        }
1140        $dt = \DateTime::createFromFormat('Y-m-d', substr($from, 0, 10));
1141        if (!$this->dateTimeCreationSuccessful($dt)) {
1142            return true;
1143        }
1144        // Check for different date granularity
1145        if (strpos($from, 'T') && strpos($from, 'Z')) {
1146            if (strpos($until, 'T') && strpos($until, 'Z')) {
1147                // This is good
1148            } else {
1149                return true;
1150            }
1151        } elseif (strpos($until, 'T') && strpos($until, 'Z')) {
1152            return true;
1153        }
1154
1155        $from_time = $this->normalizeDate($from);
1156        $until_time = $this->normalizeDate($until, '23:59:59');
1157        if ($from_time > $until_time) {
1158            throw new \Exception('noRecordsMatch:from vs. until');
1159        }
1160        if ($from_time < $this->normalizeDate($this->earliestDatestamp)) {
1161            return true;
1162        }
1163        return false;
1164    }
1165
1166    /**
1167     * Check if a DateTime was successfully created without errors or warnings
1168     *
1169     * @param \DateTime|false $dt DateTime or false (return value of createFromFormat)
1170     *
1171     * @return bool
1172     */
1173    protected function dateTimeCreationSuccessful(\DateTime|false $dt): bool
1174    {
1175        // Return value false is always an error:
1176        if (false === $dt) {
1177            return false;
1178        }
1179        $errors = $dt->getLastErrors();
1180        // getLastErrors returns false if no errors on PHP 8.2 and later:
1181        if (false === $errors) {
1182            return true;
1183        }
1184        // getLastErrors returns an array with no errors on PHP 8.1:
1185        return empty($errors['errors']) && empty($errors['warnings']);
1186    }
1187
1188    /**
1189     * Validate the from and until parameters for the listRecords method.
1190     *
1191     * @param int $from  Timestamp for start date.
1192     * @param int $until Timestamp for end date.
1193     *
1194     * @return bool      True if valid, false if not.
1195     */
1196    protected function listRecordsValidateDates($from, $until)
1197    {
1198        // Validate dates:
1199        if (!$from || !$until) {
1200            return $this->showError('badArgument', 'Bad Date Format');
1201        }
1202        if ($from > $until) {
1203            return $this->showError(
1204                'badArgument',
1205                'End date must be after start date'
1206            );
1207        }
1208        if ($from < $this->normalizeDate($this->earliestDatestamp)) {
1209            return $this->showError(
1210                'badArgument',
1211                'Start date must be after earliest date'
1212            );
1213        }
1214
1215        // If we got this far, everything is valid!
1216        return true;
1217    }
1218
1219    /**
1220     * Load a specific record from the index.
1221     *
1222     * @param string $id The record ID to load
1223     *
1224     * @return mixed     The record array (if successful) or false
1225     */
1226    protected function loadRecord($id)
1227    {
1228        // Strip the ID prefix, if necessary:
1229        $id = $this->stripID($id);
1230        if ($id !== false) {
1231            try {
1232                return $this->recordLoader->load($id, $this->searchClassId);
1233            } catch (RecordMissingException $e) {
1234                return false;
1235            }
1236        }
1237        return false;
1238    }
1239
1240    /**
1241     * Load parameters associated with a resumption token.
1242     *
1243     * @param string $token The resumption token to look up
1244     *
1245     * @return array        Parameters associated with token
1246     */
1247    protected function loadResumptionToken($token)
1248    {
1249        // Clean up expired records before doing our search:
1250        $this->resumptionService->removeExpired();
1251
1252        // Load the requested token if it still exists:
1253        if ($row = $this->resumptionService->findToken($token)) {
1254            parse_str($row->getResumptionParameters(), $params);
1255            return $params;
1256        }
1257
1258        // If we got this far, the token is invalid or expired:
1259        return false;
1260    }
1261
1262    /**
1263     * Normalize a date to a Unix timestamp.
1264     *
1265     * @param string $date Date (ISO-8601 or YYYY-MM-DD HH:MM:SS)
1266     * @param string $time Default time to use if $date has no time attached
1267     *
1268     * @return integer     Unix timestamp (or false if $date invalid)
1269     */
1270    protected function normalizeDate($date, $time = '00:00:00')
1271    {
1272        // Remove timezone markers -- we don't want PHP to outsmart us by adjusting
1273        // the time zone!
1274        if (strlen($date) == 10) {
1275            $date .= ' ' . $time;
1276        } else {
1277            $date = str_replace(['T', 'Z'], [' ', ''], $date);
1278        }
1279
1280        // Translate to a timestamp:
1281        return strtotime($date);
1282    }
1283
1284    /**
1285     * Prepend the OAI prefix to the provided ID number.
1286     *
1287     * @param string $id The ID to update.
1288     *
1289     * @return string    The prefixed ID.
1290     */
1291    protected function prefixID($id)
1292    {
1293        $prefix = empty($this->idNamespace)
1294            ? '' : 'oai:' . $this->idNamespace . ':';
1295        return $prefix . $id;
1296    }
1297
1298    /**
1299     * Generate a resumption token to continue the current operation.
1300     *
1301     * @param SimpleXMLElement $xml           XML document to update with token.
1302     * @param array            $params        Current operational parameters.
1303     * @param int              $currentCursor Current cursor position in search
1304     * results.
1305     * @param int              $listSize      Total size of search results.
1306     * @param string           $cursorMark    cursorMark for the position in the full
1307     * results list.
1308     *
1309     * @return void
1310     */
1311    protected function saveResumptionToken(
1312        $xml,
1313        $params,
1314        $currentCursor,
1315        $listSize,
1316        $cursorMark
1317    ) {
1318        // Save the old cursor position before overwriting it for storage in the
1319        // database!
1320        $oldCursor = $params['cursor'];
1321        $params['cursor'] = $currentCursor;
1322        $params['cursorMark'] = $cursorMark;
1323
1324        // Save everything to the database:
1325        $expire = time() + 24 * 60 * 60;
1326        $token = $this->resumptionService->createAndPersistToken($params, $expire)->getId();
1327
1328        // Add details to the xml:
1329        $token = $xml->addChild('resumptionToken', $token);
1330        $token->addAttribute('cursor', $oldCursor);
1331        $token->addAttribute('expirationDate', date($this->iso8601, $expire));
1332        $token->addAttribute('completeListSize', $listSize);
1333    }
1334
1335    /**
1336     * Display an error response.
1337     *
1338     * @param string $code    The error code to display
1339     * @param string $message The error string to display
1340     *
1341     * @return string
1342     */
1343    protected function showError($code, $message)
1344    {
1345        // Certain errors should not echo parameters:
1346        $echoParams = !($code == 'badVerb' || $code == 'badArgument');
1347        $response = $this->createResponse($echoParams);
1348
1349        $xml = $response->addChild('error', htmlspecialchars($message));
1350        if (!empty($code)) {
1351            $xml['code'] = $code;
1352        }
1353
1354        return $response->asXML();
1355    }
1356
1357    /**
1358     * Create an OAI-PMH response (shared support method used by various
1359     * response-specific methods).
1360     *
1361     * @param bool $echoParams Include params in <request> tag?
1362     *
1363     * @return SimpleXMLElement
1364     */
1365    protected function createResponse($echoParams = true)
1366    {
1367        // Set up standard response wrapper:
1368        $xml = simplexml_load_string(
1369            '<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/" />'
1370        );
1371        $xml->addAttribute(
1372            'xsi:schemaLocation',
1373            'http://www.openarchives.org/OAI/2.0/ '
1374            . 'http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd',
1375            'http://www.w3.org/2001/XMLSchema-instance'
1376        );
1377        $xml->responseDate = $this->getUTCDateTime('now');
1378        $xml->request = $this->baseURL;
1379        if ($echoParams) {
1380            foreach ($this->params as $key => $value) {
1381                $xml->request[$key] = $value;
1382            }
1383        }
1384
1385        return $xml;
1386    }
1387
1388    /**
1389     * Strip the OAI prefix from the provided ID number.
1390     *
1391     * @param string $id The ID to strip.
1392     *
1393     * @return string    The stripped ID (false if prefix invalid).
1394     */
1395    protected function stripID($id)
1396    {
1397        // No prefix?  No stripping!
1398        if (empty($this->idNamespace)) {
1399            return $id;
1400        }
1401
1402        // Prefix?  Strip it off and return the stripped version if valid:
1403        $prefix = 'oai:' . $this->idNamespace . ':';
1404        if (str_starts_with($id, $prefix)) {
1405            return substr($id, strlen($prefix));
1406        }
1407
1408        // Invalid prefix -- unrecognized ID:
1409        return false;
1410    }
1411
1412    /**
1413     * Die with an unexpected error code (when something outside the scope of
1414     * OAI-PMH fails).
1415     *
1416     * @param string $msg Error message
1417     *
1418     * @throws \Exception
1419     * @return void
1420     */
1421    protected function unexpectedError($msg)
1422    {
1423        throw new \Exception("Unexpected fatal error -- {$msg}.");
1424    }
1425}