Passed
Push — master ( acedf0...2adfe5 )
by Pauli
04:02
created

Util::arrayColumns()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 3
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2018 - 2024
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OCP\Files\Folder;
16
17
/**
18
 * Miscellaneous static utility functions
19
 */
20
class Util {
21
22
	const UINT32_MAX = 0xFFFFFFFF;
23
	const SINT32_MAX = 0x7FFFFFFF;
24
	const SINT32_MIN = -self::SINT32_MAX - 1;
25
26
	/**
27
	 * Map the given array by calling a named member function for each of the array elements
28
	 */
29
	public static function arrayMapMethod(array $arr, string $methodName, array $methodArgs=[]) : array {
30
		$func = function ($obj) use ($methodName, $methodArgs) {
31
			return \call_user_func_array([$obj, $methodName], $methodArgs);
32
		};
33
		return \array_map($func, $arr);
34
	}
35
36
	/**
37
	 * Extract ID of each array element by calling getId and return
38
	 * the IDs as an array
39
	 */
40
	public static function extractIds(array $arr) : array {
41
		return self::arrayMapMethod($arr, 'getId');
42
	}
43
44
	/**
45
	 * Extract User ID of each array element by calling getUserId and return
46
	 * the IDs as an array
47
	 */
48
	public static function extractUserIds(array $arr) : array {
49
		return self::arrayMapMethod($arr, 'getUserId');
50
	}
51
52
	/**
53
	 * Create a look-up table from given array of items which have a `getId` function.
54
	 * @return array where keys are the values returned by `getId` of each item
55
	 */
56
	public static function createIdLookupTable(array $array) : array {
57
		$lut = [];
58
		foreach ($array as $item) {
59
			$lut[$item->getId()] = $item;
60
		}
61
		return $lut;
62
	}
63
64
	/**
65
	 * Create a look-up table from given array so that keys of the table are obtained by calling
66
	 * the given method on each array entry and the values are arrays of entries having the same
67
	 * value returned by that method.
68
	 * @param string $getKeyMethod Name of a method found on $array entries which returns a string or an int
69
	 * @return array [int|string => array]
70
	 */
71
	public static function arrayGroupBy(array $array, string $getKeyMethod) : array {
72
		$lut = [];
73
		foreach ($array as $item) {
74
			$lut[$item->$getKeyMethod()][] = $item;
75
		}
76
		return $lut;
77
	}
78
79
	/**
80
	 * Get difference of two arrays, i.e. elements belonging to $b but not $a.
81
	 * This function is faster than the built-in array_diff for large arrays but
82
	 * at the expense of higher RAM usage and can be used only for arrays of
83
	 * integers or strings.
84
	 * From https://stackoverflow.com/a/8827033
85
	 */
86
	public static function arrayDiff(array $b, array $a) : array {
87
		$at = \array_flip($a);
88
		$d = [];
89
		foreach ($b as $i) {
90
			if (!isset($at[$i])) {
91
				$d[] = $i;
92
			}
93
		}
94
		return $d;
95
	}
96
97
	/**
98
	 * Get multiple items from @a $array, as indicated by a second array @a $keys.
99
	 * If @a $preserveKeys is given as true, the result will have the original keys, otherwise
100
	 * the result is re-indexed with keys 0, 1, 2, ...
101
	 */
102
	public static function arrayMultiGet(array $array, array $keys, bool $preserveKeys=false) : array {
103
		$result = [];
104
		foreach ($keys as $key) {
105
			if ($preserveKeys) {
106
				$result[$key] = $array[$key];
107
			} else {
108
				$result[] = $array[$key];
109
			}
110
		}
111
		return $result;
112
	}
113
114
	/**
115
	 * Get multiple columns from the multidimensional @a $array. This is similar to the built-in
116
	 * function \array_column except that this can return multiple columns and not just one.
117
	 * @param int|string|null $indexColumn
118
	 */
119
	public static function arrayColumns(array $array, array $columns, $indexColumn=null) : array {
120
		if ($indexColumn !== null) {
121
			$array = \array_column($array, null, $indexColumn);
122
		}
123
124
		return \array_map(fn($row) => self::arrayMultiGet($row, $columns, true), $array);
125
	}
126
127
	/**
128
	 * Like the built-in function \array_filter but this one works recursively on nested arrays.
129
	 * Another difference is that this function always requires an explicit callback condition.
130
	 * Both inner nodes and leafs nodes are passed to the $condition.
131
	 */
132
	public static function arrayFilterRecursive(array $array, callable $condition) : array {
133
		$result = [];
134
135
		foreach ($array as $key => $value) {
136
			if ($condition($value)) {
137
				if (\is_array($value)) {
138
					$result[$key] = self::arrayFilterRecursive($value, $condition);
139
				} else {
140
					$result[$key] = $value;
141
				}
142
			}
143
		}
144
145
		return $result;
146
	}
147
148
	/**
149
	 * Inverse operation of self::arrayFilterRecursive, keeping only those items where
150
	 * the $condition evaluates to *false*.
151
	 */
152
	public static function arrayRejectRecursive(array $array, callable $condition) : array {
153
		$invCond = function($item) use ($condition) {
154
			return !$condition($item);
155
		};
156
		return self::arrayFilterRecursive($array, $invCond);
157
	}
158
159
	/**
160
	 * Convert the given array $arr so that keys of the potentially multi-dimensional array
161
	 * are converted using the mapping given in $dictionary. Keys not found from $dictionary
162
	 * are not altered.
163
	 */
164
	public static function convertArrayKeys(array $arr, array $dictionary) : array {
165
		$newArr = [];
166
167
		foreach ($arr as $k => $v) {
168
			$key = $dictionary[$k] ?? $k;
169
			$newArr[$key] = \is_array($v) ? self::convertArrayKeys($v, $dictionary) : $v;
170
		}
171
172
		return $newArr;
173
	}
174
175
	/**
176
	 * Walk through the given, potentially multi-dimensional, array and cast all leaf nodes
177
	 * to integer type. The array is modified in-place. Optionally, apply the conversion only
178
	 * on the leaf nodes matching the given predicate.
179
	 */
180
	public static function intCastArrayValues(array &$arr, ?callable $predicate=null) : void {
181
		\array_walk_recursive($arr, function(&$value) use($predicate) {
182
			if ($predicate === null || $predicate($value)) {
183
				$value = (int)$value;
184
			}
185
		});
186
	}
187
188
	/**
189
	 * Given a two-dimensional array, sort the outer dimension according to values in the
190
	 * specified column of the inner dimension.
191
	 */
192
	public static function arraySortByColumn(array &$arr, string $column) : void {
193
		\usort($arr, function ($a, $b) use ($column) {
194
			return self::stringCaseCompare($a[$column], $b[$column]);
195
		});
196
	}
197
198
	/**
199
	 * Like the built-in \explode(...) function but this one can be safely called with
200
	 * null string, and no warning will be emitted. Also, this returns an empty array from
201
	 * null and '' inputs while the built-in alternative returns a 1-item array containing
202
	 * an empty string.
203
	 * @param string $delimiter
204
	 * @param string|null $string
205
	 * @return array
206
	 */
207
	public static function explode(string $delimiter, ?string $string) : array {
208
		if ($delimiter === '') {
209
			throw new \UnexpectedValueException();
210
		} elseif ($string === null || $string === '') {
211
			return [];
212
		} else {
213
			return \explode($delimiter, $string);
214
		}
215
	}
216
217
	/**
218
	 * Truncate the given string to maximum length, appending ellipsis character
219
	 * if the truncation happened. Also null argument may be safely passed and
220
	 * it remains unaltered.
221
	 */
222
	public static function truncate(?string $string, int $maxLength) : ?string {
223
		if ($string === null) {
224
			return null;
225
		} else {
226
			return \mb_strimwidth($string, 0, $maxLength, "\u{2026}");
227
		}
228
	}
229
230
	/**
231
	 * Test if given string starts with another given string
232
	 */
233
	public static function startsWith(string $string, string $potentialStart, bool $ignoreCase=false) : bool {
234
		$actualStart = \substr($string, 0, \strlen($potentialStart));
235
		if ($ignoreCase) {
236
			$actualStart = \mb_strtolower($actualStart);
237
			$potentialStart = \mb_strtolower($potentialStart);
238
		}
239
		return $actualStart === $potentialStart;
240
	}
241
242
	/**
243
	 * Test if given string ends with another given string
244
	 */
245
	public static function endsWith(string $string, string $potentialEnd, bool $ignoreCase=false) : bool {
246
		$actualEnd = \substr($string, -\strlen($potentialEnd));
247
		if ($ignoreCase) {
248
			$actualEnd = \mb_strtolower($actualEnd);
249
			$potentialEnd = \mb_strtolower($potentialEnd);
250
		}
251
		return $actualEnd === $potentialEnd;
252
	}
253
254
	/**
255
	 * Multi-byte safe case-insensitive string comparison
256
	 * @return int negative value if $a is less than $b, positive value if $a is greater than $b, and 0 if they are equal.
257
	 */
258
	public static function stringCaseCompare(?string $a, ?string $b) : int {
259
		return \strcmp(\mb_strtolower($a ?? ''), \mb_strtolower($b ?? ''));
260
	}
261
262
	/** 
263
	 * Convert snake case string (like_this) to camel case (likeThis).
264
	 */
265
	public static function snakeToCamelCase(string $input): string {
266
		return \lcfirst(\str_replace('_', '', \ucwords($input, '_')));
267
	}
268
269
	/**
270
	 * Test if $item is a string and not empty or only consisting of whitespace
271
	 */
272
	public static function isNonEmptyString(/*mixed*/ $item) : bool {
273
		return \is_string($item) && \trim($item) !== '';
274
	}
275
276
	/**
277
	 * Split given string to a prefix and a basename (=the remaining part after the prefix), considering the possible
278
	 * prefixes given as an array. If none of the prefixes match, the returned basename will be the original string
279
	 * and the prefix will be null.
280
	 * @param string[] $potentialPrefixes
281
	 */
282
	public static function splitPrefixAndBasename(?string $name, array $potentialPrefixes) : array {
283
		$parts = ['prefix' => null, 'basename' => $name];
284
285
		if ($name !== null) {
286
			foreach ($potentialPrefixes as $prefix) {
287
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
288
					$parts['prefix'] = $prefix;
289
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
290
					break;
291
				}
292
			}
293
		}
294
295
		return $parts;
296
	}
297
298
	/**
299
	 * Convert file size given in bytes to human-readable format
300
	 */
301
	public static function formatFileSize(?int $bytes, int $decimals = 1) : ?string {
302
		if ($bytes === null) {
303
			return null;
304
		} else {
305
			$units = 'BKMGTP';
306
			$factor = \floor((\strlen((string)$bytes) - 1) / 3);
307
			return \sprintf("%.{$decimals}f", $bytes / \pow(1024, $factor)) . @$units[(int)$factor];
308
		}
309
	}
310
311
	/**
312
	 * Convert time given as seconds to the HH:MM:SS format
313
	 */
314
	public static function formatTime(?int $seconds) : ?string {
315
		if ($seconds === null) {
316
			return null;
317
		} else {
318
			return \sprintf('%02d:%02d:%02d', ($seconds/3600), ($seconds/60%60), $seconds%60);
319
		}
320
	}
321
322
	/**
323
	 * Convert date and time given in the SQL format to the ISO UTC "Zulu format" e.g. "2021-08-19T19:33:15Z"
324
	 */
325
	public static function formatZuluDateTime(?string $dbDateString) : ?string {
326
		if ($dbDateString === null) {
327
			return null;
328
		} else {
329
			$dateTime = new \DateTime($dbDateString);
330
			return $dateTime->format('Y-m-d\TH:i:s.v\Z');
331
		}
332
	}
333
334
	/**
335
	 * Convert date and time given in the SQL format to the ISO UTC "offset format" e.g. "2021-08-19T19:33:15+00:00"
336
	 */
337
	public static function formatDateTimeUtcOffset(?string $dbDateString) : ?string {
338
		if ($dbDateString === null) {
339
			return null;
340
		} else {
341
			$dateTime = new \DateTime($dbDateString);
342
			return $dateTime->format('c');
343
		}
344
	}
345
346
	/**
347
	 * Get a Folder object using a parent Folder object and a relative path
348
	 */
349
	public static function getFolderFromRelativePath(Folder $parentFolder, string $relativePath) : Folder {
350
		if ($relativePath !== null && $relativePath !== '/' && $relativePath !== '') {
351
			$node = $parentFolder->get($relativePath);
352
			if ($node instanceof Folder) {
353
				return $node;
354
			} else {
355
				throw new \InvalidArgumentException('Path points to a file while folder expected');
356
			}
357
		} else {
358
			return $parentFolder;
359
		}
360
	}
361
362
	/**
363
	 * Create relative path from the given working dir (CWD) to the given target path
364
	 * @param string $cwdPath Absolute CWD path
365
	 * @param string $targetPath Absolute target path
366
	 */
367
	public static function relativePath(string $cwdPath, string $targetPath) : string {
368
		$cwdParts = \explode('/', $cwdPath);
369
		$targetParts = \explode('/', $targetPath);
370
371
		// remove the common prefix of the paths
372
		while (\count($cwdParts) > 0 && \count($targetParts) > 0 && $cwdParts[0] === $targetParts[0]) {
373
			\array_shift($cwdParts);
374
			\array_shift($targetParts);
375
		}
376
377
		// prepend up-navigation from CWD to the closest common parent folder with the target
378
		for ($i = 0, $count = \count($cwdParts); $i < $count; ++$i) {
379
			\array_unshift($targetParts, '..');
380
		}
381
382
		return \implode('/', $targetParts);
383
	}
384
385
	/**
386
	 * Given a current working directory path (CWD) and a relative path (possibly containing '..' parts),
387
	 * form an absolute path matching the relative path. This is a reverse operation for Util::relativePath().
388
	 */
389
	public static function resolveRelativePath(string $cwdPath, string $relativePath) : string {
390
		$cwdParts = \explode('/', $cwdPath);
391
		$relativeParts = \explode('/', $relativePath);
392
393
		// get rid of the trailing empty part of CWD which appears when CWD has a trailing '/'
394
		if ($cwdParts[\count($cwdParts)-1] === '') {
395
			\array_pop($cwdParts);
396
		}
397
398
		foreach ($relativeParts as $part) {
399
			if ($part === '..') {
400
				\array_pop($cwdParts);
401
			} else {
402
				\array_push($cwdParts, $part);
403
			}
404
		}
405
406
		return \implode('/', $cwdParts);
407
	}
408
409
	/**
410
	 * Encode a file path so that it can be used as part of a WebDAV URL
411
	 */
412
	public static function urlEncodePath(string $path) : string {
413
		// URL encode each part of the file path
414
		return \join('/', \array_map('rawurlencode', \explode('/', $path)));
415
	}
416
417
	/**
418
	 * Compose URL from parts as returned by the system function parse_url.
419
	 * From https://stackoverflow.com/a/35207936
420
	 */
421
	public static function buildUrl(array $parts) : string {
422
		return (isset($parts['scheme']) ? "{$parts['scheme']}:" : '') .
423
				((isset($parts['user']) || isset($parts['host'])) ? '//' : '') .
424
				(isset($parts['user']) ? "{$parts['user']}" : '') .
425
				(isset($parts['pass']) ? ":{$parts['pass']}" : '') .
426
				(isset($parts['user']) ? '@' : '') .
427
				(isset($parts['host']) ? "{$parts['host']}" : '') .
428
				(isset($parts['port']) ? ":{$parts['port']}" : '') .
429
				(isset($parts['path']) ? "{$parts['path']}" : '') .
430
				(isset($parts['query']) ? "?{$parts['query']}" : '') .
431
				(isset($parts['fragment']) ? "#{$parts['fragment']}" : '');
432
	}
433
434
	/**
435
	 * Swap values of two variables in place
436
	 * @param mixed $a
437
	 * @param mixed $b
438
	 */
439
	public static function swap(&$a, &$b) : void {
440
		$temp = $a;
441
		$a = $b;
442
		$b = $temp;
443
	}
444
445
	/**
446
	 * Limit an integer value between the specified minimum and maximum.
447
	 * A null value is a valid input and will produce a null output.
448
	 * @param int|float|null $input
449
	 * @param int|float $min
450
	 * @param int|float $max
451
	 * @return int|float|null
452
	 */
453
	public static function limit($input, $min, $max) {
454
		if ($input === null) {
455
			return null;
456
		} else {
457
			return \max($min, \min($input, $max));
458
		}
459
	}
460
}
461