ldc-groupe/app/Traits/Models/HasUwilImages.php
2026-02-13 12:10:35 +01:00

377 lines
14 KiB
PHP

<?php
namespace App\Traits\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Uwil\LaravelCommon\Helpers\FileHelper;
use Uwil\LaravelCommon\Helpers\ToolboxHelper;
trait HasUwilImages
{
/**
* LES CAS TRAITÉ ET NON TRAITÉ
* traité :
* - champs basique racine - image et wisiwig
* - champs basique translatable - image et wisigiw
* - prise en compte du trait softDetele
*
* Non testé :
* - la suppression totale des images dans un champs translatable
* (ça fonctionne mais il y a des changements à mettre dans FileHelper)
* - le cas ou le champs (ancien et current) ne sont que des tableaux (non translatable)
* - la récursivité dans traverseAndManage,
* je ne suis pour l'instant passé qu'une fois dans cette méthode, via un champs translatable
*
* TODO :
* - ajouter la possibilité de viser un nest array dans les tableau de fields
* - job pour nettoyer les models
*/
protected const UPDATE_ACTION = 'update';
protected const FORCE_DELETE_ACTION = 'forceDelete';
protected const WYSIWYG = "wysiwyg";
protected const IMAGE = "image";
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function bootHasUwilImages(): void
{
/** @phpstan-ignore-next-line */
$hadSoftDelete = method_exists(static::class, 'initializeSoftDeletes');
if ($hadSoftDelete) {
/** @phpstan-ignore-next-line */
static::forceDeleted(function (self $model) {
self::initLoop($model, self::FORCE_DELETE_ACTION);
});
} else {
static::deleted(function (self $model) {
self::initLoop($model, self::FORCE_DELETE_ACTION);
});
}
static::updated(function (self $model) {
self::initLoop($model, self::UPDATE_ACTION);
});
}
/**
* Get the field that are images, from the array in the model
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return array
*/
protected static function getImageFields(Model $model): array
{
if (property_exists($model, 'imageFields')) {
return $model->imageFields ?? [];
}
return [];
}
/**
* Get the field that are wysiwyg, from the array in the model
*
* @param \Illuminate\Database\Eloquent\Model $model
* @return array
*/
protected static function getWysiwygFields(Model $model): array
{
if (property_exists($model, 'wysiwygFields')) {
return $model->wysiwygFields ?? [];
}
return [];
}
/**
* Loop over the image or wysiwyg fields
*
* @param array $fields
* @param \Illuminate\Database\Eloquent\Model $model
* @param boolean $isTranslatable
* @param mixed $locale
* @param array $availableLocales
* @param boolean $forceDeleteImage
* @param string $fieldType
* @return void
*/
protected static function loopOverFields(
array $fields,
Model $model,
bool $isTranslatable,
mixed $locale,
array $availableLocales,
bool $forceDeleteImage,
string $fieldType = self::IMAGE
): void {
foreach ($fields as $field) {
$currentValue = data_get($model->getAttributes(), $field);
$originalValue = data_get($model->getOriginal(), $field);
if (is_null($currentValue) && is_null($originalValue)) {
continue;
}
if (is_array($originalValue) && $isTranslatable) {
$localesToProcess = ($forceDeleteImage) ? $availableLocales : [$locale];
foreach ($localesToProcess as $lang) {
$oldLocaleVal = $originalValue[$lang] ?? [];
$newLocaleVal = json_decode(data_get($model->getAttributes(), $field), true)[$lang] ?? [];
static::traverseAndManage(
$model,
(array) $oldLocaleVal,
(array) $newLocaleVal,
"$field",
$forceDeleteImage,
$fieldType,
$lang
);
}
continue;
} //end if
// TODO pas testé
if (is_array($originalValue) && is_array($currentValue)) {
static::traverseAndManage(
$model,
$originalValue,
$currentValue,
$field,
$forceDeleteImage,
$fieldType
);
continue;
}
static::manageField($model, $originalValue, $currentValue, $field, $forceDeleteImage, $fieldType);
} //end foreach
}
/**
* Initializes the main loop to process image and WYSIWYG fields for a given model.
*
* Determines field types, handles localization, and delegates processing to `loopOverFields()`
* for each supported field type. Used during update or forced delete events.
*
* @param \Illuminate\Database\Eloquent\Model $model The Eloquent model to process.
* @param string $eventType The event type
* (e.g. self::UPDATE_ACTION or self::FORCE_DELETE_ACTION).
* @return void
*/
protected static function initLoop(Model $model, string $eventType = self::UPDATE_ACTION): void
{
$forceDeleteImage = $eventType === self::FORCE_DELETE_ACTION;
$imagesFields = self::getImageFields($model);
$wysiwygFields = self::getWysiwygFields($model);
$isTranslatable = method_exists($model, 'getTranslations');
$locale = config('app.locale');
$availableLocales = array_keys(config('laravellocalization.supportedLocales', ['fr' => "Frenchouz"]));
self::loopOverFields(
$imagesFields,
$model,
$isTranslatable,
$locale,
$availableLocales,
$forceDeleteImage,
self::IMAGE,
);
self::loopOverFields(
$wysiwygFields,
$model,
$isTranslatable,
$locale,
$availableLocales,
$forceDeleteImage,
self::WYSIWYG,
);
}
/**
* Recursively traverses and manages changes between two nested arrays of model fields.
*
* Compares old and new values, determines if fields are lists, and delegates handling
* of each field to `manageField()`. Supports recursive traversal for nested arrays.
*
* @param \Illuminate\Database\Eloquent\Model $model The Eloquent model being processed.
* @param array $old The previous state of the data.
* @param array $new The new state of the data.
* @param string $fieldPath The current dot-notated path of the field.
* @param boolean $forceDeleteImage Whether to force deletion of old images.
* @param string $fieldType The type of field being managed
* (default: self::IMAGE).
* @param string|null $locale The locale associated with the field, if any.
*
* @return void
*/
protected static function traverseAndManage(
Model $model,
array $old,
array $new,
string $fieldPath,
bool $forceDeleteImage,
string $fieldType = self::IMAGE,
?string $locale = null
): void {
$allKeys = self::getFieldKeys($old, $new);
// This boolean is used to know if we need to keep the index (key) of the array,
// or not e.g do we want to keep content.fr.0 or just content.fr.
$isList = self::isMeaningfulList($old) || self::isMeaningfulList($new);
foreach ($allKeys as $key) {
$oldValue = $old[$key] ?? null;
$newValue = $new[$key] ?? null;
$path = $isList ? "$fieldPath.$key" : (is_int($key) ? $fieldPath : "$fieldPath.$key");
// TODO cette récursivité n'a pas encore été testé.
if (is_array($oldValue) || is_array($newValue)) {
static::traverseAndManage(
$model,
(array) $oldValue,
(array) $newValue,
$path,
$forceDeleteImage,
$fieldType,
$locale,
);
}
static::manageField($model, $oldValue, $newValue, $path, $forceDeleteImage, $fieldType);
}
}
/**
* Determine if the given array should be considered a meaningful list.
*
* A "meaningful list" is a numerically indexed array (0,1,2,...)
* that contains at least one nested array — for example, a list of
* objects, image blocks, or structured elements.
*
* This helps distinguish between true lists of complex data
* and simple value arrays like ["foo", "bar"].
*
* @param array $array
* @return boolean
*/
private static function isMeaningfulList(array $array): bool
{
return array_is_list($array) && collect($array)->contains(fn($v) => is_array($v));
}
/**
* Get the unique set of keys present in both the old and new arrays.
*
* Merges the keys from the old and new data, removes duplicates,
* and returns them as a clean, ordered list.
*
* Useful for comparing field values or recursively traversing
* nested array structures.
*
* @param array $old
* @param array $new
* @return \Illuminate\Support\Collection
*/
protected static function getFieldKeys(array $old, array $new): \Illuminate\Support\Collection
{
return collect(array_keys($old))
->merge(array_keys($new))
->unique();
}
/**
* Get the image field and manage the store image.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param mixed $oldValue
* @param mixed $newValue
* @param mixed $fieldName The fieldName, can be nested e.g content.image.
* @param boolean $forceDeleteImage Does the image be force deleted,
* even if old and new value are the same (on delete model most of case).
* @param string $fieldType Choice between WYSIWYG and IMAGE.
* @return void
*/
protected static function manageField(
Model $model,
mixed $oldValue,
mixed $newValue,
mixed $fieldName,
bool $forceDeleteImage,
string $fieldType = self::IMAGE
) {
if ($fieldType === self::IMAGE) {
// Cas 1 : Valeurs identiques -> only for force deleting model.
if ((is_string($oldValue) === is_string($newValue)) && $forceDeleteImage) {
// In this case we need to remove the actual file, not the old as we do in other conditions.
$path = ToolboxHelper::mbReplace('storage/', '', $newValue);
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
// // Cas 2 : ancienne = string, nouvelle = null → suppression.
if (is_string($oldValue) && is_null($newValue)) {
// Action suppression.
FileHelper::removeOldFile($model, $fieldName);
}
// // Cas 3 : ancienne = null, nouvelle = string → ajout.
// // Cas 4 : ancienne = string, nouvelle = autre string → remplacement.
if (is_string($oldValue) && is_string($newValue) && $oldValue !== $newValue) {
FileHelper::removeOldFile($model, $fieldName);
}
} //end if
if ($fieldType === self::WYSIWYG) {
$valueToPass = $forceDeleteImage ? '' : ($newValue ?? '');
if ($oldValue) {
FileHelper::removeOldModelFilesFromHtml($valueToPass, $oldValue);
}
}
}
/**
* Fills the model with attributes, handling uploaded files and nested arrays.
*
* Recursively processes attribute values to store uploaded files via `FileHelper::storeFile()`,
* and applies the transformed data to the model using `fill()`.
*
* @param array $attributes The attributes to fill the model with.
* @return void
*/
public function inertiaFill(array $attributes): void
{
if (!empty($attributes)) {
$attributes = self::processContentFiles($attributes);
}
parent::fill($attributes);
}
/**
* Store recursively all uploaded files in array.
*
* @param array $content
* @return array
*/
private function processContentFiles(array $content): array
{
return collect($content)
->map(function ($value) {
// Check the pattern of the key and the type of value.
if ($value instanceof UploadedFile) {
return FileHelper::storeFile($this, $value);
}
// Check if it's an array.
if (is_array($value)) {
return $this->processContentFiles($value);
}
return $value;
})
->all();
}
}