Date::renderDateRange()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 4
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
declare(strict_types=1);
3
/*
4
 * citeproc-php
5
 *
6
 * @link        http://github.com/seboettg/citeproc-php for the source repository
7
 * @copyright   Copyright (c) 2016 Sebastian Böttger.
8
 * @license     https://opensource.org/licenses/MIT
9
 */
10
11
namespace Seboettg\CiteProc\Rendering\Date;
12
13
use Exception;
14
use Seboettg\CiteProc\CiteProc;
15
use Seboettg\CiteProc\Exception\CiteProcException;
16
use Seboettg\CiteProc\Exception\InvalidStylesheetException;
17
use Seboettg\CiteProc\Rendering\Date\DateRange\DateRangeRenderer;
18
use Seboettg\CiteProc\Styles\AffixesTrait;
19
use Seboettg\CiteProc\Styles\DisplayTrait;
20
use Seboettg\CiteProc\Styles\FormattingTrait;
21
use Seboettg\CiteProc\Styles\TextCaseTrait;
22
use Seboettg\CiteProc\Util;
23
use Seboettg\Collection\Lists\ListInterface;
24
use Seboettg\Collection\Map\MapInterface;
25
use Seboettg\Collection\Map\Pair;
26
use SimpleXMLElement;
27
use function Seboettg\Collection\Lists\emptyList;
28
use function Seboettg\Collection\Lists\listOf;
29
use function Seboettg\Collection\Map\emptyMap;
30
use function Seboettg\Collection\Map\pair;
31
32
class Date
33
{
34
    use AffixesTrait,
0 ignored issues
show
Bug introduced by
The trait Seboettg\CiteProc\Styles\AffixesTrait requires the property $single which is not provided by Seboettg\CiteProc\Rendering\Date\Date.
Loading history...
35
        DisplayTrait,
36
        FormattingTrait,
37
        TextCaseTrait;
38
39
    // bitmask: ymd
40
    public const DATE_RANGE_STATE_NONE         = 0; // 000
41
    public const DATE_RANGE_STATE_DAY          = 1; // 001
42
    public const DATE_RANGE_STATE_MONTH        = 2; // 010
43
    public const DATE_RANGE_STATE_MONTHDAY     = 3; // 011
44
    public const DATE_RANGE_STATE_YEAR         = 4; // 100
45
    public const DATE_RANGE_STATE_YEARDAY      = 5; // 101
46
    public const DATE_RANGE_STATE_YEARMONTH    = 6; // 110
47
    public const DATE_RANGE_STATE_YEARMONTHDAY = 7; // 111
48
49
    private static $localizedDateFormats = [
50
        'numeric',
51
        'text'
52
    ];
53
54
    private ListInterface $dateParts;
55
    private string $form = "numeric";
56
    private string $variable = "";
57
    private string $datePartsAttribute = "";
58
59
    /**
60
     * Date constructor.
61
     * @param SimpleXMLElement $node
62
     * @throws InvalidStylesheetException
63
     */
64
    public function __construct(SimpleXMLElement $node)
65
    {
66
        $this->dateParts = emptyList();
67
68
        /** @var SimpleXMLElement $attribute */
69
        foreach ($node->attributes() as $attribute) {
70
            switch ($attribute->getName()) {
71
                case 'form':
72
                    $this->form = (string) $attribute;
73
                    break;
74
                case 'variable':
75
                    $this->variable = (string) $attribute;
76
                    break;
77
                case 'date-parts':
78
                    $this->datePartsAttribute = (string) $attribute;
79
            }
80
        }
81
        /** @var SimpleXMLElement $child */
82
        foreach ($node->children() as $child) {
83
            if ($child->getName() === "date-part") {
84
                $datePartName = (string) $child->attributes()["name"];
85
                $this->dateParts->add(pair($this->form . "-" . $datePartName, Util\Factory::create($child)));
86
            }
87
        }
88
89
        $this->initAffixesAttributes($node);
90
        $this->initDisplayAttributes($node);
91
        $this->initFormattingAttributes($node);
92
        $this->initTextCaseAttributes($node);
93
    }
94
95
    /**
96
     * @param $data
97
     * @return string
98
     * @throws InvalidStylesheetException
99
     * @throws Exception
100
     */
101
    public function render($data)
102
    {
103
        $ret = "";
104
        $var = null;
0 ignored issues
show
Unused Code introduced by
The assignment to $var is dead and can be removed.
Loading history...
105
        if (isset($data->{$this->variable})) {
106
            $var = $data->{$this->variable};
107
        } else {
108
            return "";
109
        }
110
111
        try {
112
            $this->prepareDatePartsInVariable($data, $var);
113
        } catch (CiteProcException $e) {
114
            if (isset($data->{$this->variable}->{'raw'}) &&
115
                !preg_match("/(\p{L}+)\s?([\-\–&,])\s?(\p{L}+)/u", $data->{$this->variable}->{'raw'})) {
116
                return $this->addAffixes($this->format($this->applyTextCase($data->{$this->variable}->{'raw'})));
117
            } else {
118
                if (isset($data->{$this->variable}->{'string-literal'})) {
119
                    return $this->addAffixes(
120
                        $this->format($this->applyTextCase($data->{$this->variable}->{'string-literal'}))
121
                    );
122
                }
123
            }
124
        }
125
126
        $form = $this->form;
127
        $dateParts = !empty($this->datePartsAttribute) ?
128
            listOf(...explode("-", $this->datePartsAttribute)) : emptyList();
129
        $this->prepareDatePartsChildren($dateParts, $form);
130
131
        // No date-parts in date-part attribute defined, take into account that the defined date-part children will
132
        // be used.
133
        if (empty($this->datePartsAttribute) && $this->dateParts->count() > 0) {
134
            /** @var DatePart $part */
135
            $dateParts = $this->dateParts->map(fn (Pair $pair) => $pair->getValue()->getName());
136
        }
137
138
        /* cs:date may have one or more cs:date-part child elements (see Date-part). The attributes set on
139
        these elements override those specified for the localized date formats (e.g. to get abbreviated months for all
140
        locales, the form attribute on the month-cs:date-part element can be set to “short”). These cs:date-part
141
        elements do not affect which, or in what order, date parts are rendered. Affixes, which are very
142
        locale-specific, are not allowed on these cs:date-part elements. */
143
144
        if ($this->dateParts->count() > 0) {
145
            if (!isset($var->{'date-parts'})) { // ignore empty date-parts
146
                return "";
147
            }
148
149
            if (count($data->{$this->variable}->{'date-parts'}) === 1) {
150
                list($from) = $this->createDateTime($data->{$this->variable}->{'date-parts'});
151
                $ret .= $this->iterateAndRenderDateParts($dateParts, $from);
152
            } elseif (count($var->{'date-parts'}) === 2) { //date range
153
                list($from, $to) = $this->createDateTime($var->{'date-parts'});
154
155
156
                $interval = $to->diff($from);
157
                $delimiter = "";
158
                $toRender = 0;
159
                if ($interval->y > 0 && $dateParts->contains("year")) {
160
                    $toRender |= self::DATE_RANGE_STATE_YEAR;
161
                    $delimiter = $this->dateParts
162
                        ->filter(fn (Pair $pair) => $pair->getKey() === $this->form . "-year")
163
                        ->first()
164
                        ->getValue()
165
                        ->getRangeDelimiter();
166
                }
167
                if ($interval->m > 0 && $from->getMonth() - $to->getMonth() !== 0 && $dateParts->contains("month")) {
168
                    $toRender |= self::DATE_RANGE_STATE_MONTH;
169
                    $delimiter = $this->dateParts
170
                        ->filter(fn (Pair $pair) => $pair->getKey() === $this->form . "-month")
171
                        ->first()
172
                        ->getValue()
173
                        ->getRangeDelimiter();
174
                }
175
                if ($interval->d > 0 && $from->getDay() - $to->getDay() !== 0 && $dateParts->contains("day")) {
176
                    $toRender |= self::DATE_RANGE_STATE_DAY;
177
                    $delimiter = $this->dateParts
178
                        ->filter(fn (Pair $pair) => $pair->getKey() === "$this->form-day")
179
                        ->first()
180
                        ->getValue()
181
                        ->getRangeDelimiter();
182
                }
183
                if ($toRender === self::DATE_RANGE_STATE_NONE) {
184
                    $ret .= $this->iterateAndRenderDateParts($dateParts, $from);
185
                } else {
186
                    $ret .= $this->renderDateRange($toRender, $from, $to, $delimiter);
187
                }
188
            }
189
190
            if (isset($var->raw) && preg_match("/(\p{L}+)\s?([\-–&,])\s?(\p{L}+)/u", $var->raw, $matches)) {
191
                return $matches[1].$matches[2].$matches[3];
192
            }
193
        } elseif (!empty($this->datePartsAttribute)) {
194
            // fallback:
195
            // When there are no dateParts children, but date-parts attribute in date
196
            // render numeric
197
            $data = $this->createDateTime($var->{'date-parts'});
198
            $ret = $this->renderNumeric($data[0]);
199
        }
200
201
        return !empty($ret) ? $this->addAffixes($this->format($this->applyTextCase($ret))) : "";
202
    }
203
204
    /**
205
     * @throws Exception
206
     */
207
    private function createDateTime(array $dates): array
208
    {
209
        $data = [];
210
        foreach ($dates as $date) {
211
            $date = $this->cleanDate($date);
212
            if ($date[0] < 1000) {
213
                $dateTime = new DateTime(0, 0, 0);
214
                $dateTime->setDay(0)->setMonth(0)->setYear(0);
215
                $data[] = $dateTime;
216
            }
217
            $dateTime = new DateTime(
218
                $date[0],
219
                array_key_exists(1, $date) ? $date[1] : 1,
220
                array_key_exists(2, $date) ? $date[2] : 1
221
            );
222
            if (!array_key_exists(1, $date)) {
223
                $dateTime->setMonth(0);
224
            }
225
            if (!array_key_exists(2, $date)) {
226
                $dateTime->setDay(0);
227
            }
228
            $data[] = $dateTime;
229
        }
230
231
        return $data;
232
    }
233
234
    private function renderDateRange(int $toRender, DateTime $from, DateTime $to, $delimiter): string
235
    {
236
        $datePartRenderer = DateRangeRenderer::factory($this, $toRender);
237
        return $datePartRenderer->parseDateRange($this->dateParts, $from, $to, $delimiter);
238
    }
239
240
    private function hasDatePartsFromLocales(string $format): bool
241
    {
242
        $dateXml = CiteProc::getContext()->getLocale()->getDateXml();
243
        return !empty($dateXml[$format]);
244
    }
245
246
    private function getDatePartsFromLocales($format): array
247
    {
248
        $ret = [];
249
        // date parts from locales
250
        $dateFromLocale_ = CiteProc::getContext()->getLocale()->getDateXml();
251
        $dateFromLocale = $dateFromLocale_[$format];
252
253
        // no custom date parts within the date element (this)?
254
        if (!empty($dateFromLocale)) {
255
            $dateForm = array_filter(
256
                is_array($dateFromLocale) ? $dateFromLocale : [$dateFromLocale],
257
                function ($element) use ($format) {
258
                    /** @var SimpleXMLElement $element */
259
                    $dateForm = (string) $element->attributes()["form"];
260
                    return $dateForm === $format;
261
                }
262
            );
263
264
            //has dateForm from locale children (date-part elements)?
265
            $localeDate = array_pop($dateForm);
266
267
            if ($localeDate instanceof SimpleXMLElement && $localeDate->count() > 0) {
268
                foreach ($localeDate as $child) {
269
                    $ret[] = $child;
270
                }
271
            }
272
        }
273
        return $ret;
274
    }
275
276
    /**
277
     * @return string
278
     */
279
    public function getVariable()
280
    {
281
        return $this->variable;
282
    }
283
284
    /**
285
     * @param $data
286
     * @param $var
287
     * @throws CiteProcException
288
     */
289
    private function prepareDatePartsInVariable($data, $var)
290
    {
291
        if (!isset($data->{$this->variable}->{'date-parts'}) || empty($data->{$this->variable}->{'date-parts'})) {
292
            if (isset($data->{$this->variable}->raw) && !empty($data->{$this->variable}->raw)) {
293
                // try to parse date parts from "raw" attribute
294
                $var->{'date-parts'} = Util\DateHelper::parseDateParts($data->{$this->variable});
295
            } else {
296
                throw new CiteProcException("No valid date format");
297
            }
298
        }
299
    }
300
301
    /**
302
     * @param ListInterface $dateParts
303
     * @param string $form
304
     * @throws InvalidStylesheetException
305
     */
306
    private function prepareDatePartsChildren($dateParts, $form)
307
    {
308
        /* Localized date formats are selected with the optional form attribute, which must set to either “numeric”
309
        (for fully numeric formats, e.g. “12-15-2005”), or “text” (for formats with a non-numeric month, e.g.
310
        “December 15, 2005”). Localized date formats can be customized in two ways. First, the date-parts attribute may
311
        be used to show fewer date parts. The possible values are:
312
            - “year-month-day” - (default), renders the year, month and day
313
            - “year-month” - renders the year and month
314
            - “year” - renders the year */
315
316
        if ($this->dateParts->count() < 1 && in_array($form, self::$localizedDateFormats)) {
317
            if ($this->hasDatePartsFromLocales($form)) {
318
                $datePartsFromLocales = $this->getDatePartsFromLocales($form);
319
                array_filter($datePartsFromLocales, function (SimpleXMLElement $item) use ($dateParts) {
320
                    return $dateParts->contains($item["name"]);
321
                });
322
323
                foreach ($datePartsFromLocales as $datePartNode) {
324
                    $datePart = $datePartNode["name"];
325
                    $this->dateParts->add(pair("$form-$datePart", Util\Factory::create($datePartNode)));
326
                }
327
            } else { //otherwise create default date parts
328
                foreach ($dateParts as $datePart) {
329
                    $this->dateParts->add(
330
                        "$form-$datePart",
331
                        new DatePart(
0 ignored issues
show
Unused Code introduced by
The call to Seboettg\Collection\Lists\ListInterface::add() has too many arguments starting with new Seboettg\CiteProc\Re...m="' . $form . '" />')). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

331
                    $this->dateParts->/** @scrutinizer ignore-call */ 
332
                                      add(

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
332
                            new SimpleXMLElement('<date-part name="'.$datePart.'" form="'.$form.'" />')
333
                        )
334
                    );
335
                }
336
            }
337
        }
338
    }
339
340
341
    private function renderNumeric(DateTime $date)
342
    {
343
        return $date->renderNumeric();
344
    }
345
346
    public function getForm()
347
    {
348
        return $this->form;
349
    }
350
351
    private function cleanDate($date)
352
    {
353
        $ret = [];
354
        foreach ($date as $key => $datePart) {
355
            $ret[$key] = Util\NumberHelper::extractNumber(Util\StringHelper::removeBrackets($datePart));
356
        }
357
        return $ret;
358
    }
359
360
    /**
361
     * @param ListInterface $dateParts
362
     * @param DateTime $from
363
     * @return string
364
     */
365
    private function iterateAndRenderDateParts(ListInterface $dateParts, DateTime $from): string
366
    {
367
        $glue = $this->datePartsHaveAffixes() ? "" : " ";
368
        $result = $this->dateParts
369
            ->filter(function (Pair $datePartPair) use ($dateParts) {
370
                list($_, $p) = explode("-", $datePartPair->getKey());
0 ignored issues
show
Bug introduced by
It seems like $datePartPair->getKey() can also be of type boolean; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

370
                list($_, $p) = explode("-", /** @scrutinizer ignore-type */ $datePartPair->getKey());
Loading history...
371
                return $dateParts->contains($p);
372
            })
373
            ->map(fn (Pair $datePartPair) => $datePartPair->getValue()->render($from, $this))
374
            ->filter()
375
            ->joinToString($glue);
376
        return trim($result);
377
    }
378
379
    /**
380
     * @return bool
381
     */
382
    private function datePartsHaveAffixes(): bool
383
    {
384
        return $this->dateParts
385
            ->filter(fn (Pair $datePartPair) =>
386
                $datePartPair->getValue()->renderSuffix() !== "" || $datePartPair->getValue()->renderPrefix() !== "")
387
            ->count() > 0;
388
    }
389
}
390