spatie / laravel-medialibrary

Associate files with Eloquent models
https://spatie.be/docs/laravel-medialibrary
MIT License
5.74k stars 1.07k forks source link

Add phpstan template tags to InteractsWithMedia and HasMedia #3705

Open cosmastech opened 1 month ago

cosmastech commented 1 month ago

Our project extends the package Media model. We also "extended" the InteractsWithMedia trait.

Trying to get phpstan to work with the library code would be a lot easier if there were templates added to the interface and trait.

HasMedia ```php /** * @template MediaModel of Media // not positive about this... it seems like there's enough references to Media... not sure how it works with the class-string belong tho * @mixin \Illuminate\Database\Eloquent\Model * * @method void prepareToAttachMedia(Media $media, FileAdder $fileAdder) * * @property bool $registerMediaConversionsUsingModelInstance * @property ?\Spatie\MediaLibrary\MediaCollections\MediaCollection $mediaCollections */ interface HasMedia { /** * @return MorphMany */ public function media(): MorphMany; public function addMedia(string|UploadedFile $file): FileAdder; public function copyMedia(string|UploadedFile $file): FileAdder; public function hasMedia(string $collectionName = ''): bool; /** * @param string $collectionName * @param array|callable $filters * @return Collection */ public function getMedia(string $collectionName = 'default', array|callable $filters = []): Collection; public function clearMediaCollection(string $collectionName = 'default'): HasMedia; public function clearMediaCollectionExcept(string $collectionName = 'default', array|Collection $excludedMedia = []): HasMedia; public function shouldDeletePreservingMedia(): bool; public function loadMedia(string $collectionName); public function addMediaConversion(string $name): Conversion; public function registerMediaConversions(?Media $media = null): void; public function registerMediaCollections(): void; public function registerAllMediaConversions(): void; /** * @return class-string */ public function getMediaModel(): string; } ```
InteractsWithMedia ```php namespace Spatie\MediaLibrary; use DateTimeInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Http\File; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Str; use Spatie\MediaLibrary\Conversions\Conversion; use Spatie\MediaLibrary\Downloaders\DefaultDownloader; use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent; use Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidBase64Data; use Spatie\MediaLibrary\MediaCollections\Exceptions\InvalidUrl; use Spatie\MediaLibrary\MediaCollections\Exceptions\MediaCannotBeDeleted; use Spatie\MediaLibrary\MediaCollections\Exceptions\MediaCannotBeUpdated; use Spatie\MediaLibrary\MediaCollections\Exceptions\MimeTypeNotAllowed; use Spatie\MediaLibrary\MediaCollections\FileAdder; use Spatie\MediaLibrary\MediaCollections\FileAdderFactory; use Spatie\MediaLibrary\MediaCollections\MediaCollection; use Spatie\MediaLibrary\MediaCollections\MediaRepository; use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\Support\MediaLibraryPro; use Spatie\MediaLibraryPro\PendingMediaLibraryRequestHandler; use Symfony\Component\HttpFoundation\File\UploadedFile; /** * @template MediaModel of Media * */ trait InteractsWithMedia { /** @var Conversion[] */ public array $mediaConversions = []; /** @var MediaCollection[] */ public array $mediaCollections = []; protected bool $deletePreservingMedia = false; protected array $unAttachedMediaLibraryItems = []; public static function bootInteractsWithMedia(): void { static::deleting(function (HasMedia $model) { if ($model->shouldDeletePreservingMedia()) { return; } if (in_array(SoftDeletes::class, class_uses_recursive($model))) { if (! $model->forceDeleting) { return; } } $model->media()->cursor()->each(fn (Media $media) => $media->delete()); }); } /** * @return MorphMany */ public function media(): MorphMany { return $this->morphMany($this->getMediaModel(), 'model'); } /** * Add a file to the media library. */ public function addMedia(string|UploadedFile $file): FileAdder { return app(FileAdderFactory::class)->create($this, $file); } public function addMediaFromRequest(string $key): FileAdder { return app(FileAdderFactory::class)->createFromRequest($this, $key); } /** * Add a file from the given disk. */ public function addMediaFromDisk(string $key, ?string $disk = null): FileAdder { return app(FileAdderFactory::class)->createFromDisk($this, $key, $disk ?: config('filesystems.default')); } public function addFromMediaLibraryRequest(?array $mediaLibraryRequestItems): PendingMediaLibraryRequestHandler { MediaLibraryPro::ensureInstalled(); return new PendingMediaLibraryRequestHandler( $mediaLibraryRequestItems ?? [], $this, $preserveExisting = true ); } public function syncFromMediaLibraryRequest(?array $mediaLibraryRequestItems): PendingMediaLibraryRequestHandler { MediaLibraryPro::ensureInstalled(); return new PendingMediaLibraryRequestHandler( $mediaLibraryRequestItems ?? [], $this, $preserveExisting = false ); } /** * Add multiple files from a request by keys. * * @param string[] $keys * @return \Spatie\MediaLibrary\MediaCollections\FileAdder[] */ public function addMultipleMediaFromRequest(array $keys): Collection { return app(FileAdderFactory::class)->createMultipleFromRequest($this, $keys); } /** * Add all files from a request. * * @return \Spatie\MediaLibrary\MediaCollections\FileAdder[] */ public function addAllMediaFromRequest(): Collection { return app(FileAdderFactory::class)->createAllFromRequest($this); } /** * Add a remote file to the media library. * * * * @throws \Spatie\MediaLibrary\MediaCollections\Exceptions\FileCannotBeAdded */ public function addMediaFromUrl(string $url, array|string ...$allowedMimeTypes): FileAdder { if (! Str::startsWith($url, ['http://', 'https://'])) { throw InvalidUrl::doesNotStartWithProtocol($url); } $downloader = config('media-library.media_downloader', DefaultDownloader::class); $temporaryFile = (new $downloader)->getTempFile($url); $this->guardAgainstInvalidMimeType($temporaryFile, $allowedMimeTypes); $filename = basename(parse_url($url, PHP_URL_PATH)); $filename = urldecode($filename); if ($filename === '') { $filename = 'file'; } if (! Str::contains($filename, '.')) { $mediaExtension = explode('/', mime_content_type($temporaryFile)); $filename = "{$filename}.{$mediaExtension[1]}"; } return app(FileAdderFactory::class) ->create($this, $temporaryFile) ->usingName(pathinfo($filename, PATHINFO_FILENAME)) ->usingFileName($filename); } /** * Add a file to the media library that contains the given string. * * @param string string */ public function addMediaFromString(string $text): FileAdder { $tmpFile = tempnam(sys_get_temp_dir(), 'media-library'); file_put_contents($tmpFile, $text); $file = app(FileAdderFactory::class) ->create($this, $tmpFile) ->usingFileName('text.txt'); return $file; } /** * Add a base64 encoded file to the media library. * * @throws \Spatie\MediaLibrary\MediaCollections\Exceptions\FileCannotBeAdded * @throws InvalidBase64Data */ public function addMediaFromBase64(string $base64data, array|string ...$allowedMimeTypes): FileAdder { // strip out data uri scheme information (see RFC 2397) if (str_contains($base64data, ';base64')) { [$_, $base64data] = explode(';', $base64data); [$_, $base64data] = explode(',', $base64data); } // strict mode filters for non-base64 alphabet characters $binaryData = base64_decode($base64data, true); if ($binaryData === false) { throw InvalidBase64Data::create(); } // decoding and then re-encoding should not change the data if (base64_encode($binaryData) !== $base64data) { throw InvalidBase64Data::create(); } // temporarily store the decoded data on the filesystem to be able to pass it to the fileAdder $tmpFile = tempnam(sys_get_temp_dir(), 'media-library'); file_put_contents($tmpFile, $binaryData); $this->guardAgainstInvalidMimeType($tmpFile, $allowedMimeTypes); $file = app(FileAdderFactory::class)->create($this, $tmpFile); return $file; } /** * Add a file to the media library from a stream. */ public function addMediaFromStream($stream): FileAdder { $tmpFile = tempnam(sys_get_temp_dir(), 'media-library'); file_put_contents($tmpFile, $stream); $file = app(FileAdderFactory::class) ->create($this, $tmpFile) ->usingFileName('text.txt'); return $file; } /** * Copy a file to the media library. */ public function copyMedia(string|UploadedFile $file): FileAdder { return $this->addMedia($file)->preservingOriginal(); } /* * Determine if there is media in the given collection. */ public function hasMedia(string $collectionName = 'default', array $filters = []): bool { return count($this->getMedia($collectionName, $filters)) ? true : false; } /** * Get media collection by its collectionName. * @return MediaCollection */ public function getMedia(string $collectionName = 'default', array|callable $filters = []): MediaCollections\Models\Collections\MediaCollection { return $this->getMediaRepository() ->getCollection($this, $collectionName, $filters) ->collectionName($collectionName); } public function getMediaRepository(): MediaRepository { return app(MediaRepository::class); } /** * @return class-string */ public function getMediaModel(): string { return config('media-library.media_model'); } public function getFirstMedia(string $collectionName = 'default', $filters = []): ?Media { $media = $this->getMedia($collectionName, $filters); return $media->first(); } /* * Get the url of the image for the given conversionName * for first media for the given collectionName. * If no profile is given, return the source's url. */ public function getFirstMediaUrl(string $collectionName = 'default', string $conversionName = ''): string { $media = $this->getFirstMedia($collectionName); if (! $media) { return $this->getFallbackMediaUrl($collectionName, $conversionName) ?: ''; } if ($conversionName !== '' && ! $media->hasGeneratedConversion($conversionName)) { return $media->getUrl(); } return $media->getUrl($conversionName); } /* * Get the url of the image for the given conversionName * for first media for the given collectionName. * * If no profile is given, return the source's url. */ public function getFirstTemporaryUrl( DateTimeInterface $expiration, string $collectionName = 'default', string $conversionName = '' ): string { $media = $this->getFirstMedia($collectionName); if (! $media) { return $this->getFallbackMediaUrl($collectionName, $conversionName) ?: ''; } if ($conversionName !== '' && ! $media->hasGeneratedConversion($conversionName)) { return $media->getTemporaryUrl($expiration); } return $media->getTemporaryUrl($expiration, $conversionName); } public function getRegisteredMediaCollections(): Collection { $this->registerMediaCollections(); return collect($this->mediaCollections); } public function getMediaCollection(string $collectionName = 'default'): ?MediaCollection { $this->registerMediaCollections(); return collect($this->mediaCollections) ->first(fn (MediaCollection $collection) => $collection->name === $collectionName); } public function getFallbackMediaUrl(string $collectionName = 'default', string $conversionName = ''): string { $fallbackUrls = optional($this->getMediaCollection($collectionName))->fallbackUrls; if (in_array($conversionName, ['', 'default'], true)) { return $fallbackUrls['default'] ?? ''; } return $fallbackUrls[$conversionName] ?? $fallbackUrls['default'] ?? ''; } public function getFallbackMediaPath(string $collectionName = 'default', string $conversionName = ''): string { $fallbackPaths = optional($this->getMediaCollection($collectionName))->fallbackPaths; if (in_array($conversionName, ['', 'default'], true)) { return $fallbackPaths['default'] ?? ''; } return $fallbackPaths[$conversionName] ?? $fallbackPaths['default'] ?? ''; } /* * Get the url of the image for the given conversionName * for first media for the given collectionName. * If no profile is given, return the source's url. */ public function getFirstMediaPath(string $collectionName = 'default', string $conversionName = ''): string { $media = $this->getFirstMedia($collectionName); if (! $media) { return $this->getFallbackMediaPath($collectionName, $conversionName) ?: ''; } if ($conversionName !== '' && ! $media->hasGeneratedConversion($conversionName)) { return $media->getPath(); } return $media->getPath($conversionName); } /* * Update a media collection by deleting and inserting again with new values. */ public function updateMedia(array $newMediaArray, string $collectionName = 'default'): Collection { $this->removeMediaItemsNotPresentInArray($newMediaArray, $collectionName); $mediaClass = $this->getMediaModel(); $mediaInstance = new $mediaClass; $keyName = $mediaInstance->getKeyName(); return collect($newMediaArray) ->map(function (array $newMediaItem) use ($collectionName, $mediaClass, $keyName) { static $orderColumn = 1; $currentMedia = $mediaClass::findOrFail($newMediaItem[$keyName]); if ($currentMedia->collection_name !== $collectionName) { throw MediaCannotBeUpdated::doesNotBelongToCollection($collectionName, $currentMedia); } if (array_key_exists('name', $newMediaItem)) { $currentMedia->name = $newMediaItem['name']; } if (array_key_exists('custom_properties', $newMediaItem)) { $currentMedia->custom_properties = $newMediaItem['custom_properties']; } $currentMedia->order_column = $orderColumn++; $currentMedia->save(); return $currentMedia; }); } protected function removeMediaItemsNotPresentInArray(array $newMediaArray, string $collectionName = 'default'): void { $this ->getMedia($collectionName) ->reject(fn (Media $currentMediaItem) => in_array( $currentMediaItem->getKey(), array_column($newMediaArray, $currentMediaItem->getKeyName()), )) ->each(fn (Media $media) => $media->delete()); if ($this->mediaIsPreloaded()) { unset($this->media); } } public function clearMediaCollection(string $collectionName = 'default'): HasMedia { $this ->getMedia($collectionName) ->each(fn (Media $media) => $media->delete()); event(new CollectionHasBeenClearedEvent($this, $collectionName)); if ($this->mediaIsPreloaded()) { unset($this->media); } return $this; } public function clearMediaCollectionExcept( string $collectionName = 'default', array|Collection|Media $excludedMedia = [] ): HasMedia { if ($excludedMedia instanceof Media) { $excludedMedia = collect()->push($excludedMedia); } $excludedMedia = collect($excludedMedia); if ($excludedMedia->isEmpty()) { return $this->clearMediaCollection($collectionName); } $this ->getMedia($collectionName) ->reject(fn (Media $media) => $excludedMedia->where($media->getKeyName(), $media->getKey())->count()) ->each(fn (Media $media) => $media->delete()); if ($this->mediaIsPreloaded()) { unset($this->media); } if ($this->getMedia($collectionName)->isEmpty()) { event(new CollectionHasBeenClearedEvent($this, $collectionName)); } return $this; } /** * Delete the associated media with the given id. * You may also pass a media object. * * @throws \Spatie\MediaLibrary\MediaCollections\Exceptions\MediaCannotBeDeleted */ public function deleteMedia(int|string|Media $mediaId): void { if ($mediaId instanceof Media) { $mediaId = $mediaId->getKey(); } $media = $this->media->find($mediaId); if (! $media) { throw MediaCannotBeDeleted::doesNotBelongToModel($mediaId, $this); } $media->delete(); } public function addMediaConversion(string $name): Conversion { $conversion = Conversion::create($name); $this->mediaConversions[] = $conversion; return $conversion; } public function addMediaCollection(string $name): MediaCollection { $mediaCollection = MediaCollection::create($name); $this->mediaCollections[$name] = $mediaCollection; return $mediaCollection; } public function deletePreservingMedia(): bool { $this->deletePreservingMedia = true; return $this->delete(); } public function shouldDeletePreservingMedia(): bool { return $this->deletePreservingMedia ?? false; } protected function mediaIsPreloaded(): bool { return $this->relationLoaded('media'); } public function loadMedia(string $collectionName): Collection { if (config('media-library.force_lazy_loading') && $this->exists) { $this->loadMissing('media'); } $collection = $this->exists ? $this->media : collect($this->unAttachedMediaLibraryItems)->pluck('media'); $collection = new MediaCollections\Models\Collections\MediaCollection($collection); return $collection ->filter(fn (Media $mediaItem) => $collectionName !== '*' ? $mediaItem->collection_name === $collectionName : true) ->sortBy('order_column') ->values(); } public function prepareToAttachMedia(Media $media, FileAdder $fileAdder): void { $this->unAttachedMediaLibraryItems[] = compact('media', 'fileAdder'); } public function processUnattachedMedia(callable $callable): void { foreach ($this->unAttachedMediaLibraryItems as $item) { $callable($item['media'], $item['fileAdder']); } $this->unAttachedMediaLibraryItems = []; } protected function guardAgainstInvalidMimeType(string $file, ...$allowedMimeTypes): void { $allowedMimeTypes = Arr::flatten($allowedMimeTypes); if (empty($allowedMimeTypes)) { return; } $validation = Validator::make( ['file' => new File($file)], ['file' => 'mimetypes:'.implode(',', $allowedMimeTypes)] ); if ($validation->fails()) { throw MimeTypeNotAllowed::create($file, $allowedMimeTypes); } } public function registerMediaConversions(?Media $media = null): void {} public function registerMediaCollections(): void {} public function registerAllMediaConversions(?Media $media = null): void { $this->registerMediaCollections(); collect($this->mediaCollections)->each(function (MediaCollection $mediaCollection) use ($media) { $actualMediaConversions = $this->mediaConversions; $this->mediaConversions = []; ($mediaCollection->mediaConversionRegistrations)($media); $preparedMediaConversions = collect($this->mediaConversions) ->each(fn (Conversion $conversion) => $conversion->performOnCollections($mediaCollection->name)) ->values() ->toArray(); $this->mediaConversions = [...$actualMediaConversions, ...$preparedMediaConversions]; }); $this->registerMediaConversions($media); } public function __sleep(): array { // do not serialize properties from the trait return collect(parent::__sleep()) ->reject( fn ($key) => in_array( $key, [ 'mediaConversions', 'mediaCollections', 'unAttachedMediaLibraryItems', 'deletePreservingMedia', ] ) )->toArray(); } } ```

Happy to implement these in a PR, but wasn't sure if that had either been suggested before (and was not implemented for a reason) or if there's anything I'm missing.