ldc-boutique-cnc-sable/app/Lib/Helpers/ToolboxHelper.php
Jérémy Munsch f9ded6d01c
refactor(ToolboxHelper): remove unused getValidatedEnum method
Cleaned up ToolboxHelper by removing the unused getValidatedEnum method to enhance code clarity.
2026-10-28 08:57:35 +01:00

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;
}
}