$array1 * @param array $array2 * @return array */ public static function arrayMergeRecursiveDistinct(array &$array1, array &$array2): array { $merged = $array1; foreach ($array2 as $key => &$value) { if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) { $merged[$key] = self::arrayMergeRecursiveDistinct($merged[$key], $value); } else { $merged[$key] = $value; } } return $merged; } /** * Strip scheme (https) on oembed urls * Only embed allowed are for MP4 self hosted videos right now * * https is force by ckeditor MediaElements which natively supports * only cloud hosted medias (youtube, spotify etc..) */ public static function stripCkEditorSchemesOnMediaInputs(string $input): string { return Str::of($input) ->replaceMatches('/(?<=)/m', '') ->toString(); } /** * Asserts that the field is unique. * * @param array|string|int|float|bool|null $value * * @throws \Illuminate\Validation\ValidationException If the field is not unique. */ public static function assertFieldIsUnique( string $table, string $field, string | int | float | bool | null | array $value, null | int | string $id = null, \Closure | string | null $query = null ): void { if (! $query) { Validator::make([$field => $value], [ (string) str_replace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id), ], [ (string) str_replace('.', '\.', "{$field}.unique") => trans( 'crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => is_array($value) ? json_encode($value) : $value] ), ])->validate(); return; } Validator::make([$field => $value], [ $field => Rule::unique($table)->where($query)->ignore($id), ], [ $field . '.unique' => trans( 'crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => is_array($value) ? json_encode($value) : $value] ), ])->validate(); } /** * Assert That no Back Office url is present on txt * * @throws \Illuminate\Validation\ValidationException If url is a bo url. */ public static function assertNoInternalBoUrls(string $field, string $text): string { $routes = Route::getRoutes(); return self::replaceUrlsInText($text, function (string $url) use ($routes, $field): void { $request = Request::create($url); if (! self::isInternalHost($url)) { return; } if (trim($routes->match($request)->getPrefix() ?? '', '/') === 'bo') { throw ValidationException::withMessages([ $field => trans( 'crud.libs.toolbox.assertNoInternalBoUrls', ['attribute' => $field] ), ]); } }); } /** * Assert That only external url is present on txt * * @throws \Illuminate\Validation\ValidationException If url is a bo url. */ public static function assertNoInternalUrls(string $field, string $text): string { self::assertNoInternalBoUrls($field, $text); $routes = Route::getRoutes(); return self::replaceUrlsInText($text, function (string $url) use ($routes, $field): void { $request = Request::create($url); $routes->match($request); if (! self::isInternalHost($url)) { return; } throw ValidationException::withMessages([ $field => trans( 'crud.libs.toolbox.assertNoInternalUrls', ['attribute' => $field, 'appname' => Config::string('app.name')] ), ]); }); } private static function isInternalHost(string $url): bool { return \parse_url((string) Config::string('app.url'), \PHP_URL_HOST) === \parse_url($url, \PHP_URL_HOST); } private static function replaceUrlsInText(string $text, callable $onMatchedUrl): string { $result = preg_replace_callback( // phpcs:ignore Generic.Files.LineLength.TooLong '/(?P(?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_.+~#?&\/=]*)/', function (array $matches) use ($onMatchedUrl): string { $match = collect($matches)->first() ?? ''; $url = (string) $match; try { $onMatchedUrl($url); } catch (NotFoundHttpException) { return (string) $match; } return (string) $match; }, $text ); return is_string($result) ? $result : $text; } /** * Get original polyfill with autotranslate behavior */ public static function getOriginal(Model $model, string $attribute): mixed { // @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent) if (\in_array('Spatie\Translatable\HasTranslations', \class_uses_recursive($model)) && $model->isTranslatableAttribute($attribute)) { // @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent) return $model->getTranslation( $attribute, // @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent) $model->getLocale(), // @phpstan-ignore method.notFound (\Spatie\Translatable\HasTranslations could be absent) $model->useFallbackLocale() ); } return $model->getOriginal($attribute); } /** * Dynamic model query */ public static function queryColumnWithLocales( string $defaultLocale, Builder $q, string $column, string $value ): Builder { $columnRegex = '/^[A-Za-z0-9_.]+$/'; $localeRegex = '/^[A-Za-z0-9_-]+$/'; throw_unless(preg_match($columnRegex, $column), \InvalidArgumentException::class, 'Invalid column identifier.'); throw_unless(preg_match($localeRegex, $defaultLocale), \InvalidArgumentException::class, 'Invalid locale identifier.'); $currentLocale = app()->currentLocale(); throw_unless(preg_match($localeRegex, $currentLocale), \InvalidArgumentException::class, 'Invalid locale identifier.'); /** @var array $locales */ $locales = Config::array('app.locales', []); $locales = Collection::make($locales) ->filter(fn (string $locale) => (bool) preg_match($localeRegex, $locale)) ->mapWithKeys(fn (string $locale) => [$locale => $locale]); if (! $locales->has($defaultLocale)) { // * Remove default locale. $locales->pull($defaultLocale); } if (! $locales->has($currentLocale)) { // * Remove current locale. $locales->pull($currentLocale); } return $q->where(function (Builder $q) use ($defaultLocale, $currentLocale, $locales, $column, $value): void { // * Query default locale $q->where("{$column}->{$defaultLocale}", $value); if ($defaultLocale !== $currentLocale) { // * Query current locale $q->orWhere("{$column}->{$currentLocale}", $value); } // * Query any other locale $locales->each(fn (string $locale) => $q->orWhere( "{$column}->{$locale}", $value )); }); } /** * Generates an array from a dot separated string * * @param string $path The dot separated string to convert to an array. * @param mixed $value The value to set at the end of the array. * @param string $separator The separator used in the path (default is '.'). * @return array The generated array. */ public static function dotStringToNestedArray(string $path, mixed $value, string $separator = '.'): array { throw_if($separator === '', \InvalidArgumentException::class, 'Separator cannot be an empty string.'); $arr = $value; $keys = explode($separator, $path); $keys = array_reverse($keys); foreach ($keys as $key) { $arr = [$key => $arr]; } return $arr; } /** * Validate picture * * @param callable(string):void $fail A callback that should be called if the validation fails. * @param object{ * minWidth: int, * maxWidth: int, * minHeight: int, * maxHeight: int * }&\stdClass $sizes An object containing the minimum and maximum width and height for the picture. * * @throws \Illuminate\Validation\ValidationException If the picture is invalid. */ public static function validatePicture(string $attribute, ?string $value, callable $fail, object $sizes): void { if ($value === null) { return; } if (! ($useStorage = Storage::exists($value)) && ! File::exists($value)) { $fail(trans('validation.custom.has_picture.file_does_not_exist', [ 'attribute' => $attribute, ])); return; } $fileFullPath = self::getUploadedFileFullPath($value, $useStorage); if ( ! \in_array(self::getUploadedFileMimeType($value, $useStorage), [ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', ]) ) { $fail(trans('validation.mimetypes', [ 'attribute' => $attribute, 'values' => 'image/jpeg, image/jpg, image/png, image/gif', ])); } if (! ($dimensions = \getimagesize($fileFullPath))) { $fail(trans('validation.dimensions', [ 'attribute' => $attribute, ])); return; } $width = intval($dimensions[0]); $height = intval($dimensions[1]); $xMessage = trans('validation.custom.has_picture.width_between', [ 'attribute' => $attribute, 'min' => "{$sizes->minWidth}px", 'max' => "{$sizes->maxWidth}px", ]); if ($sizes->minWidth === $sizes->maxWidth) { $xMessage = trans('validation.custom.has_picture.width_equal', [ 'attribute' => $attribute, 'value' => "{$sizes->minWidth}px", ]); } $yMessage = trans('validation.custom.has_picture.height_between', [ 'attribute' => $attribute, 'min' => "{$sizes->minHeight}px", 'max' => "{$sizes->maxHeight}px", ]); if ($sizes->minHeight === $sizes->maxHeight) { $yMessage = trans('validation.custom.has_picture.height_equal', [ 'attribute' => $attribute, 'value' => "{$sizes->minHeight}px", ]); } if (($width < $sizes->minWidth) || ($width > $sizes->maxWidth)) { $fail($xMessage); } if (($height < $sizes->minHeight) || ($height > $sizes->maxHeight)) { $fail($yMessage); } } /** * Fetch class list from a specific namespace * * @return array An array of classe namespaces */ public static function getClassesUsingNamespace(string $namespace): array { return collect(File::files(\app_path((string) Str::of($namespace)->replaceFirst('\App', '')))) ->filter(fn (SplFileInfo $splFileInfo) => $splFileInfo->getExtension() === 'php') ->map(fn (SplFileInfo $splFileInfo) => (string) Str::of($splFileInfo->getBasename()) ->replace('.' . $splFileInfo->getExtension(), '')->prepend("{$namespace}\\"))->all(); } /** * Get all classes from a specific namespace that implement a specific interface * * @return array An array of classe namespaces */ public static function getClassesUsingInterface(string $namespace, string $interface): array { return Arr::where( static::getClassesUsingNamespace($namespace), fn (string $class_namespace) => \collect(class_implements($class_namespace, true) ?: [])->filter() ->contains(fn (string $class_interface) => $class_interface === $interface) ); } /** * Convert Hex color to RGB * * @return int[] */ public static function hexToRgb(string $hexcolor): array { $hexcolor = (string) str($hexcolor)->replaceStart('#', ''); $r = (int) hexdec(substr($hexcolor, 0, 2)); $g = (int) hexdec(substr($hexcolor, 2, 2)); $b = (int) hexdec(substr($hexcolor, 4, 2)); return [$r, $g, $b]; } /** * Get text color contrast for a hex background color * * @return string "black" or "white" */ public static function getContrastYiq(string $hexcolor): string { [$r, $g, $b] = self::hexToRgb($hexcolor); $yiq = (($r * 299) + ($g * 587) + ($b * 114)) / 1000; return ($yiq >= 128) ? 'black' : 'white'; } /** * Parse a phone number and return a PhoneNumber instance * * @param string $phoneNumber The phone number to parse * @param string $countryCode The country code (default is 'FR' for France) * @return \Propaganistas\LaravelPhone\PhoneNumber|null Returns the formatted phone number or null if invalid */ public static function parsePhoneNumber(string $phoneNumber, string $countryCode = 'FR'): ?PhoneNumber { $phoneNumber = new PhoneNumber($phoneNumber, $countryCode); if (! $phoneNumber->isValid()) { return null; } return $phoneNumber; } /** * Convert an HTML string to plain text with proper word spacing. * * Replaces HTML tags with spaces, strips remaining tags, * normalises whitespace and trims the result. * * @param string|null $html The HTML content to convert. * @param int|null $limit Optional character limit (uses Str::limit). */ public static function htmlToText(?string $html, ?int $limit = null): string { if ($html === null || $html === '') { return ''; } $text = strip_tags((string) preg_replace('/<[^>]*>/', ' ', $html)); $text = trim((string) preg_replace('/\s+/', ' ', $text)); if ($limit !== null) { return Str::limit($text, $limit); } return $text; } }