Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.34% covered (warning)
76.34%
242 / 317
73.33% covered (warning)
73.33%
33 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
MarcAdvancedTrait
76.34% covered (warning)
76.34%
242 / 317
73.33% covered (warning)
73.33%
33 / 45
423.45
0.00% covered (danger)
0.00%
0 / 1
 getAccessRestrictions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllSubjectHeadings
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getAllSubjectHeadingsRecordOrder
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getAllSubjectHeadingsNumericalOrder
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 processSubjectHeadings
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 getAwards
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBibliographicLevel
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
 getBibliographyNotes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilteredXML
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFindingAids
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGeneralNotes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHumanReadablePublicationDates
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNewerTitles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getPlacesOfPublication
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPlayingTimes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getPreviousTitles
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getProductionCredits
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPublicationFrequency
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRelationshipNotes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSeries
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
3.65
 getSeriesFromMARC
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getSummary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSystemDetails
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTargetAudienceNotes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleSection
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleStatement
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTOC
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 getHierarchicalPlaceNames
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getURLs
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 getAllRecordLinks
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 getRecordLinkNote
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
9.00
 getFieldData
42.55% covered (danger)
42.55%
20 / 47
0.00% covered (danger)
0.00%
0 / 1
87.44
 getIdFromLinkingField
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 extractSingleMarcDetail
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getFormattedMarcDetails
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 getXML
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
5.17
 getRDFXML
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 getConsortialIDs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCleanISMN
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getCleanNBN
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 getTitlesAltScript
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFullTitlesAltScript
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getShortTitlesAltScript
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubtitlesAltScript
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitleSectionsAltScript
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Functions to add advanced MARC-driven functionality to a record driver already
5 * powered by the standard index spec. Depends upon MarcReaderTrait.
6 *
7 * PHP version 8
8 *
9 * Copyright (C) Villanova University 2017.
10 * Copyright (C) The National Library of Finland 2020.
11 *
12 * This program is free software; you can redistribute it and/or modify
13 * it under the terms of the GNU General Public License version 2,
14 * as published by the Free Software Foundation.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
19 * GNU General Public License for more details.
20 *
21 * You should have received a copy of the GNU General Public License
22 * along with this program; if not, write to the Free Software
23 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
24 *
25 * @category VuFind
26 * @package  RecordDrivers
27 * @author   Demian Katz <demian.katz@villanova.edu>
28 * @author   Ere Maijala <ere.maijala@helsinki.fi>
29 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
30 * @link     https://vufind.org/wiki/development:plugins:record_drivers Wiki
31 */
32
33namespace VuFind\RecordDriver\Feature;
34
35use VuFind\View\Helper\Root\RecordLinker;
36use VuFind\XSLT\Processor as XSLTProcessor;
37
38use function count;
39use function in_array;
40use function is_array;
41
42/**
43 * Functions to add advanced MARC-driven functionality to a record driver already
44 * powered by the standard index spec. Depends upon MarcReaderTrait.
45 *
46 * @category VuFind
47 * @package  RecordDrivers
48 * @author   Demian Katz <demian.katz@villanova.edu>
49 * @author   Ere Maijala <ere.maijala@helsinki.fi>
50 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
51 * @link     https://vufind.org/wiki/development:plugins:record_drivers Wiki
52 */
53trait MarcAdvancedTrait
54{
55    /**
56     * Fields that may contain subject headings, and their descriptions
57     *
58     * @var array
59     */
60    protected $subjectFields = [
61        '600' => 'personal name',
62        '610' => 'corporate name',
63        '611' => 'meeting name',
64        '630' => 'uniform title',
65        '648' => 'chronological',
66        '650' => 'topic',
67        '651' => 'geographic',
68        '653' => '',
69        '655' => 'genre/form',
70        '656' => 'occupation',
71    ];
72
73    /**
74     * Mappings from subject source indicators (2nd indicator of subject fields in
75     * MARC 21) to the their codes.
76     *
77     * @var  array
78     * @link https://www.loc.gov/marc/bibliographic/bd6xx.html     Subject field docs
79     * @link https://www.loc.gov/standards/sourcelist/subject.html Code list
80     */
81    protected $subjectSources = [
82        '0' => 'lcsh',
83        '1' => 'lcshac',
84        '2' => 'mesh',
85        '3' => 'nal',
86        '4' => 'unknown',
87        '5' => 'cash',
88        '6' => 'rvm',
89    ];
90
91    /**
92     * Type to export in getXML().
93     *
94     * @var string
95     */
96    protected $xmlType = 'Bibliographic';
97
98    /**
99     * Get access restriction notes for the record.
100     *
101     * @return array
102     */
103    public function getAccessRestrictions()
104    {
105        return $this->getFieldArray('506');
106    }
107
108    /**
109     * Get all subject headings associated with this record. Each heading is
110     * returned as an array of chunks, increasing from least specific to most
111     * specific.
112     *
113     * @param bool $extended Whether to return a keyed array with the following
114     * keys:
115     * - heading: the actual subject heading chunks
116     * - type: heading type
117     * - source: source vocabulary
118     *
119     * @return array
120     */
121    public function getAllSubjectHeadings($extended = false)
122    {
123        if (($this->mainConfig->Record->marcSubjectHeadingsSort ?? '') === 'numerical') {
124            $returnValues = $this->getAllSubjectHeadingsNumericalOrder($extended);
125        } else {
126            // Default | value === 'record'
127            $returnValues = $this->getAllSubjectHeadingsRecordOrder($extended);
128        }
129
130        // Remove duplicates and then send back everything we collected:
131        return array_map(
132            'unserialize',
133            array_unique(array_map('serialize', $returnValues))
134        );
135    }
136
137    /**
138     * Get all subject headings associated with this record. Each heading is
139     * returned as an array of chunks, increasing from least specific to most
140     * specific. Sorted in the same way it is saved for the record.
141     *
142     * @param bool $extended Whether to return a keyed array with the following
143     *  keys:
144     *  - heading: the actual subject heading chunks
145     *  - type: heading type
146     *  - source: source vocabulary
147     *
148     * @return array
149     */
150    protected function getAllSubjectHeadingsRecordOrder(bool $extended = false): array
151    {
152        $returnValues = [];
153        $allFields = $this->getMarcReader()->getAllFields();
154        $subjectFieldsKeys = array_keys($this->subjectFields);
155        // Go through all the fields and handle them if they are part of what we want
156        foreach ($allFields as $field) {
157            if (isset($field['tag']) && in_array($field['tag'], $subjectFieldsKeys)) {
158                $fieldType = $this->subjectFields[$field['tag']];
159                if ($nextLine = $this->processSubjectHeadings($field, $extended, $fieldType)) {
160                    $returnValues[] = $nextLine;
161                }
162            }
163        }
164        return $returnValues;
165    }
166
167    /**
168     * Get all subject headings associated with this record. Each heading is
169     * returned as an array of chunks, increasing from least specific to most
170     * specific. Sorted numerically on marc fields.
171     *
172     * @param bool $extended Whether to return a keyed array with the following
173     *  keys:
174     *  - heading: the actual subject heading chunks
175     *  - type: heading type
176     *  - source: source vocabulary
177     *
178     * @return array
179     */
180    protected function getAllSubjectHeadingsNumericalOrder(bool $extended = false): array
181    {
182        $returnValues = [];
183        // Try each MARC field one at a time:
184        foreach ($this->subjectFields as $field => $fieldType) {
185            // Do we have any results for the current field?  If not, try the next.
186            $fields = $this->getMarcReader()->getFields($field);
187            if (!$fields) {
188                continue;
189            }
190
191            // If we got here, we found results -- let's loop through them.
192            foreach ($fields as $f) {
193                if ($nextLine = $this->processSubjectHeadings($f, $extended, $fieldType)) {
194                    $returnValues[] = $nextLine;
195                }
196            }
197        }
198        return $returnValues;
199    }
200
201    /**
202     * Get subject headings of a given record field.
203     * The heading is returned as a chunk, increasing from least specific to most specific.
204     *
205     * @param array  $field     field to handle
206     * @param bool   $extended  Whether to return a keyed array with the following keys:
207     *                          - heading: the actual subject heading chunks - type:
208     *                          heading type - source: source vocabulary
209     * @param string $fieldType Type of the field
210     *
211     * @return ?array
212     */
213    protected function processSubjectHeadings(
214        array $field,
215        bool $extended,
216        string $fieldType
217    ): ?array {
218        // Start an array for holding the chunks of the current heading:
219        $current = [];
220
221        // Get all the chunks and collect them together:
222        foreach ($field['subfields'] as $subfield) {
223            // Numeric subfields are for control purposes and should not
224            // be displayed:
225            if (!is_numeric($subfield['code'])) {
226                $current[] = $subfield['data'];
227            }
228        }
229        // If we found at least one chunk, add a heading to our result:
230        if (!empty($current)) {
231            if ($extended) {
232                $sourceIndicator = $field['i2'];
233                $source = $this->subjectSources[$sourceIndicator]
234                    ?? $this->getSubfield($field, '2');
235                return [
236                    'heading' => $current,
237                    'type' => $fieldType,
238                    'source' => $source,
239                    'id' => $this->getSubfield($field, '0'),
240                ];
241            } else {
242                return $current;
243            }
244        }
245        return null;
246    }
247
248    /**
249     * Get award notes for the record.
250     *
251     * @return array
252     */
253    public function getAwards()
254    {
255        return $this->getFieldArray('586');
256    }
257
258    /**
259     * Get the bibliographic level of the current record.
260     *
261     * @return string
262     */
263    public function getBibliographicLevel()
264    {
265        $leader = $this->getMarcReader()->getLeader();
266        $biblioLevel = strtoupper($leader[7]);
267
268        switch ($biblioLevel) {
269            case 'M': // Monograph
270                return 'Monograph';
271            case 'S': // Serial
272                return 'Serial';
273            case 'A': // Monograph Part
274                return 'MonographPart';
275            case 'B': // Serial Part
276                return 'SerialPart';
277            case 'C': // Collection
278                return 'Collection';
279            case 'D': // Collection Part
280                return 'CollectionPart';
281            case 'I': // Integrating Resource
282                return 'IntegratingResource';
283            default:
284                return 'Unknown';
285        }
286    }
287
288    /**
289     * Get notes on bibliography content.
290     *
291     * @return array
292     */
293    public function getBibliographyNotes()
294    {
295        return $this->getFieldArray('504');
296    }
297
298    /**
299     * Return full record as filtered XML for public APIs.
300     *
301     * @return string
302     */
303    public function getFilteredXML()
304    {
305        // The default implementation does not filter out any fields. You can do
306        // simple filtering using MarcReader's getFilteredRecord method, or more
307        // complex changes by using the XML DOM.
308        //
309        // Example for removing field 520, 9xx fields and subfield 0 from all fields
310        // with getFilteredRecord:
311        //
312        //
313        // return $this->getMarcReader()->getFilteredRecord(
314        //     [
315        //         [
316        //             'tag' => '520',
317        //         ],
318        //         [
319        //             'tag' => '9..',
320        //         ],
321        //         [
322        //             'tag' => '...',
323        //             'subfields' => '0',
324        //         ],
325        //     ]
326        // )->toFormat('MARCXML');
327        //
328        //
329        // Example for removing field 520 using DOM (note that the fields must be
330        // removed in a second loop to not affect the iteration of fields) and adding
331        // a new 955 field:
332        //
333        // $collection = new \DOMDocument();
334        // $collection->preserveWhiteSpace = false;
335        // $collection->loadXML($this->getMarcReader()->toFormat('MARCXML'));
336        // $record = $collection->getElementsByTagName('record')->item(0);
337        // $fieldsToRemove = [];
338        // foreach ($record->getElementsByTagName('datafield') as $field) {
339        //     $tag = $field->getAttribute('tag');
340        //     if ('520' === $tag) {
341        //         $fieldsToRemove[] = $field;
342        //     }
343        // }
344        // foreach ($fieldsToRemove as $field) {
345        //     $record->removeChild($field);
346        // }
347        //
348        // $field = $collection->createElement('datafield');
349        // $tag = $collection->createAttribute('tag');
350        // $tag->value = '955';
351        // $field->appendChild($tag);
352        // $ind1 = $collection->createAttribute('ind1');
353        // $ind1->value = ' ';
354        // $field->appendChild($ind1);
355        // $ind2 = $collection->createAttribute('ind2');
356        // $ind2->value = ' ';
357        // $field->appendChild($ind2);
358        // $subfield = $collection->createElement('subfield');
359        // $code = $collection->createAttribute('code');
360        // $code->value = 'a';
361        // $subfield->appendChild($code);
362        // $subfield->appendChild($collection->createTextNode('VuFind'));
363        // $field->appendChild($subfield);
364        // $record->appendChild($field);
365        //
366        // return $collection->saveXML();
367
368        return $this->getMarcReader()->toFormat('MARCXML');
369    }
370
371    /**
372     * Get notes on finding aids related to the record.
373     *
374     * @return array
375     */
376    public function getFindingAids()
377    {
378        return $this->getFieldArray('555');
379    }
380
381    /**
382     * Get general notes on the record.
383     *
384     * @return array
385     */
386    public function getGeneralNotes()
387    {
388        return $this->getFieldArray('500');
389    }
390
391    /**
392     * Get human readable publication dates for display purposes (may not be suitable
393     * for computer processing -- use getPublicationDates() for that).
394     *
395     * @return array
396     */
397    public function getHumanReadablePublicationDates()
398    {
399        return $this->getPublicationInfo('c');
400    }
401
402    /**
403     * Get an array of newer titles for the record.
404     *
405     * @return array
406     */
407    public function getNewerTitles()
408    {
409        // If the MARC links are being used, return blank array
410        $fieldsNames = isset($this->mainConfig->Record->marc_links)
411            ? array_map('trim', explode(',', $this->mainConfig->Record->marc_links))
412            : [];
413        return in_array('785', $fieldsNames) ? [] : parent::getNewerTitles();
414    }
415
416    /**
417     * Get the item's places of publication.
418     *
419     * @return array
420     */
421    public function getPlacesOfPublication()
422    {
423        return $this->getPublicationInfo();
424    }
425
426    /**
427     * Get an array of playing times for the record (if applicable).
428     *
429     * @return array
430     */
431    public function getPlayingTimes()
432    {
433        $times = $this->getFieldArray('306', ['a'], false);
434
435        // Format the times to include colons ("HH:MM:SS" format).
436        foreach ($times as $x => $time) {
437            if (!preg_match('/\d\d:\d\d:\d\d/', $time)) {
438                $times[$x] = substr($time, 0, 2) . ':' .
439                    substr($time, 2, 2) . ':' .
440                    substr($time, 4, 2);
441            }
442        }
443        return $times;
444    }
445
446    /**
447     * Get an array of previous titles for the record.
448     *
449     * @return array
450     */
451    public function getPreviousTitles()
452    {
453        // If the MARC links are being used, return blank array
454        $fieldsNames = isset($this->mainConfig->Record->marc_links)
455            ? array_map('trim', explode(',', $this->mainConfig->Record->marc_links))
456            : [];
457        return in_array('780', $fieldsNames) ? [] : parent::getPreviousTitles();
458    }
459
460    /**
461     * Get credits of people involved in production of the item.
462     *
463     * @return array
464     */
465    public function getProductionCredits()
466    {
467        return $this->getFieldArray('508');
468    }
469
470    /**
471     * Get an array of publication frequency information.
472     *
473     * @return array
474     */
475    public function getPublicationFrequency()
476    {
477        return $this->getFieldArray('310', ['a', 'b']);
478    }
479
480    /**
481     * Get an array of strings describing relationships to other items.
482     *
483     * @return array
484     */
485    public function getRelationshipNotes()
486    {
487        return $this->getFieldArray('580');
488    }
489
490    /**
491     * Get an array of all series names containing the record. Array entries may
492     * be either the name string, or an associative array with 'name' and 'number'
493     * keys.
494     *
495     * @return array
496     */
497    public function getSeries()
498    {
499        // First check the 440, 800 and 830 fields for series information:
500        $primaryFields = [
501            '440' => ['a', 'p'],
502            '800' => ['a', 'b', 'c', 'd', 'f', 'p', 'q', 't'],
503            '830' => ['a', 'p']];
504        $matches = $this->getSeriesFromMARC($primaryFields);
505        if (!empty($matches)) {
506            return $matches;
507        }
508
509        // Now check 490 and display it only if 440/800/830 were empty:
510        $secondaryFields = ['490' => ['a']];
511        $matches = $this->getSeriesFromMARC($secondaryFields);
512        if (!empty($matches)) {
513            return $matches;
514        }
515
516        // Still no results found?  Resort to the Solr-based method just in case!
517        return parent::getSeries();
518    }
519
520    /**
521     * Support method for getSeries() -- given a field specification, look for
522     * series information in the MARC record.
523     *
524     * @param array $fieldInfo Associative array of field => subfield information
525     * (used to find series name)
526     *
527     * @return array
528     */
529    protected function getSeriesFromMARC($fieldInfo)
530    {
531        $matches = [];
532
533        // Loop through the field specification....
534        foreach ($fieldInfo as $field => $subfields) {
535            // Did we find any matching fields?
536            $series = $this->getMarcReader()->getFields($field);
537            foreach ($series as $currentField) {
538                // Can we find a name using the specified subfield list?
539                $name = $this->getSubfieldArray($currentField, $subfields);
540                if (isset($name[0])) {
541                    $currentArray = ['name' => $name[0]];
542
543                    // Can we find a number in subfield v?  (Note that number is
544                    // always in subfield v regardless of whether we are dealing
545                    // with 440, 490, 800 or 830 -- hence the hard-coded array
546                    // rather than another parameter in $fieldInfo).
547                    $number = $this->getSubfieldArray($currentField, ['v']);
548                    if (isset($number[0])) {
549                        $currentArray['number'] = $number[0];
550                    }
551
552                    // Save the current match:
553                    $matches[] = $currentArray;
554                }
555            }
556        }
557
558        return $matches;
559    }
560
561    /**
562     * Get an array of summary strings for the record.
563     *
564     * @return array
565     */
566    public function getSummary()
567    {
568        return $this->getFieldArray('520');
569    }
570
571    /**
572     * Get an array of technical details on the item represented by the record.
573     *
574     * @return array
575     */
576    public function getSystemDetails()
577    {
578        return $this->getFieldArray('538');
579    }
580
581    /**
582     * Get an array of note about the record's target audience.
583     *
584     * @return array
585     */
586    public function getTargetAudienceNotes()
587    {
588        return $this->getFieldArray('521');
589    }
590
591    /**
592     * Get the text of the part/section portion of the title.
593     *
594     * @return string
595     */
596    public function getTitleSection()
597    {
598        return $this->getFirstFieldValue('245', ['n', 'p']);
599    }
600
601    /**
602     * Get the statement of responsibility that goes with the title (i.e. "by John
603     * Smith").
604     *
605     * @return string
606     */
607    public function getTitleStatement()
608    {
609        return $this->getFirstFieldValue('245', ['c']);
610    }
611
612    /**
613     * Get an array of lines from the table of contents.
614     *
615     * @return array
616     */
617    public function getTOC()
618    {
619        $toc = [];
620        if (
621            $fields = $this->getMarcReader()->getFields(
622                '505',
623                ['a', 'g', 'r', 't', 'u']
624            )
625        ) {
626            foreach ($fields as $field) {
627                // Implode all the subfields into a single string, then explode
628                // on the -- separators (filtering out empty chunks). Due to
629                // inconsistent application of subfield codes, this is the most
630                // reliable way to split up a table of contents.
631                $str = '';
632                foreach ($field['subfields'] as $subfield) {
633                    $str .= trim($subfield['data']) . ' ';
634                }
635                $toc = array_merge(
636                    $toc,
637                    array_filter(array_map('trim', preg_split('/[.\s]--/', $str)))
638                );
639            }
640        }
641        return $toc;
642    }
643
644    /**
645     * Get hierarchical place names (MARC field 752)
646     *
647     * Returns an array of formatted hierarchical place names, consisting of all
648     * alpha-subfields, concatenated for display
649     *
650     * @return array
651     */
652    public function getHierarchicalPlaceNames()
653    {
654        $placeNames = [];
655        if ($fields = $this->getMarcReader()->getFields('752')) {
656            foreach ($fields as $field) {
657                $current = [];
658                foreach ($field['subfields'] as $subfield) {
659                    if (!is_numeric($subfield['code'])) {
660                        $current[] = $subfield['data'];
661                    }
662                }
663                $placeNames[] = implode(' -- ', $current);
664            }
665        }
666        return $placeNames;
667    }
668
669    /**
670     * Return an array of associative URL arrays with one or more of the following
671     * keys:
672     *
673     * <li>
674     *   <ul>desc: URL description text to display (optional)</ul>
675     *   <ul>url: fully-formed URL (required if 'route' is absent)</ul>
676     *   <ul>route: VuFind route to build URL with (required if 'url' is absent)</ul>
677     *   <ul>routeParams: Parameters for route (optional)</ul>
678     *   <ul>queryString: Query params to append after building route (optional)</ul>
679     * </li>
680     *
681     * @return array
682     */
683    public function getURLs()
684    {
685        $retVal = [];
686
687        // Which fields/subfields should we check for URLs?
688        $fieldsToCheck = [
689            '856' => ['y', 'z', '3'],   // Standard URL
690            '555' => ['a'],              // Cumulative index/finding aids
691        ];
692
693        foreach ($fieldsToCheck as $field => $subfields) {
694            $urls = $this->getMarcReader()->getFields($field);
695            foreach ($urls as $url) {
696                // Is there an address in the current field?
697                $address = $this->getSubfield($url, 'u');
698                if ($address) {
699                    // Is there a description?  If not, just use the URL itself.
700                    foreach ($subfields as $current) {
701                        $desc = $this->getSubfield($url, $current);
702                        if ($desc) {
703                            break;
704                        }
705                    }
706
707                    $retVal[] = ['url' => $address, 'desc' => $desc ?: $address];
708                }
709            }
710        }
711
712        return $retVal;
713    }
714
715    /**
716     * Get all record links related to the current record. Each link is returned as
717     * array.
718     * Format:
719     * array(
720     *        array(
721     *               'title' => label_for_title
722     *               'value' => link_name
723     *               'link'  => link_URI
724     *        ),
725     *        ...
726     * )
727     *
728     * @return null|array
729     */
730    public function getAllRecordLinks()
731    {
732        // Load configurations:
733        $fieldsNames = isset($this->mainConfig->Record->marc_links)
734            ? explode(',', $this->mainConfig->Record->marc_links) : [];
735        $useVisibilityIndicator
736            = $this->mainConfig->Record->marc_links_use_visibility_indicator ?? true;
737
738        $retVal = [];
739        foreach ($fieldsNames as $value) {
740            $value = trim($value);
741            $fields = $this->getMarcReader()->getFields($value);
742            foreach ($fields as $field) {
743                // Check to see if we should display at all
744                if ($useVisibilityIndicator) {
745                    $visibilityIndicator = $field['i1'];
746                    if ($visibilityIndicator == '1') {
747                        continue;
748                    }
749                }
750
751                // Get data for field
752                $tmp = $this->getFieldData($field);
753                if (is_array($tmp)) {
754                    $retVal[] = $tmp;
755                }
756            }
757        }
758        return empty($retVal) ? null : $retVal;
759    }
760
761    /**
762     * Support method for getFieldData() -- factor the relationship indicator
763     * into the field number where relevant to generate a note to associate
764     * with a record link.
765     *
766     * @param array $field Field to examine
767     *
768     * @return string
769     */
770    protected function getRecordLinkNote($field)
771    {
772        // If set, use relationship information from subfield i
773        if ($subfieldI = $this->getSubfield($field, 'i')) {
774            // VuFind will add a colon to the label, so prevent double colons:
775            $data = rtrim($subfieldI, ':');
776            if (!empty($data)) {
777                return $data;
778            }
779        }
780
781        // Normalize blank relationship indicator to 0:
782        $relationshipIndicator = $field['i2'];
783        if ($relationshipIndicator == ' ') {
784            $relationshipIndicator = '0';
785        }
786
787        // Assign notes based on the relationship type
788        $value = $field['tag'];
789        switch ($value) {
790            case '780':
791                if (in_array($relationshipIndicator, range('0', '7'))) {
792                    $value .= '_' . $relationshipIndicator;
793                }
794                break;
795            case '785':
796                if (in_array($relationshipIndicator, range('0', '8'))) {
797                    $value .= '_' . $relationshipIndicator;
798                }
799                break;
800        }
801
802        return 'note_' . $value;
803    }
804
805    /**
806     * Returns the array element for the 'getAllRecordLinks' method
807     *
808     * @param array $field Field to examine
809     *
810     * @return array|bool  Array on success, boolean false if no valid link could be
811     * found in the data.
812     */
813    protected function getFieldData($field)
814    {
815        // Make sure that there is a t field to be displayed:
816        if (!($title = $this->getSubfield($field, 't'))) {
817            return false;
818        }
819
820        $linkTypeSetting = $this->mainConfig->Record->marc_links_link_types
821            ?? 'id,oclc,dlc,isbn,issn,title';
822        $linkTypes = explode(',', $linkTypeSetting);
823        $linkFields = $this->getSubfields($field, 'w');
824
825        // Run through the link types specified in the config.
826        // For each type, check field for reference
827        // If reference found, exit loop and go straight to end
828        // If no reference found, check the next link type instead
829        foreach ($linkTypes as $linkType) {
830            switch (trim($linkType)) {
831                case 'oclc':
832                    foreach ($linkFields as $current) {
833                        $oclc = $this->getIdFromLinkingField($current, 'OCoLC');
834                        if ($oclc) {
835                            $link = ['type' => 'oclc', 'value' => $oclc];
836                        }
837                    }
838                    break;
839                case 'dlc':
840                    foreach ($linkFields as $current) {
841                        $dlc = $this->getIdFromLinkingField($current, 'DLC', true);
842                        if ($dlc) {
843                            $link = ['type' => 'dlc', 'value' => $dlc];
844                        }
845                    }
846                    break;
847                case 'id':
848                    foreach ($linkFields as $current) {
849                        if ($bibLink = $this->getIdFromLinkingField($current)) {
850                            $link = ['type' => 'bib', 'value' => $bibLink];
851                        }
852                    }
853                    break;
854                case 'isbn':
855                    if ($isbn = $this->getSubfield($field, 'z')) {
856                        $link = [
857                            'type' => 'isn', 'value' => $isbn,
858                            'exclude' => $this->getUniqueId(),
859                        ];
860                    }
861                    break;
862                case 'issn':
863                    if ($issn = $this->getSubfield($field, 'x')) {
864                        $link = [
865                            'type' => 'isn', 'value' => $issn,
866                            'exclude' => $this->getUniqueId(),
867                        ];
868                    }
869                    break;
870                case 'title':
871                    $link = ['type' => 'title', 'value' => $title];
872                    break;
873            }
874            // Exit loop if we have a link
875            if (isset($link)) {
876                break;
877            }
878        }
879        // Make sure we have something to display:
880        return !isset($link) ? false : [
881            'title' => $this->getRecordLinkNote($field),
882            'value' => $title,
883            'link'  => $link,
884        ];
885    }
886
887    /**
888     * Returns an id extracted from the identifier subfield passed in
889     *
890     * @param string $idField MARC subfield containing id information
891     * @param string $prefix  Prefix to search for in id field
892     * @param bool   $raw     Return raw match, or normalize?
893     *
894     * @return string|bool    ID on success, false on failure
895     */
896    protected function getIdFromLinkingField($idField, $prefix = null, $raw = false)
897    {
898        if (preg_match('/\(([^)]+)\)(.+)/', $idField, $matches)) {
899            // If prefix matches, return ID:
900            if ($matches[1] == $prefix) {
901                // Special case -- LCCN should not be stripped:
902                return $raw
903                    ? $matches[2]
904                    : trim(str_replace(range('a', 'z'), '', ($matches[2])));
905            }
906        } elseif ($prefix == null) {
907            // If no prefix was given or found, we presume it is a raw bib record
908            return $idField;
909        }
910        return false;
911    }
912
913    /**
914     * Support method for getFormattedMarcDetails() -- extract a single result
915     *
916     * @param array $currentField Result from MarcReader::getFields
917     * @param array $details      Parsed instructions from getFormattedMarcDetails()
918     *
919     * @return string|bool
920     */
921    protected function extractSingleMarcDetail($currentField, $details)
922    {
923        // Simplest case -- "msg" mode (just return a configured message):
924        if ($details['mode'] === 'msg') {
925            // Map 'true' and 'false' to boolean equivalents:
926            $msgMap = ['true' => true, 'false' => false];
927            return $msgMap[$details['params']] ?? $details['params'];
928        }
929
930        // Standard case -- "marc" mode (extract subfield data):
931        $result = $this->getSubfieldArray(
932            $currentField,
933            // Default to subfield a if nothing is specified:
934            str_split($details['params'] ?? 'a'),
935            true
936        );
937        return count($result) > 0 ? (string)$result[0] : '';
938    }
939
940    /**
941     * Get Status/Holdings Information from the internally stored MARC Record
942     * (support method used by the NoILS driver).
943     *
944     * @param string $defaultField The MARC Field to retrieve if $data commands do
945     * not request something more specific
946     * @param array  $data         The type of data to retrieve from the MARC field;
947     * an array of pipe-delimited commands where the first part determines the data
948     * retrieval mode, the second part provides further instructions, and the
949     * optional third part provides a field to override $defaultField; supported
950     * modes: "msg" (for a hard-coded message) and "marc" (for fetching subfield
951     * data)
952     *
953     * @return array
954     */
955    public function getFormattedMarcDetails($defaultField, $data)
956    {
957        // First, parse the instructions into a more useful format, so we know
958        // which fields we're going to have to look up.
959        $instructions = [];
960        foreach ($data as $key => $rawInstruction) {
961            $instructionParts = explode('|', $rawInstruction);
962            $instructions[$key] = [
963                'mode' => $instructionParts[0],
964                'params' => $instructionParts[1] ?? null,
965                'field' => $instructionParts[2] ?? $defaultField,
966            ];
967        }
968
969        // Now fetch all of the MARC data that we need.
970        $getTagCallback = function ($instruction) {
971            return $instruction['field'];
972        };
973        $fields = [];
974        foreach (array_unique(array_map($getTagCallback, $instructions)) as $field) {
975            $fields[$field] = $this->getMarcReader()->getFields($field);
976        }
977
978        // Initialize return array
979        $matches = [];
980
981        // Process the instructions on the requested data.
982        foreach ($instructions as $key => $details) {
983            foreach ($fields[$details['field']] as $i => $currentField) {
984                if (!isset($matches[$i])) {
985                    $matches[$i] = ['id' => $this->getUniqueId()];
986                }
987                $matches[$i][$key] = $this->extractSingleMarcDetail(
988                    $currentField,
989                    $details
990                );
991            }
992        }
993        return $matches;
994    }
995
996    /**
997     * Return an XML representation of the record using the specified format.
998     * Return false if the format is unsupported.
999     *
1000     * @param string       $format  Name of format to use (corresponds with
1001     * OAI-PMH metadataPrefix parameter).
1002     * @param string       $baseUrl Base URL of host containing VuFind (optional;
1003     * may be used to inject record URLs into XML when appropriate).
1004     * @param RecordLinker $linker  Record linker helper (optional; may be used to
1005     * inject record URLs into XML when appropriate).
1006     *
1007     * @return mixed XML, or false if format unsupported.
1008     */
1009    public function getXML($format, $baseUrl = null, $linker = null)
1010    {
1011        // Special case for MARC:
1012        if ($format == 'marc21') {
1013            try {
1014                $xml = $this->getMarcReader()->toFormat('MARCXML');
1015            } catch (\Exception) {
1016                return false;
1017            }
1018            $xml = simplexml_load_string($xml);
1019            if (!$xml || !isset($xml->record)) {
1020                return false;
1021            }
1022
1023            // Set up proper namespacing and extract just the <record> tag:
1024            $xml->record->addAttribute('xmlns', 'http://www.loc.gov/MARC21/slim');
1025            // There's a quirk in SimpleXML that strips the first namespace
1026            // declaration, hence the double xmlns: prefix:
1027            $xml->record->addAttribute(
1028                'xmlns:xmlns:xsi',
1029                'http://www.w3.org/2001/XMLSchema-instance'
1030            );
1031            $xml->record->addAttribute(
1032                'xsi:schemaLocation',
1033                'http://www.loc.gov/MARC21/slim ' .
1034                'http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd',
1035                'http://www.w3.org/2001/XMLSchema-instance'
1036            );
1037            $xml->record->addAttribute('type', $this->xmlType);
1038            return $xml->record->asXML();
1039        }
1040
1041        // Try the parent method:
1042        return parent::getXML($format, $baseUrl, $linker);
1043    }
1044
1045    /**
1046     * Get an XML RDF representation of the data in this record.
1047     *
1048     * @return mixed XML RDF data (empty if unsupported or error).
1049     */
1050    public function getRDFXML()
1051    {
1052        try {
1053            $xml = $this->getMarcReader()->toFormat('MARCXML');
1054        } catch (\Exception $e) {
1055            return '';
1056        }
1057        return XSLTProcessor::process(
1058            'record-rdf-mods.xsl',
1059            trim($xml)
1060        );
1061    }
1062
1063    /**
1064     * Return the list of "source records" for this consortial record.
1065     *
1066     * @return array
1067     */
1068    public function getConsortialIDs()
1069    {
1070        return $this->getFieldArray('035');
1071    }
1072
1073    /**
1074     * Return first ISMN found for this record, or false if no one found
1075     *
1076     * @return mixed
1077     */
1078    public function getCleanISMN()
1079    {
1080        $fields024 = $this->getMarcReader()->getFields('024');
1081        foreach ($fields024 as $field) {
1082            if (
1083                $field['i1'] == 2
1084                && $subfield = $this->getSubfield($field, 'a')
1085            ) {
1086                return $subfield;
1087            }
1088        }
1089        return false;
1090    }
1091
1092    /**
1093     * Return first national bibliography number found, or false if not found
1094     *
1095     * @return mixed
1096     */
1097    public function getCleanNBN()
1098    {
1099        $field = $this->getMarcReader()->getField('015');
1100        if ($field && $nbn = $this->getSubfield($field, 'a')) {
1101            $result = compact('nbn');
1102            if ($source = $this->getSubfield($field, '7')) {
1103                $result['source'] = $source;
1104            }
1105            return $result;
1106        }
1107        return false;
1108    }
1109
1110    /**
1111     * Get the full titles of the record in alternative scripts.
1112     *
1113     * @return array
1114     */
1115    public function getTitlesAltScript(): array
1116    {
1117        return $this->getMarcReader()
1118            ->getLinkedFieldsSubfields('880', '245', ['a', 'b']);
1119    }
1120
1121    /**
1122     * Get the full titles of the record including section and part information in
1123     * alternative scripts.
1124     *
1125     * @return array
1126     */
1127    public function getFullTitlesAltScript(): array
1128    {
1129        return $this->getMarcReader()
1130            ->getLinkedFieldsSubfields('880', '245', ['a', 'b', 'n', 'p']);
1131    }
1132
1133    /**
1134     * Get the short (pre-subtitle) title of the record in alternative scripts.
1135     *
1136     * @return array
1137     */
1138    public function getShortTitlesAltScript(): array
1139    {
1140        return $this->getMarcReader()->getLinkedFieldsSubfields('880', '245', ['a']);
1141    }
1142
1143    /**
1144     * Get the subtitle of the record in alternative script.
1145     *
1146     * @return array
1147     */
1148    public function getSubtitlesAltScript(): array
1149    {
1150        return $this->getMarcReader()->getLinkedFieldsSubFields('880', '245', ['b']);
1151    }
1152
1153    /**
1154     * Get the text of the part/section portion of the title in alternative scripts.
1155     *
1156     * @return array
1157     */
1158    public function getTitleSectionsAltScript(): array
1159    {
1160        return $this->getMarcReader()
1161            ->getLinkedFieldsSubfields('880', '245', ['n', 'p']);
1162    }
1163}