1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui\Persistence;
6:
7: use Atk4\Data\Field;
8: use Atk4\Data\Field\PasswordField;
9: use Atk4\Data\Model;
10: use Atk4\Data\Persistence;
11: use Atk4\Data\Persistence\Sql\Expression;
12: use Atk4\Ui\Exception;
13:
14: /**
15: * This class is used for typecasting model types to the values that will be presented to the user. App will
16: * always initialize this persistence in $app->uiPersistence and this object will be used by various
17: * UI elements to output data to the user.
18: *
19: * Overriding and extending this class is a great place where you can tweak how various data-types are displayed
20: * to the user in the way so it would affect UI globally.
21: *
22: * You may want to localize some of the output.
23: */
24: class Ui extends Persistence
25: {
26: /** @var string */
27: public $locale = 'en';
28:
29: /** @var '.'|',' Decimal point separator for numeric (non-integer) types. */
30: public $decimalSeparator = '.';
31: /** @var ''|' '|','|'.' Thousands separator for numeric types. */
32: public $thousandsSeparator = ' ';
33:
34: /** @var string Currency symbol for 'atk4_money' type. */
35: public $currency = '€';
36: /** @var int Number of decimal digits for 'atk4_money' type. */
37: public $currencyDecimals = 2;
38:
39: /** @var string */
40: public $timezone;
41: /** @var string */
42: public $dateFormat = 'M j, Y';
43: /** @var string */
44: public $timeFormat = 'H:i';
45: /** @var string */
46: public $datetimeFormat = 'M j, Y H:i';
47: /** @var int Calendar input first day of week, 0 = Sunday, 1 = Monday. */
48: public $firstDayOfWeek = 0;
49:
50: /** @var string */
51: public $yes = 'Yes';
52: /** @var string */
53: public $no = 'No';
54:
55: public function __construct()
56: {
57: if ($this->timezone === null) {
58: $this->timezone = date_default_timezone_get();
59: }
60: }
61:
62: /**
63: * @return scalar|null
64: */
65: #[\Override]
66: public function typecastSaveField(Field $field, $value)
67: {
68: // relax empty checks for UI render for not yet set values
69: $fieldNullableOrig = $field->nullable;
70: $fieldRequiredOrig = $field->required;
71: if (in_array($value, [null, false, 0, 0.0, ''], true)) {
72: $field->nullable = true;
73: $field->required = false;
74: }
75: try {
76: return parent::typecastSaveField($field, $value);
77: } finally {
78: $field->nullable = $fieldNullableOrig;
79: $field->required = $fieldRequiredOrig;
80: }
81: }
82:
83: #[\Override]
84: protected function _typecastSaveField(Field $field, $value): string
85: {
86: // always normalize string EOL
87: if (is_string($value)) {
88: $value = preg_replace('~\r?\n|\r~', "\n", $value);
89: }
90:
91: // typecast using DBAL types
92: $value = parent::_typecastSaveField($field, $value);
93:
94: switch ($field->type) {
95: case 'boolean':
96: $value = parent::_typecastLoadField($field, $value);
97: $value = $value ? $this->yes : $this->no;
98:
99: break;
100: case 'integer':
101: case 'float':
102: $value = parent::_typecastLoadField($field, $value);
103: $value = is_int($value)
104: ? (string) $value
105: : Expression::castFloatToString($value);
106: $value = preg_replace_callback('~\.?\d+~', function ($matches) {
107: return substr($matches[0], 0, 1) === '.'
108: ? $this->decimalSeparator . preg_replace('~\d{3}\K(?!$)~', '', substr($matches[0], 1))
109: : preg_replace('~(?<!^)(?=(?:\d{3})+$)~', $this->thousandsSeparator, $matches[0]);
110: }, $value);
111: $value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);
112:
113: break;
114: case 'atk4_money':
115: $value = parent::_typecastLoadField($field, $value);
116: $valueDecimals = strlen(preg_replace('~^[^.]$|^.+\.|0+$~s', '', number_format($value, max(0, 11 - (int) log10($value)), '.', '')));
117: $value = ($this->currency ? $this->currency . ' ' : '')
118: . number_format($value, max($this->currencyDecimals, $valueDecimals), $this->decimalSeparator, $this->thousandsSeparator);
119: $value = str_replace(' ', "\u{00a0}" /* Unicode NBSP */, $value);
120:
121: break;
122: case 'date':
123: case 'datetime':
124: case 'time':
125: /** @var \DateTimeInterface|null */
126: $value = parent::_typecastLoadField($field, $value);
127: if ($value !== null) {
128: $format = [
129: 'date' => $this->dateFormat,
130: 'datetime' => $this->datetimeFormat,
131: 'time' => $this->timeFormat,
132: ][$field->type];
133:
134: $valueHasSeconds = (int) $value->format('s') !== 0;
135: $valueHasMicroseconds = (int) $value->format('u') !== 0;
136: $formatHasMicroseconds = str_contains($format, '.u');
137: if ($valueHasSeconds || $valueHasMicroseconds) {
138: $format = preg_replace('~(?<=:i)(?!:s)~', ':s', $format);
139: }
140: if ($valueHasMicroseconds) {
141: $format = preg_replace('~(?<=:s)(?!\.u)~', '.u', $format);
142: }
143:
144: if ($field->type === 'datetime') {
145: $value = new \DateTime($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
146: $value->setTimezone(new \DateTimeZone($this->timezone));
147: }
148: $value = $value->format($format);
149:
150: if (!$formatHasMicroseconds) {
151: $value = preg_replace('~(?<!\d|:)\d{1,2}:\d{1,2}(?::\d{1,2})?\.\d*?\K0+(?!\d)~', '', $value);
152: }
153: }
154:
155: break;
156: }
157:
158: return (string) $value;
159: }
160:
161: #[\Override]
162: protected function _typecastLoadField(Field $field, $value)
163: {
164: switch ($field->type) {
165: case 'boolean':
166: if (is_string($value)) {
167: $value = trim($value);
168: if (mb_strtolower($value) === mb_strtolower($this->yes)) {
169: $value = '1';
170: } elseif (mb_strtolower($value) === mb_strtolower($this->no)) {
171: $value = '0';
172: }
173: }
174:
175: break;
176: case 'integer':
177: case 'float':
178: case 'atk4_money':
179: if (is_string($value)) {
180: $dSep = $this->decimalSeparator;
181: $tSep = $this->thousandsSeparator;
182: if ($tSep !== '.' && $tSep !== ',' && !str_contains($value, $dSep)) {
183: if (str_contains($value, '.')) {
184: $dSep = '.';
185: } elseif (str_contains($value, ',')) {
186: $dSep = ',';
187: }
188: }
189:
190: $value = str_replace([' ', "\u{00a0}" /* Unicode NBSP */, '_', $tSep], '', $value);
191: $value = str_replace($dSep, '.', $value);
192:
193: if ($field->type === 'atk4_money' && $this->currency !== '' && substr_count($value, $this->currency) === 1) {
194: $currencyPos = strpos($value, $this->currency);
195: $beforeStr = substr($value, 0, $currencyPos);
196: $afterStr = substr($value, $currencyPos + strlen($this->currency));
197:
198: $value = $beforeStr
199: . (ctype_digit(substr($beforeStr, -1)) && ctype_digit(substr($afterStr, 0, 1)) ? '.' : '')
200: . $afterStr;
201: }
202: }
203:
204: break;
205: case 'date':
206: case 'datetime':
207: case 'time':
208: if ($value === '') {
209: return null;
210: }
211:
212: $dtClass = \DateTime::class;
213: $tzClass = \DateTimeZone::class;
214: $format = [
215: 'date' => $this->dateFormat,
216: 'datetime' => $this->datetimeFormat,
217: 'time' => $this->timeFormat,
218: ][$field->type];
219:
220: if (preg_match('~(?<!\d|:)\d{1,2}:\d{1,2}:\d{1,2}(?!\d)~', $value)) {
221: $format = preg_replace('~(?<=:i)(?!:s)~', ':s', $format);
222: }
223: if (preg_match('~(?<!\d|:)\d{1,2}:\d{1,2}(?::\d{1,2})?\.\d{1,9}(?!\d)~', $value)) {
224: $format = preg_replace('~(?<=:s)(?!\.u)~', '.u', $format);
225: }
226:
227: $valueOrig = $value;
228: $value = $dtClass::createFromFormat('!' . $format, $value, $field->type === 'datetime' ? new $tzClass($this->timezone) : null);
229: if ($value === false) {
230: throw (new Exception('Incorrectly formatted datetime'))
231: ->addMoreInfo('format', $format)
232: ->addMoreInfo('value', $valueOrig)
233: ->addMoreInfo('field', $field);
234: }
235:
236: if ($field->type === 'datetime') {
237: $value->setTimezone(new $tzClass(date_default_timezone_get()));
238: }
239:
240: $value = parent::_typecastSaveField($field, $value);
241:
242: break;
243: // <-- reindent once https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/6490 is merged
244: // SECURITY: do not unserialize any user input
245: // https://github.com/search?q=unserialize+repo%3Adoctrine%2Fdbal+path%3A%2Fsrc%2FTypes
246: case 'object':
247: case 'array':
248: throw new Exception('Object serialization is not supported');
249: }
250:
251: // typecast using DBAL type and normalize
252: $value = parent::_typecastLoadField($field, $value);
253: $value = (new Field(['type' => $field->type]))->normalize($value);
254:
255: if ($field->hasReference() && $value === '') {
256: return null;
257: }
258:
259: if ($value !== null && $field instanceof PasswordField && !$field->hashPasswordIsHashed($value)) {
260: $value = $field->hashPassword($value);
261: }
262:
263: return $value;
264: }
265:
266: /**
267: * Override parent method to ignore key change by Field::actual property.
268: */
269: #[\Override]
270: public function typecastSaveRow(Model $model, array $row): array
271: {
272: $result = [];
273: foreach ($row as $key => $value) {
274: $result[$key] = $this->typecastSaveField($model->getField($key), $value);
275: }
276:
277: return $result;
278: }
279: }
280: