Cleaned up ToolboxHelper by removing the unused getValidatedEnum method to enhance code clarity.
447 lines
16 KiB
PHP
447 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Lib\Helpers;
|
|
|
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Propaganistas\LaravelPhone\PhoneNumber;
|
|
use Symfony\Component\Finder\SplFileInfo;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ToolboxHelper extends ToolboxHelperCore
|
|
{
|
|
/**
|
|
* @param array<int|string, mixed> $array1
|
|
* @param array<int|string, mixed> $array2
|
|
* @return array<int|string, mixed>
|
|
*/
|
|
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('/(?<=<oembed url=")(http:\/\/)(?=.*?">)/m', '')
|
|
->toString();
|
|
}
|
|
|
|
/**
|
|
* Asserts that the field is unique.
|
|
*
|
|
* @param array<mixed>|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<url>(?: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<string,string> $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<string,mixed> 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<string> 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<string,string> 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;
|
|
}
|
|
}
|