Passed
Push — master ( 120af9...cc4d5a )
by Pauli
03:07
created

Util::buildUrl()   F

Complexity

Conditions 12
Paths 2048

Size

Total Lines 11
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 10
c 0
b 0
f 0
nc 2048
nop 1
dl 0
loc 11
rs 2.8

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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