Completed
Pull Request — master (#6783)
by Ingo
08:19
created

TimeField::getInternalFormatter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 9
nc 1
nop 0
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Forms;
4
5
use IntlDateFormatter;
6
use InvalidArgumentException;
7
use SilverStripe\i18n\i18n;
8
use SilverStripe\ORM\FieldType\DBDatetime;
9
use SilverStripe\ORM\FieldType\DBTime;
10
11
/**
12
 * Form field to display editable time values in an <input type="text"> field.
13
 *
14
 * # Localization
15
 *
16
 * See {@link DateField}
17
 *
18
 * @todo Timezone support
19
 */
20
class TimeField extends TextField
21
{
22
    protected $schemaDataType = FormField::SCHEMA_DATA_TYPE_TIME;
23
24
    /**
25
     * Override locale. If empty will default to current locale
26
     *
27
     * @var string
28
     */
29
    protected $locale = null;
30
31
    /**
32
     * Override time format. If empty will default to that used by the current locale.
33
     *
34
     * @var string
35
     */
36
    protected $timeFormat = null;
37
38
    /**
39
     * Length of this date (full, short, etc).
40
     *
41
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
42
     * @var int
43
     */
44
    protected $timeLength = null;
45
46
    /**
47
     * Unparsed value, used exclusively for comparing with internal value
48
     * to detect invalid values.
49
     *
50
     * @var mixed
51
     */
52
    protected $rawValue = null;
53
54
    /**
55
     * Set custom timezone
56
     *
57
     * @var string
58
     */
59
    protected $timezone = null;
60
61
    /**
62
     * Use HTML5-based input fields (and force ISO 8601 time formats).
63
     *
64
     * @var bool
65
     */
66
    protected $html5 = true;
67
68
    /**
69
     * @return bool
70
     */
71
    public function getHTML5()
72
    {
73
        return $this->html5;
74
    }
75
76
    /**
77
     * @param boolean $bool
78
     * @return $this
79
     */
80
    public function setHTML5($bool)
81
    {
82
        $this->html5 = $bool;
83
        return $this;
84
    }
85
86
    /**
87
     * Get time format in CLDR standard format
88
     *
89
     * This can be set explicitly. If not, this will be generated from the current locale
90
     * with the current time length.
91
     *
92
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
93
     */
94
    public function getTimeFormat()
95
    {
96
        if ($this->getHTML5()) {
97
            // Browsers expect ISO 8601 times, localisation is handled on the client
98
            $this->setTimeFormat(DBTime::ISO_TIME);
99
        }
100
101
        if ($this->timeFormat) {
102
            return $this->timeFormat;
103
        }
104
105
        // Get from locale
106
        return $this->getFrontendFormatter()->getPattern();
107
    }
108
109
    /**
110
     * Set time format in CLDR standard format.
111
     * Only applicable with {@link setHTML5(false)}.
112
     *
113
     * @see http://userguide.icu-project.org/formatparse/datetime#TOC-Date-Field-Symbol-Table
114
     * @param string $format
115
     * @return $this
116
     */
117
    public function setTimeFormat($format)
118
    {
119
        $this->timeFormat = $format;
120
        return $this;
121
    }
122
123
    /**
124
     * Get length of the time format to use. One of:
125
     *
126
     *  - IntlDateFormatter::SHORT E.g. '6:31 PM'
127
     *  - IntlDateFormatter::MEDIUM E.g. '6:30:48 PM'
128
     *  - IntlDateFormatter::LONG E.g. '6:32:09 PM NZDT'
129
     *  - IntlDateFormatter::FULL E.g. '6:32:24 PM New Zealand Daylight Time'
130
     *
131
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
132
     * @return int
133
     */
134
    public function getTimeLength()
135
    {
136
        if ($this->timeLength) {
137
            return $this->timeLength;
138
        }
139
        return IntlDateFormatter::MEDIUM;
140
    }
141
142
    /**
143
     * Get length of the time format to use.
144
     * Only applicable with {@link setHTML5(false)}.
145
     *
146
     * @see http://php.net/manual/en/class.intldateformatter.php#intl.intldateformatter-constants
147
     *
148
     * @param int $length
149
     * @return $this
150
     */
151
    public function setTimeLength($length)
152
    {
153
        $this->timeLength = $length;
154
        return $this;
155
    }
156
157
    /**
158
     * Get time formatter with the standard locale / date format
159
     *
160
     * @return IntlDateFormatter
161
     */
162
    protected function getFrontendFormatter()
163
    {
164
        if ($this->getHTML5() && $this->timeFormat && $this->timeFormat !== DBTime::ISO_TIME) {
165
            throw new \LogicException(
166
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setTimeFormat()'
167
            );
168
        }
169
170
        if ($this->getHTML5() && $this->timeLength) {
171
            throw new \LogicException(
172
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setTimeLength()'
173
            );
174
        }
175
176
        if ($this->getHTML5() && $this->locale) {
177
            throw new \LogicException(
178
                'Please opt-out of HTML5 processing of ISO 8601 times via setHTML5(false) if using setLocale()'
179
            );
180
        }
181
182
        $formatter =  IntlDateFormatter::create(
183
            $this->getLocale(),
184
            IntlDateFormatter::NONE,
185
            $this->getTimeLength(),
186
            $this->getTimezone()
187
        );
188
189
        if ($this->getHTML5()) {
190
            // Browsers expect ISO 8601 times, localisation is handled on the client
191
            $formatter->setPattern(DBTime::ISO_TIME);
192
            // Don't invoke getTimeFormat() directly to avoid infinite loop
193
        } elseif ($this->timeFormat) {
194
            $ok = $formatter->setPattern($this->timeFormat);
195
            if (!$ok) {
196
                throw new InvalidArgumentException("Invalid time format {$this->timeFormat}");
197
            }
198
        }
199
        return $formatter;
200
    }
201
202
    /**
203
     * Get a time formatter for the ISO 8601 format
204
     *
205
     * @return IntlDateFormatter
206
     */
207
    protected function getInternalFormatter()
208
    {
209
        $formatter = IntlDateFormatter::create(
210
            i18n::config()->uninherited('default_locale'),
211
            IntlDateFormatter::NONE,
212
            IntlDateFormatter::MEDIUM,
213
            date_default_timezone_get() // Default to server timezone
214
        );
215
        $formatter->setLenient(false);
216
217
        // Note we omit timezone from this format, and we assume server TZ always.
218
        $formatter->setPattern(DBTime::ISO_TIME);
219
220
        return $formatter;
221
    }
222
223
    public function getAttributes()
224
    {
225
        $attributes = parent::getAttributes();
226
227
        if ($this->getHTML5()) {
228
            $attributes['type'] = 'time';
229
        }
230
231
        return $attributes;
232
    }
233
234
    public function getSchemaDataDefaults()
235
    {
236
        $defaults = parent::getSchemaDataDefaults();
237
        return array_merge($defaults, [
238
            'lang' => i18n::convert_rfc1766($this->getLocale()),
239
            'data' => array_merge($defaults['data'], [
240
                'html5' => $this->getHTML5(),
241
            ])
242
        ]);
243
    }
244
245
    public function Type()
246
    {
247
        return 'time text';
248
    }
249
250
    /**
251
     * Assign value posted from form submission
252
     *
253
     * @param mixed $value
254
     * @param mixed $data
255
     * @return $this
256
     */
257
    public function setSubmittedValue($value, $data = null)
258
    {
259
        // Save raw value for later validation
260
        $this->rawValue = $value;
261
262
        // Parse from submitted value
263
        $this->value = $this->frontendToInternal($value);
264
        return $this;
265
    }
266
267
    /**
268
     * Set time assigned from database value
269
     *
270
     * @param mixed $value
271
     * @param mixed $data
272
     * @return $this
273
     */
274
    public function setValue($value, $data = null)
275
    {
276
        // Save raw value for later validation
277
        $this->rawValue = $value;
278
279
        // Null case
280
        if (!$value) {
281
            $this->value = null;
282
            return $this;
283
        }
284
285
        // Re-run through formatter to tidy up (e.g. remove date component)
286
        $this->value = $this->tidyInternal($value);
287
        return $this;
288
    }
289
290
    public function Value()
291
    {
292
        $localised = $this->internalToFrontend($this->value);
293
        if ($localised) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $localised of type null|string is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
294
            return $localised;
295
        }
296
297
        // Show midnight in localised format
298
        return $this->getMidnight();
299
    }
300
301
    /**
302
     * Show midnight in current format (adjusts for timezone)
303
     *
304
     * @return string
305
     */
306
    public function getMidnight()
307
    {
308
        $formatter = $this->getFrontendFormatter();
309
        $timestamp = $this->withTimezone($this->getTimezone(), function () {
310
            return strtotime('midnight');
311
        });
312
        return $formatter->format($timestamp);
313
    }
314
315
    /**
316
     * Validate this field
317
     *
318
     * @param Validator $validator
319
     * @return bool
320
     */
321
    public function validate($validator)
322
    {
323
        // Don't validate empty fields
324
        if (empty($this->rawValue)) {
325
            return true;
326
        }
327
328
        // We submitted a value, but it couldn't be parsed
329
        if (empty($this->value)) {
330
            $validator->validationError(
331
                $this->name,
332
                _t(
333
                    'TimeField.VALIDATEFORMAT',
334
                    "Please enter a valid time format ({format})",
335
                    ['format' => $this->getTimeFormat()]
336
                )
337
            );
338
            return false;
339
        }
340
        return true;
341
    }
342
343
    /**
344
     * @return string
345
     */
346
    public function getLocale()
347
    {
348
        return $this->locale ?: i18n::get_locale();
349
    }
350
351
    /**
352
     * Determines the presented/processed format based on locale defaults,
353
     * instead of explicitly setting {@link setTimeFormat()}.
354
     * Only applicable with {@link setHTML5(false)}.
355
     *
356
     * @param string $locale
357
     * @return $this
358
     */
359
    public function setLocale($locale)
360
    {
361
        $this->locale = $locale;
362
        return $this;
363
    }
364
365
    /**
366
     * Creates a new readonly field specified below
367
     *
368
     * @return TimeField_Readonly
369
     */
370
    public function performReadonlyTransformation()
371
    {
372
        /** @var TimeField_Readonly $result */
373
        $result = $this->castedCopy(TimeField_Readonly::class);
374
        return $result;
375
    }
376
377
    /**
378
     * Convert frontend time to the internal representation (ISO 8601).
379
     * The frontend time is also in ISO 8601 when $html5=true.
380
     *
381
     * @param string $time
382
     * @return string The formatted time, or null if not a valid time
383
     */
384
    protected function frontendToInternal($time)
385
    {
386
        if (!$time) {
387
            return null;
388
        }
389
        $fromFormatter = $this->getFrontendFormatter();
390
        $toFormatter = $this->getInternalFormatter();
391
        $timestamp = $fromFormatter->parse($time);
392
393
        // Try to parse time without seconds, since that's a valid HTML5 submission format
394
        // See https://html.spec.whatwg.org/multipage/infrastructure.html#times
395
        if ($timestamp === false && $this->getHTML5()) {
396
            $fromFormatter->setPattern(str_replace(':ss', '', DBTime::ISO_TIME));
397
            $timestamp = $fromFormatter->parse($time);
398
        }
399
400
        // If timestamp still can't be detected, we've got an invalid time
401
        if ($timestamp === false) {
402
            return null;
403
        }
404
405
        return $toFormatter->format($timestamp);
406
    }
407
408
    /**
409
     * Convert the internal time representation (ISO 8601) to a format used by the frontend,
410
     * as defined by {@link $timeFormat}. With $html5=true, the frontend time will also be
411
     * in ISO 8601.
412
     *
413
     * @param string $time
414
     * @return string
415
     */
416
    protected function internalToFrontend($time)
417
    {
418
        $time = $this->tidyInternal($time);
419
        if (!$time) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $time of type null|string is loosely compared to false; this is ambiguous if the string can be empty. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
420
            return null;
421
        }
422
        $fromFormatter = $this->getInternalFormatter();
423
        $toFormatter = $this->getFrontendFormatter();
424
        $timestamp = $fromFormatter->parse($time);
425
        if ($timestamp === false) {
426
            return null;
427
        }
428
        return $toFormatter->format($timestamp);
429
    }
430
431
432
433
    /**
434
     * Tidy up the internal time representation (ISO 8601),
435
     * and fall back to strtotime() if there's parsing errors.
436
     *
437
     * @param string $time Time in ISO 8601 or approximate form
438
     * @return string ISO 8601 time, or null if not valid
439
     */
440
    protected function tidyInternal($time)
441
    {
442
        if (!$time) {
443
            return null;
444
        }
445
        // Re-run through formatter to tidy up (e.g. remove date component)
446
        $formatter = $this->getInternalFormatter();
447
        $timestamp = $formatter->parse($time);
448
        if ($timestamp === false) {
449
            // Fallback to strtotime
450
            $timestamp = strtotime($time, DBDatetime::now()->getTimestamp());
451
            if ($timestamp === false) {
452
                return null;
453
            }
454
        }
455
        return $formatter->format($timestamp);
456
    }
457
458
    /**
459
     * @return string
460
     */
461
    public function getTimezone()
462
    {
463
        return $this->timezone;
464
    }
465
466
    /**
467
     * @param string $timezone
468
     * @return $this
469
     */
470
    public function setTimezone($timezone)
471
    {
472
        if ($this->value && $timezone !== $this->timezone) {
473
            throw new \BadMethodCallException("Can't change timezone after setting a value");
474
        }
475
        $this->timezone = $timezone;
476
        return $this;
477
    }
478
479
480
    /**
481
     * Run a callback within a specific timezone
482
     *
483
     * @param string $timezone
484
     * @param callable $callback
485
     */
486
    protected function withTimezone($timezone, $callback)
487
    {
488
        $currentTimezone = date_default_timezone_get();
489
        try {
490
            if ($timezone) {
491
                date_default_timezone_set($timezone);
492
            }
493
            return $callback();
494
        } finally {
495
            // Restore timezone
496
            if ($timezone) {
497
                date_default_timezone_set($currentTimezone);
498
            }
499
        }
500
    }
501
}
502