Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
89.72% |
288 / 321 |
|
71.43% |
20 / 28 |
CRAP | |
0.00% |
0 / 1 |
Citation | |
89.72% |
288 / 321 |
|
71.43% |
20 / 28 |
150.93 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__invoke | |
97.73% |
43 / 44 |
|
0.00% |
0 / 1 |
9 | |||
prepareAuthors | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
9 | |||
getCitation | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getCitationAPA | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
getCitationChicago | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
getCitationMLA | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
4 | |||
getPageRange | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getMLANumberAndDate | |
52.38% |
11 / 21 |
|
0.00% |
0 / 1 |
20.80 | |||
getAPANumbersAndDate | |
42.86% |
9 / 21 |
|
0.00% |
0 / 1 |
19.94 | |||
isNameSuffix | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
isDateRange | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
abbreviateName | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
6.02 | |||
fixAbbreviatedNameLetters | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
cleanNameDates | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
isPunctuated | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
stripPunctuation | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
reverseName | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
capitalizeTitle | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
6 | |||
getAPATitle | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
getAPAAuthors | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
14 | |||
getEdition | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
7.04 | |||
getMLATitle | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
formatPrimaryMLAAuthor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
formatSecondaryMLAAuthor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getMLAAuthors | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
10 | |||
getPublisher | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
getYear | |
45.45% |
5 / 11 |
|
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 | |
31 | namespace VuFind\View\Helper\Root; |
32 | |
33 | use VuFind\Date\DateException; |
34 | use VuFind\I18n\Translator\TranslatorAwareInterface; |
35 | |
36 | use function count; |
37 | use function function_exists; |
38 | use function in_array; |
39 | use function is_array; |
40 | use 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 | */ |
52 | class 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 | } |