Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.72% covered (warning)
89.72%
288 / 321
71.43% covered (warning)
71.43%
20 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Citation
89.72% covered (warning)
89.72%
288 / 321
71.43% covered (warning)
71.43%
20 / 28
150.93
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
 __invoke
97.73% covered (success)
97.73%
43 / 44
0.00% covered (danger)
0.00%
0 / 1
9
 prepareAuthors
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
9
 getCitation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getCitationAPA
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 getCitationChicago
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getCitationMLA
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 getPageRange
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getMLANumberAndDate
52.38% covered (warning)
52.38%
11 / 21
0.00% covered (danger)
0.00%
0 / 1
20.80
 getAPANumbersAndDate
42.86% covered (danger)
42.86%
9 / 21
0.00% covered (danger)
0.00%
0 / 1
19.94
 isNameSuffix
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isDateRange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 abbreviateName
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 fixAbbreviatedNameLetters
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 cleanNameDates
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 isPunctuated
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stripPunctuation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 reverseName
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 capitalizeTitle
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
6
 getAPATitle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getAPAAuthors
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
14
 getEdition
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
7.04
 getMLATitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatPrimaryMLAAuthor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 formatSecondaryMLAAuthor
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMLAAuthors
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
10
 getPublisher
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 getYear
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
6.60
1<?php
2
3/**
4 * Citation view helper
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  View_Helpers
25 * @author   Demian Katz <demian.katz@villanova.edu>
26 * @author   Juha Luoma <juha.luoma@helsinki.fi>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org/wiki/development Wiki
29 */
30
31namespace VuFind\View\Helper\Root;
32
33use VuFind\Date\DateException;
34use VuFind\I18n\Translator\TranslatorAwareInterface;
35
36use function count;
37use function function_exists;
38use function in_array;
39use function is_array;
40use function strlen;
41
42/**
43 * Citation view helper
44 *
45 * @category VuFind
46 * @package  View_Helpers
47 * @author   Demian Katz <demian.katz@villanova.edu>
48 * @author   Juha Luoma <juha.luoma@helsinki.fi>
49 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
50 * @link     https://vufind.org/wiki/development Wiki
51 */
52class Citation extends \Laminas\View\Helper\AbstractHelper implements TranslatorAwareInterface
53{
54    use \VuFind\I18n\Translator\TranslatorAwareTrait;
55
56    /**
57     * Citation details
58     *
59     * @var array
60     */
61    protected $details = [];
62
63    /**
64     * Record driver
65     *
66     * @var \VuFind\RecordDriver\AbstractBase
67     */
68    protected $driver;
69
70    /**
71     * Date converter
72     *
73     * @var \VuFind\Date\Converter
74     */
75    protected $dateConverter;
76
77    /**
78     * List of words to never capitalize when using title case.
79     *
80     * Some words that were considered for this list, but excluded due to their
81     * potential ambiguity: down, near, out, past, up
82     *
83     * Some words that were considered, but excluded because they were five or
84     * more characters in length: about, above, across, after, against, along,
85     * among, around, before, behind, below, beneath, beside, between, beyond,
86     * despite, during, except,  inside, opposite, outside, round, since, through,
87     * towards, under, underneath, unlike, until, within, without
88     *
89     * @var string[]
90     */
91    protected $uncappedWords = [
92        'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'from', 'in',
93        'into', 'like', 'nor', 'of', 'off', 'on', 'onto', 'or', 'over', 'so',
94        'than', 'the', 'to', 'upon', 'via', 'with', 'yet',
95    ];
96
97    /**
98     * List of multi-word phrases to never capitalize when using title case.
99     *
100     * @var string[]
101     */
102    protected $uncappedPhrases = [
103        'even if', 'if only', 'now that', 'on top of',
104    ];
105
106    /**
107     * Constructor
108     *
109     * @param \VuFind\Date\Converter $converter Date converter
110     */
111    public function __construct(\VuFind\Date\Converter $converter)
112    {
113        $this->dateConverter = $converter;
114    }
115
116    /**
117     * Store a record driver object and return this object so that the appropriate
118     * template can be rendered.
119     *
120     * @param \VuFind\RecordDriver\Base $driver Record driver object.
121     *
122     * @return Citation
123     */
124    public function __invoke($driver)
125    {
126        // Store the driver first, since we will need it for prepareAuthors checks.
127        $this->driver = $driver;
128
129        // Build author list:
130        $authors = $this->prepareAuthors(
131            (array)$driver->tryMethod('getPrimaryAuthors')
132        );
133        $corporateAuthors = [];
134        if (empty($authors)) {
135            // Corporate authors are more likely to have inappropriate trailing
136            // punctuation; strip it off, unless the last word is short, like
137            // "Co.", "Ltd.," etc.:
138            $trimmer = function ($str) {
139                return preg_match('/\s+.{1,3}\.$/', $str)
140                    ? $str : rtrim($str, '.');
141            };
142            $corporateAuthors = $authors = $this->prepareAuthors(
143                array_map(
144                    $trimmer,
145                    (array)$driver->tryMethod('getCorporateAuthors')
146                ),
147                true
148            );
149        }
150        $secondary = $this->prepareAuthors(
151            (array)$driver->tryMethod('getSecondaryAuthors')
152        );
153        if (!empty($secondary)) {
154            $authors = array_unique(array_merge($authors, $secondary));
155        }
156
157        // Get best available title details:
158        $title = $driver->tryMethod('getShortTitle');
159        $subtitle = $driver->tryMethod('getSubtitle');
160        if (empty($title)) {
161            $title = $driver->tryMethod('getTitle');
162        }
163        if (empty($title)) {
164            $title = $driver->getBreadcrumb();
165        }
166        // Find subtitle in title if they're not separated:
167        if (empty($subtitle) && strstr($title, ':')) {
168            [$title, $subtitle] = explode(':', $title, 2);
169        }
170
171        // Extract the additional details from the record driver:
172        $publishers = $driver->tryMethod('getPublishers');
173        $pubDates = $driver->tryMethod('getPublicationDates');
174        $pubPlaces = $driver->tryMethod('getPlacesOfPublication');
175        $edition = $driver->tryMethod('getEdition');
176
177        // Store all the collected details:
178        $this->details = [
179            'authors' => $authors,
180            'corporateAuthors' => $corporateAuthors,
181            'title' => trim($title ?? ''),
182            'subtitle' => trim($subtitle ?? ''),
183            'pubPlace' => $pubPlaces[0] ?? null,
184            'pubName' => $publishers[0] ?? null,
185            'pubDate' => $pubDates[0] ?? null,
186            'edition' => empty($edition) ? [] : [$edition],
187            'journal' => $driver->tryMethod('getContainerTitle'),
188        ];
189
190        return $this;
191    }
192
193    /**
194     * The code in this module expects authors in "Last Name, First Name" format.
195     * This support method (used by the main citation() method) attempts to fix
196     * any non-compliant names.
197     *
198     * @param array $authors     Authors to process.
199     * @param bool  $isCorporate Is this a list of corporate authors?
200     *
201     * @return array
202     */
203    protected function prepareAuthors($authors, $isCorporate = false)
204    {
205        $callables = [];
206
207        // If this data comes from a MARC record, we can probably assume that
208        // anything without a comma is supposed to be formatted that way. We
209        // also know if we have a valid corporate author name that it should be
210        // left alone... otherwise, it's worth trying to reverse names (for example,
211        // this may be dirty data from Summon):
212        if (
213            !($this->driver instanceof \VuFind\RecordDriver\SolrMarc)
214            && !$isCorporate
215        ) {
216            $callables[] = function (string $name): string {
217                $name = $this->cleanNameDates($name);
218                if (!strstr($name, ',')) {
219                    $parts = explode(' ', $name);
220                    if (count($parts) > 1) {
221                        $last = array_pop($parts);
222                        $first = implode(' ', $parts);
223                        return rtrim($last, '.') . ', ' . $first;
224                    }
225                }
226                return $name;
227            };
228        }
229
230        // We always want to apply these standard cleanup routines to non-corporate
231        // authors:
232        if (!$isCorporate) {
233            $callables[] = function (string $name): string {
234                // Eliminate parenthetical information:
235                $strippedName = trim(preg_replace('/\s\(.*\)/', '', $name));
236
237                // Split the text into words:
238                $parts = explode(' ', empty($strippedName) ? $name : $strippedName);
239
240                // If we have exactly two parts, we should trim any trailing
241                // punctuation from the second part (this reduces the odds of
242                // accidentally trimming a "Jr." or "Sr."):
243                if (count($parts) == 2) {
244                    $parts[1] = rtrim($parts[1], '.');
245                }
246                // Put the parts back together; eliminate stray commas:
247                return rtrim(implode(' ', $parts), ',');
248            };
249        }
250
251        // Now apply all of the functions we collected to all of the strings:
252        return array_map(
253            function (string $value) use ($callables): string {
254                foreach ($callables as $current) {
255                    $value = $current($value);
256                }
257                return $value;
258            },
259            $authors
260        );
261    }
262
263    /**
264     * Retrieve a citation in a particular format
265     *
266     * Returns the citation in the format specified
267     *
268     * @param string $format Citation format ('APA' or 'MLA')
269     *
270     * @return string        Formatted citation
271     */
272    public function getCitation($format)
273    {
274        // Construct method name for requested format:
275        $method = 'getCitation' . $format;
276
277        // Avoid calls to inappropriate/missing methods:
278        if (!empty($format) && method_exists($this, $method)) {
279            return $this->$method();
280        }
281
282        // Return blank string if no valid method found:
283        return '';
284    }
285
286    /**
287     * Get APA citation.
288     *
289     * This function assigns all the necessary variables and then returns an APA
290     * citation.
291     *
292     * @return string
293     */
294    public function getCitationAPA()
295    {
296        $apa = [
297            'title' => $this->getAPATitle(),
298            'authors' => $this->getAPAAuthors(),
299            'edition' => $this->getEdition(),
300        ];
301
302        // Show a period after the title if it does not already have punctuation
303        // and is not followed by an edition statement:
304        $apa['periodAfterTitle']
305            = (!$this->isPunctuated($apa['title']) && empty($apa['edition']));
306        if ($doi = $this->driver->tryMethod('getCleanDOI')) {
307            $apa['doi'] = $doi;
308        }
309
310        $partial = $this->getView()->plugin('partial');
311        // Behave differently for books vs. journals:
312        if (empty($this->details['journal'])) {
313            $apa['publisher'] = $this->getPublisher(false);
314            $apa['year'] = $this->getYear();
315            return $partial('Citation/apa.phtml', $apa);
316        }
317
318        // If we got this far, it's the default article case:
319        [$apa['volume'], $apa['issue'], $apa['date']]
320            = $this->getAPANumbersAndDate();
321        $apa['journal'] = $this->details['journal'];
322        $apa['pageRange'] = $this->getPageRange();
323        return $partial('Citation/apa-article.phtml', $apa);
324    }
325
326    /**
327     * Get Chicago Style citation.
328     *
329     * This function returns a Chicago Style citation using a modified version
330     * of the MLA logic.
331     *
332     * @return string
333     */
334    public function getCitationChicago()
335    {
336        return $this->getCitationMLA(
337            9,
338            ', no. ',
339            ' ',
340            '',
341            ' (%s)',
342            ':',
343            true,
344            'https://doi.org/',
345            false,
346            false
347        );
348    }
349
350    /**
351     * Get MLA citation.
352     *
353     * This function assigns all the necessary variables and then returns an MLA
354     * citation. By adjusting the parameters below, it can also render a Chicago
355     * Style citation.
356     *
357     * @param int    $etAlThreshold   The number of authors to abbreviate with 'et
358     * al.'
359     * @param string $volNumSeparator String to separate volume and issue number
360     * in citation.
361     * @param string $numPrefix       String to display in front of numbering
362     * @param string $volPrefix       String to display in front of volume
363     * @param string $yearFormat      Format string for year display
364     * @param string $pageNoSeparator Separator between date / page no.
365     * @param bool   $includePubPlace Should we include the place of publication?
366     * @param string $doiPrefix       Prefix to display in front of DOI; set to
367     * false to omit DOIs.
368     * @param bool   $labelPageRange  Should we include p./pp. before page ranges?
369     * @param bool   $doiArticleComma Should we put a comma instead of period before
370     * a DOI in an article-style citation?
371     *
372     * @return string
373     */
374    public function getCitationMLA(
375        $etAlThreshold = 2,
376        $volNumSeparator = ', no. ',
377        $numPrefix = ', ',
378        $volPrefix = 'vol. ',
379        $yearFormat = ', %s',
380        $pageNoSeparator = ',',
381        $includePubPlace = false,
382        $doiPrefix = 'https://doi.org/',
383        $labelPageRange = true,
384        $doiArticleComma = true
385    ) {
386        $mla = [
387            'title' => $this->getMLATitle(),
388            'authors' => $this->getMLAAuthors($etAlThreshold),
389            'labelPageRange' => $labelPageRange,
390            'pageNumberSeparator' => $pageNoSeparator,
391        ];
392        $mla['periodAfterTitle'] = !$this->isPunctuated($mla['title']);
393        if ($doiPrefix && $doi = $this->driver->tryMethod('getCleanDOI')) {
394            $mla['doi'] = $doi;
395            $mla['doiPrefix'] = $doiPrefix;
396        }
397
398        // Behave differently for books vs. journals:
399        $partial = $this->getView()->plugin('partial');
400        if (empty($this->details['journal'])) {
401            $mla['publisher'] = $this->getPublisher($includePubPlace);
402            $mla['year'] = $this->getYear();
403            $mla['edition'] = $this->getEdition();
404            return $partial('Citation/mla.phtml', $mla);
405        }
406        // If we got this far, we should add other journal-specific details:
407        $mla['doiArticleComma'] = $doiArticleComma;
408        $mla['pageRange'] = $this->getPageRange();
409        $mla['journal'] = $this->capitalizeTitle($this->details['journal']);
410        $mla['numberAndDate'] = $numPrefix . $this->getMLANumberAndDate(
411            $volNumSeparator,
412            $volPrefix,
413            $yearFormat
414        );
415        return $partial('Citation/mla-article.phtml', $mla);
416    }
417
418    /**
419     * Construct page range portion of citation.
420     *
421     * @return string
422     */
423    protected function getPageRange()
424    {
425        $start = $this->driver->tryMethod('getContainerStartPage');
426        $end = $this->driver->tryMethod('getContainerEndPage');
427        return ($start == $end || empty($end))
428            ? $start : $start . '-' . $end;
429    }
430
431    /**
432     * Construct volume/issue/date portion of MLA or Chicago Style citation.
433     *
434     * @param string $volNumSeparator String to separate volume and issue number
435     * in citation (only difference between MLA/Chicago Style).
436     * @param string $volPrefix       String to display in front of volume
437     * @param string $yearFormat      Format string for year display
438     *
439     * @return string
440     */
441    protected function getMLANumberAndDate(
442        $volNumSeparator = '.',
443        $volPrefix = '',
444        $yearFormat = ', %s'
445    ) {
446        $vol = $this->driver->tryMethod('getContainerVolume');
447        $num = $this->driver->tryMethod('getContainerIssue');
448        $date = $this->details['pubDate'];
449        if (strlen($date) > 4) {
450            try {
451                $year = $this->dateConverter->convertFromDisplayDate('Y', $date);
452                $month = $this->dateConverter->convertFromDisplayDate('M', $date)
453                    . '.';
454                $day = $this->dateConverter->convertFromDisplayDate('j', $date);
455            } catch (DateException $e) {
456                // If conversion fails, use raw date as year -- not ideal,
457                // but probably better than nothing:
458                $year = $date;
459                $month = $day = '';
460            }
461        } else {
462            $year = $date;
463            $month = $day = '';
464        }
465
466        // If vol/num is set, we need to display one format...
467        if (!empty($vol) || !empty($num)) {
468            // If volume and number are both non-empty, separate them with a
469            // period; otherwise just use the one that is set.
470            $volNum = (!empty($vol) && !empty($num))
471                ? $vol . $volNumSeparator . $num : $vol . $num;
472            return (empty($vol) ? '' : $volPrefix)
473                . $volNum . sprintf($yearFormat, $year);
474        }
475        // If we got this far, there's no vol/num, so we need to supply additional
476        // date information...
477        // Right now, we'll assume if day == 1, this is a monthly publication;
478        // that's probably going to result in some bad citations, but it's the
479        // best we can do without writing extra record driver methods.
480        return (($day > 1) ? $day . ' ' : '')
481            . (empty($month) ? '' : $month . ' ')
482            . $year;
483    }
484
485    /**
486     * Construct volume/issue/date portion of APA citation. Returns an array with
487     * three elements: volume, issue and date (since these end up in different areas
488     * of the final citation, we don't return a single string, but since their
489     * determination is related, we need to do the work in a single function).
490     *
491     * @return array
492     */
493    protected function getAPANumbersAndDate()
494    {
495        $vol = $this->driver->tryMethod('getContainerVolume');
496        $num = $this->driver->tryMethod('getContainerIssue');
497        $date = $this->details['pubDate'];
498        if (strlen($date) > 4) {
499            try {
500                $year = $this->dateConverter->convertFromDisplayDate('Y', $date);
501                $month = $this->dateConverter->convertFromDisplayDate('F', $date);
502                $day = $this->dateConverter->convertFromDisplayDate('j', $date);
503            } catch (DateException $e) {
504                // If conversion fails, use raw date as year -- not ideal,
505                // but probably better than nothing:
506                $year = $date;
507                $month = $day = '';
508            }
509        } else {
510            $year = $date;
511            $month = $day = '';
512        }
513
514        // We need to supply additional date information if no vol/num:
515        if (!empty($vol) || !empty($num)) {
516            // If only the number is non-empty, move the value to the volume to
517            // simplify template behavior:
518            if (empty($vol)) {
519                $vol = $num;
520                $num = '';
521            }
522            return [$vol, $num, $year];
523        }
524        // Right now, we'll assume if day == 1, this is a monthly publication;
525        // that's probably going to result in some bad citations, but it's the
526        // best we can do without writing extra record driver methods.
527        $finalDate = $year
528            . (empty($month) ? '' : ', ' . $month)
529            . (($day > 1) ? ' ' . $day : '');
530        return ['', '', $finalDate];
531    }
532
533    /**
534     * Is the string a valid name suffix?
535     *
536     * @param string $str The string to check.
537     *
538     * @return bool       True if it's a name suffix.
539     */
540    protected function isNameSuffix($str)
541    {
542        $str = $this->stripPunctuation($str);
543
544        // Is it a standard suffix?
545        $suffixes = ['Jr', 'Sr'];
546        if (in_array($str, $suffixes)) {
547            return true;
548        }
549
550        // Is it a roman numeral?  (This check could be smarter, but it's probably
551        // good enough as it is).
552        if (preg_match('/^[MDCLXVI]+$/', $str)) {
553            return true;
554        }
555
556        // If we got this far, it's not a suffix.
557        return false;
558    }
559
560    /**
561     * Is the string a date range?
562     *
563     * @param string $str The string to check.
564     *
565     * @return bool       True if it's a date range.
566     */
567    protected function isDateRange($str)
568    {
569        $str = trim($str);
570        return preg_match('/^([0-9]+)-([0-9]*)\.?$/', $str);
571    }
572
573    /**
574     * Abbreviate a first name.
575     *
576     * @param string $name The name to abbreviate
577     *
578     * @return string      The abbreviated name.
579     */
580    protected function abbreviateName($name)
581    {
582        $parts = explode(', ', $this->cleanNameDates($name));
583        $name = $parts[0];
584
585        // Attach initials...
586        if (isset($parts[1])) {
587            $fnameParts = explode(' ', $parts[1]);
588            for ($i = 0; $i < count($fnameParts); $i++) {
589                // Use the multi-byte substring function if available to avoid
590                // problems with accented characters:
591                $fnameParts[$i] = function_exists('mb_substr')
592                    ? mb_substr($fnameParts[$i], 0, 1, 'utf8') . '.'
593                    : substr($fnameParts[$i], 0, 1) . '.';
594            }
595            $name .= ', ' . implode(' ', $fnameParts);
596            if (isset($parts[2]) && $this->isNameSuffix($parts[2])) {
597                $name = trim($name) . ', ' . $parts[2];
598            }
599        }
600
601        return trim($name);
602    }
603
604    /**
605     * Fix bad punctuation on abbreviated name letters.
606     *
607     * @param string $str String to fix.
608     *
609     * @return string
610     */
611    protected function fixAbbreviatedNameLetters($str)
612    {
613        // Fix abbreviated letters.
614        if (
615            strlen($str) == 1
616            || preg_match('/\s[a-zA-Z]/', substr($str, -2))
617        ) {
618            return $str . '.';
619        }
620        return $str;
621    }
622
623    /**
624     * Strip the dates off the end of a name.
625     *
626     * @param string $str Name to clean.
627     *
628     * @return string     Cleaned name.
629     */
630    protected function cleanNameDates($str)
631    {
632        $arr = explode(', ', $str);
633        $name = $arr[0];
634        if (isset($arr[1]) && !$this->isDateRange($arr[1])) {
635            $name .= ', ' . $this->fixAbbreviatedNameLetters($arr[1]);
636            if (isset($arr[2]) && $this->isNameSuffix($arr[2])) {
637                $name .= ', ' . $arr[2];
638            }
639        }
640        // For good measure, strip out any remaining date ranges lurking in
641        // non-standard places.
642        return preg_replace(
643            '/\s+(\d{4}\-\d{4}|b\. \d{4}|\d{4}-)[,.]*$/',
644            '',
645            $name
646        );
647    }
648
649    /**
650     * Does the string end in punctuation that we want to retain?
651     *
652     * @param string $string String to test.
653     *
654     * @return bool          Does string end in punctuation?
655     */
656    protected function isPunctuated($string)
657    {
658        $punctuation = ['.', '?', '!'];
659        return in_array(substr($string, -1), $punctuation);
660    }
661
662    /**
663     * Strip unwanted punctuation from the right side of a string.
664     *
665     * @param string $text Text to clean up.
666     *
667     * @return string      Cleaned up text.
668     */
669    protected function stripPunctuation($text)
670    {
671        $punctuation = ['.', ',', ':', ';', '/'];
672        $text = trim($text);
673        if (in_array(substr($text, -1), $punctuation)) {
674            $text = substr($text, 0, -1);
675        }
676        return trim($text);
677    }
678
679    /**
680     * Turn a "Last, First" name into a "First Last" name.
681     *
682     * @param string $str Name to reverse.
683     *
684     * @return string     Reversed name.
685     */
686    protected function reverseName($str)
687    {
688        $arr = explode(', ', $str);
689
690        // If the second chunk is a date range, there is nothing to reverse!
691        if (!isset($arr[1]) || $this->isDateRange($arr[1])) {
692            return $arr[0];
693        }
694
695        $name = $this->fixAbbreviatedNameLetters($arr[1]) . ' ' . $arr[0];
696        if (isset($arr[2]) && $this->isNameSuffix($arr[2])) {
697            $name .= ', ' . $arr[2];
698        }
699        return $name;
700    }
701
702    /**
703     * Capitalize all words in a title, except for a few common exceptions.
704     *
705     * @param string $str Title to capitalize.
706     *
707     * @return string     Capitalized title.
708     */
709    protected function capitalizeTitle($str)
710    {
711        $words = explode(' ', $str);
712        $newwords = [];
713        $followsColon = false;
714        foreach ($words as $word) {
715            // Capitalize words unless they are in the exception list... but even
716            // exceptional words get capitalized if they follow a colon. Note that
717            // we need to strip non-word characters (like punctuation) off of words
718            // in order to reliably look them up in the uncappedWords list.
719            $baseWord = preg_replace('/\W/', '', $word);
720            if (!in_array($baseWord, $this->uncappedWords) || $followsColon) {
721                // Includes special case to properly capitalize words in quotes:
722                $firstChar = substr($word, 0, 1);
723                $word = in_array($firstChar, ['"', "'"])
724                    ? $firstChar . ucfirst(substr($word, 1))
725                    : ucfirst($word);
726            }
727            array_push($newwords, $word);
728
729            $followsColon = str_ends_with($word, ':');
730        }
731
732        // We've dealt with capitalization of words; now we need to deal with
733        // multi-word phrases:
734        $adjustedTitle = ucfirst(implode(' ', $newwords));
735        foreach ($this->uncappedPhrases as $phrase) {
736            // We need to cover two cases: the phrase at the start of a title,
737            // and the phrase in the middle of a title:
738            $adjustedTitle = preg_replace(
739                '/^' . $phrase . '\b/i',
740                strtoupper(substr($phrase, 0, 1)) . substr($phrase, 1),
741                $adjustedTitle
742            );
743            $adjustedTitle = preg_replace(
744                '/(.+)\b' . $phrase . '\b/i',
745                '$1' . $phrase,
746                $adjustedTitle
747            );
748        }
749        return $adjustedTitle;
750    }
751
752    /**
753     * Get the full title for an APA citation.
754     *
755     * @return string
756     */
757    protected function getAPATitle()
758    {
759        // Create Title
760        $title = $this->stripPunctuation($this->details['title']);
761        if (isset($this->details['subtitle'])) {
762            $subtitle = $this->stripPunctuation($this->details['subtitle']);
763            // Capitalize subtitle and apply it, assuming it really exists:
764            if (!empty($subtitle)) {
765                $subtitle
766                    = strtoupper(substr($subtitle, 0, 1)) . substr($subtitle, 1);
767                $title .= ': ' . $subtitle;
768            }
769        }
770
771        return $title;
772    }
773
774    /**
775     * Get an array of authors for an APA citation.
776     *
777     * @return array
778     */
779    protected function getAPAAuthors()
780    {
781        $authorStr = '';
782        if (
783            isset($this->details['authors'])
784            && is_array($this->details['authors'])
785        ) {
786            $i = 0;
787            $ellipsis = false;
788            $authorCount = count($this->details['authors']);
789            foreach ($this->details['authors'] as $author) {
790                // Do not abbreviate corporate authors:
791                $author = in_array($author, $this->details['corporateAuthors'])
792                    ? $author : $this->abbreviateName($author);
793                if (($i + 1 == $authorCount) && ($i > 0)) { // Last
794                    // Do we already have periods of ellipsis?  If not, we need
795                    // an ampersand:
796                    $authorStr .= $ellipsis ? ' ' : '& ';
797                    $authorStr .= $this->stripPunctuation($author) . '.';
798                } elseif ($i > 5) {
799                    // If we have more than seven authors, we need to skip some:
800                    if (!$ellipsis) {
801                        $authorStr .= '. . .';
802                        $ellipsis = true;
803                    }
804                } elseif ($authorCount > 1) {
805                    // If this is the second-to-last author, and we have not found
806                    // any commas yet, we can skip the comma. Otherwise, add one to
807                    // the list. Useful for two-item lists including corporate
808                    // authors as the first entry.
809                    $skipComma = ($i + 2 == $authorCount)
810                        && (!str_contains($authorStr . $author, ','));
811                    $authorStr .= $author . ($skipComma ? ' ' : ', ');
812                } else { // First and only
813                    $authorStr .= $this->stripPunctuation($author) . '.';
814                }
815                $i++;
816            }
817        }
818        return empty($authorStr) ? false : $authorStr;
819    }
820
821    /**
822     * Get edition statement for inclusion in a citation.  Shared by APA and
823     * MLA functionality.
824     *
825     * @return string
826     */
827    protected function getEdition()
828    {
829        // Find the first edition statement that isn't "1st ed."
830        if (
831            isset($this->details['edition'])
832            && is_array($this->details['edition'])
833        ) {
834            foreach ($this->details['edition'] as $edition) {
835                // Strip punctuation from the edition to get rid of unwanted
836                // junk... but if there is nothing left after stripping, put
837                // back at least one period!
838                $edition = $this->stripPunctuation($edition);
839                if (empty($edition)) {
840                    continue;
841                }
842                if (!$this->isPunctuated($edition)) {
843                    $edition .= '.';
844                }
845                if ($edition !== '1st ed.') {
846                    return $edition;
847                }
848            }
849        }
850
851        // No edition statement found:
852        return false;
853    }
854
855    /**
856     * Get the full title for an MLA citation.
857     *
858     * @return string
859     */
860    protected function getMLATitle()
861    {
862        // MLA titles are just like APA titles, only capitalized differently:
863        return $this->capitalizeTitle($this->getAPATitle());
864    }
865
866    /**
867     * Format an author name for inclusion as the first name in an MLA citation.
868     *
869     * @param string $author Name to reformat.
870     *
871     * @return string
872     */
873    protected function formatPrimaryMLAAuthor($author)
874    {
875        // Corporate authors should not be reformatted:
876        return in_array($author, $this->details['corporateAuthors'])
877            ? $author : $this->cleanNameDates($author);
878    }
879
880    /**
881     * Format an author name for inclusion in an MLA citation (after the primary
882     * name, which gets formatted differently).
883     *
884     * @param string $author Name to reformat.
885     *
886     * @return string
887     */
888    protected function formatSecondaryMLAAuthor($author)
889    {
890        // If there is no comma in the name, we don't need to reverse it and
891        // should leave its punctuation alone (since it was adjusted earlier).
892        return !str_contains($author, ',')
893            ? $author : $this->reverseName($this->stripPunctuation($author));
894    }
895
896    /**
897     * Get an array of authors for an MLA or Chicago Style citation.
898     *
899     * @param int $etAlThreshold The number of authors to abbreviate with 'et al.'
900     * This is a major difference between MLA/Chicago Style.
901     *
902     * @return array
903     */
904    protected function getMLAAuthors($etAlThreshold = 2)
905    {
906        $authorStr = '';
907        if (
908            isset($this->details['authors'])
909            && is_array($this->details['authors'])
910        ) {
911            $i = 0;
912            if (count($this->details['authors']) > $etAlThreshold) {
913                $author = $this->details['authors'][0];
914                $authorStr = $this->formatPrimaryMLAAuthor($author) . ', et al.';
915            } else {
916                foreach ($this->details['authors'] as $rawAuthor) {
917                    $author = $this->formatPrimaryMLAAuthor($rawAuthor);
918                    if (($i + 1 == count($this->details['authors'])) && ($i > 0)) {
919                        // Last
920                        // Only add a comma if there are commas already in the
921                        // preceding text. This helps, for example, with cases where
922                        // the first author is a corporate author.
923                        $finalJoin = str_contains($authorStr, ',') ? ', ' : ' ';
924                        $authorStr .= $finalJoin . $this->translate('and') . ' '
925                            . $this->formatSecondaryMLAAuthor($author);
926                    } elseif ($i > 0) {
927                        $authorStr .= ', '
928                            . $this->formatSecondaryMLAAuthor($author);
929                    } else {
930                        // First
931                        $authorStr .= $author;
932                    }
933                    $i++;
934                }
935            }
936        }
937        return empty($authorStr) ? false : $this->stripPunctuation($authorStr);
938    }
939
940    /**
941     * Get publisher information (place: name) for inclusion in a citation.
942     * Shared by APA and MLA functionality.
943     *
944     * @param bool $includePubPlace Should we include the place of publication?
945     *
946     * @return string
947     */
948    protected function getPublisher($includePubPlace = true)
949    {
950        $parts = [];
951        if (
952            $includePubPlace && !empty($this->details['pubPlace'])
953        ) {
954            $parts[] = $this->stripPunctuation($this->details['pubPlace']);
955        }
956        if (
957            !empty($this->details['pubName'])
958        ) {
959            $parts[] = $this->details['pubName'];
960        }
961        if (empty($parts)) {
962            return false;
963        }
964        return $this->stripPunctuation(implode(': ', $parts));
965    }
966
967    /**
968     * Get the year of publication for inclusion in a citation.
969     * Shared by APA and MLA functionality.
970     *
971     * @return string
972     */
973    protected function getYear()
974    {
975        if (isset($this->details['pubDate'])) {
976            $numericDate = preg_replace('/\D/', '', $this->details['pubDate']);
977            if (strlen($numericDate) > 4) {
978                try {
979                    return $this->dateConverter->convertFromDisplayDate(
980                        'Y',
981                        $this->details['pubDate']
982                    );
983                } catch (\Exception $e) {
984                    // Ignore date errors -- no point in dying here:
985                    return false;
986                }
987            }
988            return $numericDate;
989        }
990        return false;
991    }
992}