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