1: <?php declare(strict_types=1);
2:
3: namespace Salient\Utility;
4:
5: use Salient\Contract\Core\Jsonable;
6: use DateTimeInterface;
7: use InvalidArgumentException;
8: use JsonException;
9:
10: /**
11: * Make data human-readable
12: *
13: * @api
14: */
15: final class Format extends AbstractUtility
16: {
17: private const BINARY_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
18: private const DECIMAL_UNITS = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB', 'RB', 'QB'];
19:
20: /**
21: * Format values in a list
22: *
23: * @param mixed[]|null $list
24: * @param string $format Passed to {@see sprintf()} with each value after it
25: * is formatted by {@see Format::value()} and indented (if applicable).
26: * @param int $indent Spaces to add after newlines in values.
27: * @param string|null $characters Characters to trim from the end of the
28: * result, `null` to trim whitespace, or an empty string (the default) to
29: * trim nothing.
30: */
31: public static function list(
32: ?array $list,
33: string $format = "- %s\n",
34: int $indent = 2,
35: ?string $characters = ''
36: ): string {
37: if ($list === null || !$list) {
38: return '';
39: }
40: $indent = $indent > 0 ? str_repeat(' ', $indent) : '';
41: $string = '';
42: foreach ($list as $value) {
43: $value = self::value($value);
44: if ($indent !== '') {
45: $value = str_replace("\n", "\n" . $indent, $value);
46: }
47: $string .= sprintf($format, $value);
48: }
49: return $characters === ''
50: ? $string
51: : ($characters === null
52: ? rtrim($string)
53: : rtrim($string, $characters));
54: }
55:
56: /**
57: * Format keys and values in an array
58: *
59: * To improve readability, newlines are added before multi-line values.
60: *
61: * @param mixed[]|null $array
62: * @param string $format Passed to {@see sprintf()} with each key and value
63: * after the latter is formatted by {@see Format::value()} and indented (if
64: * applicable).
65: * @param int $indent Spaces to add after newlines in values.
66: * @param string|null $characters Characters to trim from the end of the result,
67: * `null` to trim whitespace, or an empty string (the default) to trim
68: * nothing.
69: */
70: public static function array(
71: ?array $array,
72: string $format = "%s: %s\n",
73: int $indent = 4,
74: ?string $characters = ''
75: ): string {
76: if ($array === null || !$array) {
77: return '';
78: }
79: $indent = $indent > 0 ? str_repeat(' ', $indent) : '';
80: $string = '';
81: foreach ($array as $key => $value) {
82: $value = self::value($value);
83: if ($indent !== '') {
84: $value = str_replace("\n", "\n" . $indent, $value, $count);
85: if ($count) {
86: $value = "\n" . $indent . $value;
87: }
88: }
89: $string .= sprintf($format, $key, $value);
90: }
91: return $characters === ''
92: ? $string
93: : ($characters === null
94: ? rtrim($string)
95: : rtrim($string, $characters));
96: }
97:
98: /**
99: * Format a value
100: *
101: * @param mixed $value
102: */
103: public static function value($value): string
104: {
105: if ($value === null) {
106: return '';
107: }
108: if (Test::isStringable($value)) {
109: return Str::setEol((string) $value);
110: }
111: if (is_bool($value)) {
112: return self::bool($value);
113: }
114: if (is_scalar($value)) {
115: return (string) $value;
116: }
117: if ($value instanceof Jsonable) {
118: return $value->toJson(Json::ENCODE_FLAGS);
119: }
120: try {
121: return Json::encode($value);
122: } catch (JsonException $ex) {
123: return '<' . Get::type($value) . '>';
124: }
125: }
126:
127: /**
128: * Format a boolean as "true" or "false"
129: */
130: public static function bool(?bool $value): string
131: {
132: return $value === null ? '' : ($value ? 'true' : 'false');
133: }
134:
135: /**
136: * Format a boolean as "yes" or "no"
137: */
138: public static function yn(?bool $value): string
139: {
140: return $value === null ? '' : ($value ? 'yes' : 'no');
141: }
142:
143: /**
144: * Format a date and time without redundant information
145: */
146: public static function date(
147: ?DateTimeInterface $date,
148: string $before = '[',
149: ?string $after = ']',
150: ?string $thisYear = null
151: ): string {
152: if ($date === null) {
153: return '';
154: }
155:
156: $thisYear ??= date('Y');
157:
158: $date = Date::maybeSetTimezone($date);
159:
160: // - Start with "Tue 9 Apr"
161: // - Add " 2024" if `$date` is not in the current year
162: // - Add " 16:52:31 AEST" if the time is not midnight
163: $format = 'D j M';
164: if ($date->format('Y') !== $thisYear) {
165: $format .= ' Y';
166: }
167: if ($date->format('H:i:s') !== '00:00:00') {
168: $format .= ' H:i:s T';
169: }
170:
171: return Str::enclose($date->format($format), $before, $after);
172: }
173:
174: /**
175: * Format a date and time range without redundant information
176: */
177: public static function dateRange(
178: ?DateTimeInterface $from,
179: ?DateTimeInterface $to,
180: string $delimiter = '–',
181: string $before = '[',
182: ?string $after = ']',
183: ?string $thisYear = null
184: ): string {
185: if ($from === null && $to === null) {
186: return '';
187: }
188:
189: $thisYear ??= date('Y');
190:
191: if ($from === null || $to === null) {
192: return sprintf(
193: '%s%s%s',
194: self::date($from, $before, $after, $thisYear),
195: $delimiter,
196: self::date($to, $before, $after, $thisYear),
197: );
198: }
199:
200: $from = Date::maybeSetTimezone($from);
201: $to = Date::maybeSetTimezone($to);
202:
203: [$fromTimezone, $fromYear, $fromTime] =
204: [$from->format('T'), $from->format('Y'), $from->format('H:i:s')];
205: [$toTimezone, $toYear, $toTime] =
206: [$to->format('T'), $to->format('Y'), $to->format('H:i:s')];
207:
208: // - Start with "Tue 9 Apr"
209: // - Add " 2024" to both if they are in different years, or once if they
210: // are not in the current year
211: // - If the time of `$from` or `$to` is not midnight:
212: // - Add " 16:52:31" to both
213: // - Add " AEST" to both if they are in different timezones, or once
214: // if they are in the same timezone
215: $fromFormat = $toFormat = 'D j M';
216: if ($fromYear !== $toYear) {
217: $fromFormat = $toFormat .= ' Y';
218: } elseif ($fromYear !== $thisYear) {
219: $toFormat .= ' Y';
220: }
221: if ($fromTime !== '00:00:00' || $toTime !== '00:00:00') {
222: $fromFormat .= ' H:i:s';
223: $toFormat .= ' H:i:s T';
224: if ($fromTimezone !== $toTimezone) {
225: $fromFormat .= ' T';
226: }
227: }
228:
229: return sprintf(
230: '%s%s%s',
231: Str::enclose($from->format($fromFormat), $before, $after),
232: $delimiter,
233: Str::enclose($to->format($toFormat), $before, $after),
234: );
235: }
236:
237: /**
238: * Format a size in bytes by rounding to an appropriate binary or decimal
239: * unit (B, KiB/kB, MiB/MB, GiB/GB, ...)
240: */
241: public static function bytes(
242: ?int $bytes,
243: int $precision = 3,
244: bool $binary = true
245: ): string {
246: if ($bytes === null) {
247: return '';
248: }
249: if ($bytes < 0) {
250: throw new InvalidArgumentException('$bytes cannot be less than zero');
251: }
252: if ($precision < 0) {
253: throw new InvalidArgumentException('$precision cannot be less than zero');
254: }
255:
256: [$base, $units] = $binary
257: ? [1024, self::BINARY_UNITS]
258: : [1000, self::DECIMAL_UNITS];
259: $maxPower = count($units) - 1;
260: $power = $bytes
261: ? min($maxPower, (int) (log($bytes) / log($base)))
262: : 0;
263: $bytes = $bytes / $base ** $power;
264:
265: if ($bytes >= 1000 && $precision && $power < $maxPower) {
266: $power++;
267: $bytes /= $base;
268: }
269:
270: return sprintf(
271: $precision && $power ? "%.{$precision}f%s" : '%d%s',
272: $precision ? (int) ($bytes * 10 ** $precision) / 10 ** $precision : (int) $bytes,
273: $units[$power],
274: );
275: }
276: }
277: