File: D:/HostingSpaces/centrum8a/centrum8a.com/app/KommaApp/Documents/Kms/DocumentService.php
<?php
namespace App\KommaApp\Documents\Kms;
use App\KommaApp\Documents\Models\Document;
use App\KommaApp\Images\CropperInterface;
use App\KommaApp\Kms\Core\Attributes\Documents;
use App\KommaApp\Kms\Core\Attributes\Models\ImageProperty;
use App\KommaApp\Kms\Core\HouseKeeping\CanDoHousekeepingInterface;
use App\KommaApp\Kms\Core\Sections\AbstractAttributeKey;
use Illuminate\Http\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Model;
use PhpParser\Comment\Doc;
/**
* Class DocumentService
*
* Manages uploads
*
* @package App\KommaApp\Documents\Kms
*/
class DocumentService implements DocumentServiceInterface, CanDoHousekeepingInterface
{
public static $uploadsFolderName = DIRECTORY_SEPARATOR.'uploads'.DIRECTORY_SEPARATOR;
protected $sortable = false;
/**
* @var ImageProperty[]
*/
protected $defaultImageProperties;
/** @var string $forModelName*/
const forModelName = Document::class;
/**
* @var CropperInterface $imageCropper
*/
private $imageCropper;
/**
* DocumentService constructor.
*/
function __construct()
{
$this->defaultImageProperties = [
// (new ImageProperty())->setName('original')->setCropMethod(ImageProperty::CropFit)->setWidth(2000)->setHeight(8000), //exhausts php memory on some installations and causes errors like this: Allowed memory size of 134217728 bytes exhausted (tried to allocate 38400001 bytes or internal server errors
(new ImageProperty())->setName('thumb')->setCropMethod(ImageProperty::Fit)->setWidth(100),
(new ImageProperty())->setName('small')->setCropMethod(ImageProperty::Fit)->setWidth(240),
(new ImageProperty())->setName('medium')->setCropMethod(ImageProperty::Fit)->setWidth(460),
(new ImageProperty())->setName('large')->setCropMethod(ImageProperty::Fit)->setWidth(500),
];
$this->imageCropper = app()->make(CropperInterface::class);
}
/**
* Retrieves the uploaded documents and stores them in the filesystem and database by delegating to other methods
*
* @param Model $model
* @param Documents $attribute
*/
public function processUploadedDocumentsForModel(Model $model, Documents $attribute)
{
$documents = $this->getDocumentsUsingAttributeKey($attribute->getKey());
$documents = $this->storeUploadedDocumentsIfNeeded($documents, $attribute->getSubFolder());
$documents = $this->deleteDocumentsIfNeeded($documents, $attribute->getSubFolder());
$documents = $this->processImagesIfNeeded($documents, $attribute);
$this->saveDocumentsForModel($documents, $model);
}
/**
* Link a specific file to a model as a document.
*
* @param Model $model
* @param string $absoluteFileSourcePath
* @param string $uploadsSubFolder
* @param ImageProperty[] $imageProperties
*/
public function linkFileToModelAsDocument(Model $model, string $absoluteFileSourcePath, string $uploadsSubFolder, array $imageProperties)
{
$file = new File($absoluteFileSourcePath);
$document = new Document();
$document->file = $file;
$document->mime_type = $file->getMimeType();
$documents = collect([$document]);
$documents = $this->storeUploadedDocumentsIfNeeded($documents, $uploadsSubFolder);
$documents = $this->processImagesIfNeeded($documents, $imageProperties);
$this->saveDocumentsForModel($documents, $model);
}
/**
* Checks if a filename is linked to a model
*
* @param Model $model
* @param $name
* @return bool
*/
public function fileLinkedForModel(Model $model, $name)
{
$documents = Document::where('documentable_id', '=', $model->id)->where('documentable_type', '=', get_class($model))->where('name', '=', $name)->get();
return $documents->count() > 0;
}
/**
* Save the documents in a collection
* @param Collection $documents
* @param Model $model
*/
private function saveDocumentsForModel(Collection $documents, $model)
{
$documents->each(function(Document $document) use ($model) {
$document->documentable()->associate($model);
$document->save(); //Only saves documents which "original" attributes do not match the ones in "attributes".
});
}
/**
* Stores the Uploaded Files instances (in the file attributes from the documents) into the public folder.
* When done this function returns a collection with the same collection keys as the input collection but
* with paths set in the documents
*
* @param Collection $documents
* @param string $uploadsSubFolder
* @return Collection
*/
private function storeUploadedDocumentsIfNeeded(Collection $documents, string $uploadsSubFolder)
{
//Create the folder if it does not exist
$relativeSubFolder = self::$uploadsFolderName.$uploadsSubFolder;
$absoluteUploadsFolder = public_path($relativeSubFolder);
if(!file_exists($absoluteUploadsFolder))
{
if(!mkdir($absoluteUploadsFolder, 0777, true)) throw new \RuntimeException('Could not create the folder in which to store the uploads: '.$absoluteUploadsFolder);
}
//Validate that each of the documents are Documents
$documents->each(function($document) {
if(!is_a($document, Document::class)) throw new \RuntimeException('One of the uploaded files wasn\'t an uploaded instance of "'.self::forModelName.'" while it should');
});
//Store them in the desired location
$documents = $documents->map(function(Document $document) use ($absoluteUploadsFolder, $relativeSubFolder, &$keyPathPairs) {
if($document->file == null) return $document; //Skip this iteration as there is not file to store
$allowedFileClasses = [
File::class,
UploadedFile::class
];
if(!in_array(get_class($document->file), $allowedFileClasses)) throw new \RuntimeException('A document must either have a file instance of one these classes: '.implode(', ', $allowedFileClasses).'. But was: '.get_class($document->file));
if($document->file instanceof File)
{
$name = $document->file->getFilename();
$extension = $document->file->getExtension();
} else if ($document->file instanceof UploadedFile) {
$name = $document->file->getClientOriginalName();
$extension = $document->file->getClientOriginalExtension();
}
$documentNameFirstPart = pathinfo($name, PATHINFO_FILENAME);
$documentNameAndExtension = $documentNameFirstPart.'_'.time().'.'.$extension;
$document->file->move($absoluteUploadsFolder, $documentNameAndExtension);
$document->file = null; //After the file is moved to its permanent location, the uploaded file cannot be used anymore. That why we null it
$document->path = $relativeSubFolder.DIRECTORY_SEPARATOR.$documentNameAndExtension;
if($document->name == '') $document->name = $documentNameFirstPart;
return $document;
});
return $documents;
}
/**
* Deletes documents which have state Document::STATE_DELETED
*
* @param Collection $documents
* @param string $uploadsSubFolder
* @return mixed
*/
private function deleteDocumentsIfNeeded(Collection $documents, string $uploadsSubFolder)
{
//Validate that each of the documents are Documents
$documents->each(function($document) {
if(!is_a($document, Document::class)) throw new \RuntimeException('One of the uploaded files wasn\'t an uploaded instance of "'.Document::class.'" while it should');
});
//Get the documents to delete
$documentsToDelete = $documents->filter(function(Document $document) {
return ($document->state == Document::STATE_DELETED);
});
$this->deleteDocuments($documentsToDelete);
//and return the documents that are not to be deleted
return $documents->filter(function(Document $document) {
return ($document->state != Document::STATE_DELETED);
});
}
/**
* Deletes all documents for the model given
*
* @param DocumentableInterface|Document $model
*/
public function deleteDocumentsForModel($model)
{
if(is_a($model, DocumentableInterface::class)) {
$documents = $model->documents()->get();
}
else if (is_a($model, self::forModelName))
{
$documents = collect($model);
} else {
throw new \RuntimeException("The model '".get_class($model)."' must be either an instance of '".self::forModelName."' or on instance of '".DocumentableInterface::class."' but was '".get_class($model)."'");
}
$this->deleteDocuments($documents);
}
/**
* Deletes the specified documents and their related files
*
* @param $documents
*/
private function deleteDocuments(Collection $documents)
{
$documentIdsToDestroy = [];
$documents->each(function ($document) use (&$documentIdsToDestroy) {
/** @var $document Document */
$attributes = $document->getAttributes();
foreach($attributes as $attributeName => $attributeValue)
{
$absolutePath = public_path($document->$attributeName);
if ($absolutePath != public_path() && file_exists($absolutePath)) {
if (!unlink($absolutePath)) {
throw new \RuntimeException('Could not delete file: ' . $absolutePath);
}
}
}
$documentIdsToDestroy[] = $document->id;
});
(self::forModelName)::destroy($documentIdsToDestroy);
}
/**
* Uses the key (for example Documents-documents) to scan for uploaded documents by appending -1, -2, -3 etc to the key name.
* and then using the \Input facade to retrieve it. Then stores all the uploaded files in their corresponding Document models.
* Also gets the json version of all models in a form using $key.'-data' input field, looks at those json versions state attributes
* and either gets them from the database or makes a new document.
*
* @param string $key
* @return Collection
*/
private function getDocumentsUsingAttributeKey(string $key):Collection
{
if(!\Input::has($key.'-data')) throw new \RuntimeException('The documentService expects a input field with name "'.$key.'-data"');
$documents = [];
// $inputFieldNames = array_keys(\Input::all());
// dd($inputFieldNames);
$documentsData = json_decode(\Input::get($key.'-data'));
foreach($documentsData as $documentData)
{
/** @var Document|null $document */
$documentData = (array) $documentData; //Typecasts object to array. Because json_decode only did convert the array of documents but not the documents themselves.
if(!isset($documentData['state'])) throw new \RuntimeException('Object version of a document must have a transient state attribute, but did not have it.');
if(!isset($documentData['id'])) throw new \RuntimeException('Object version of a document must have an id attribute, but did not have it.');
if(!isset($documentData['sort_order'])) throw new \RuntimeException('Object version of a document must have an sort_order attribute, but did not have it.');
$document = null;
$file = null;
if($documentData['state'] == Document::STATE_NEW)
{
//retrieve uploaded file and store it in the document
$document = new Document();
$document->state = Document::STATE_NEW;
//Use the documents sort order and key to get the corresponding file
$fileFieldName = $key.'-'.$documentData['sort_order'];
$file = \Input::file($fileFieldName);
}
elseif($documentData['state'] == Document::STATE_MODIFIED)
{
//retrieve original document and re-fill it.
$document = Document::find($documentData['id']);
$document->state = Document::STATE_MODIFIED;
}
elseif($documentData['state'] == Document::STATE_DELETED)
{
//retrieve original document and re-fill it.
$document = Document::find($documentData['id']);
$document->state = Document::STATE_DELETED;
}
elseif($documentData['state'] == Document::STATE_PRISTINE)
{
//We don't need to do anything with pristine documents :) since nothing changed on them.
}
if(!$document) continue;
if(isset($documentData['id'])) unset($documentData['id']);
$document->fill($documentData);
if($file) {
$document->file = $file;
$document->mime_type = $file->getMimeType();
}
$documents[] = $document;
}
$documents = collect($documents);
//filter out new documents without file and path
$documents = $documents->filter(function(Document $document) {
return $document->file != null || $document->path != null;
});
return $documents;
}
/**
* Returns the images from input that needs to be deleted.
* TODO: make me work
*
* @param string $key
* @return Collection
*/
private function getFilesToDelete(string $key):Collection
{
//Collect documents
$documents = [];
$counter = 1;
while(\Input::has($key.'-to-delete'))
{
$documents[$key.'-to-delete'] = \Input::get($key.'-to-delete');
$counter++;
}
return collect($documents);
}
/**
* Resize and save an image to the server
*
* @param ImageProperty $imageProperty
* @param $file string|\Symfony\Component\HttpFoundation\File\UploadedFile
* @return string
* @throws \Exception
*/
// protected function formatImage($name, $file)
protected function formatImage(ImageProperty $imageProperty, $file)
{
//If there is no name, return empty string
if ($imageProperty->getName() == '') {
return '';
}
if (!is_string($file)) {
$wasA = (is_object($file)) ? $wasA = get_class($file) : gettype($file);
throw new \InvalidArgumentException("The image must be a string but wasn't. It was a: " . $wasA);
}
if(!file_exists($file))
{
throw new \InvalidArgumentException('The image wit path "'.$file.'" does not exist');
}
//NOTE: if you get an exception like this: UnexpectedValueException in Common.php line 196. Unable to open file (filename).
//You must set gd.jpeg_ignore_warning in php ini to 1.
if(!$this->imageCropper->open($file)) return false;
$this->imageCropper->enableProgressive(true);
// $image->useFallback(false); //don't use a fallback. to make sure we never hit it by fixing bugs that trigger the fallback
$path_parts = pathinfo($file);
$fileName = $path_parts['dirname'].DIRECTORY_SEPARATOR.$path_parts['filename'].'_' . $imageProperty->getName();
if(isset($path_parts['extension']) && $path_parts['extension'] !== '') $fileName .= '.'.$path_parts['extension'];
//switch between the methods
switch ($imageProperty->getCropMethod()) {
case ImageProperty::Fit:
//This will fit the image between the set with and height, but keep de aspect ratio, and saves it to the server.
$this->imageCropper->fit($imageProperty->getWidth(),
$imageProperty->getHeight() == 0 ? null : $imageProperty->getHeight());
$this->imageCropper->save($fileName);
// dd('fit: '.$this->options[$name]['width']." x ".$this->options[$name]['height']);
break;
case ImageProperty::Crop:
//This will crop the image between the set with and height, and saves it to the server.
$this->imageCropper->crop($imageProperty->getWidth(), $imageProperty->getHeight());
$this->imageCropper->save($fileName);
break;
case ImageProperty::Resize:
//This will crop the image between the set with and height, and saves it to the server.
$this->imageCropper->resize($imageProperty->getWidth() == 0 ? null : $imageProperty->getWidth(),
$imageProperty->getHeight() == 0 ? null : $imageProperty->getHeight());
$this->imageCropper->save($fileName);
break;
}
$this->imageCropper->destroy();
return $fileName;
}
/**
* Deletes all documents with non existing files.
*/
public static function doHouseKeeping()
{
self::pruneDocumentsWithNonExistingFiles();
}
/**
* Deletes all documents which refer to a non existing file.
*/
private static function pruneDocumentsWithNonExistingFiles()
{
$modelsToDestroy = [];
$documents = (self::forModelName)::all(['id', 'path']);
$documents->each(function($document) use (&$modelsToDestroy) {
/** @var Document $document */
$absoluteFilePath = public_path($document->path);
if(!file_exists($absoluteFilePath)) {
$modelsToDestroy[] = $document->id;
}
});
(self::forModelName)::destroy($modelsToDestroy);
}
/**
* Does have a look at the paths in the collection and resizes them if needed.
* Note: The image properties names without "image_url" suffixed to it, must correspond to the column names of the Documents model table.
* If not an exception is thrown. Note 2: You must save the documents yourself.
*
* @param Collection $documents
* @param Documents|array $attributeOrImageProperties
* @return Collection
*/
private function processImagesIfNeeded(Collection $documents, $attributeOrImageProperties)
{
//Get an array of ImageProperties (into $imagePropertiesToUse) from the specified Documents attribute, array of ImageProperties or from this class if either one not valid or present.
$imagePropertiesToUse = $this->defaultImageProperties;
if($attributeOrImageProperties instanceof Documents)
if(count($attributeOrImageProperties->getImageProperties()) > 0) $imagePropertiesToUse = $attributeOrImageProperties->getImageProperties();
else if(is_array($attributeOrImageProperties))
{
$valid = true;
foreach ($attributeOrImageProperties as $imageProperty) {
if(!$imageProperty instanceof ImageProperty)
{
$valid = false;
break;
}
}
if($valid) $imagePropertiesToUse = $attributeOrImageProperties;
}
$documents->map(function(Document $document, $collectionKey) use ($imagePropertiesToUse, $documents) {
//Check if the documents file is a image. If it isn't or it can't be determined we return the document and we don't process the image
if($document->path !== null)
{
if(!$this->pathIsImage($document->path)) return $document;
} else {
return $document;
}
foreach ($imagePropertiesToUse as $imageProperty) {
/** @var ImageProperty $imageProperty */
$attributeName = $imageProperty->getName() . '_image_url';
if (\Schema::hasColumn($document->getTable(), $attributeName)) {
$processedImagePath = $this->formatImage($imageProperty, public_path($document->path));
if ($processedImagePath) {
$document->$attributeName = str_replace(public_path(), '', $processedImagePath);
$document->$attributeName = str_replace('\\', '/', $document->$attributeName);
}
} else {
throw new \RuntimeException('The "' . $document->getTable() . '" table is expected to have the column "' . $attributeName . '" according to the corresponding image property with name "' . $imageProperty->getName() . '"');
}
}
return $document;
});
return $documents;
}
/**
* Checks if the path most likely is an image
*
* @param string $path
* @return bool
*/
private function pathIsImage(string $path):bool
{
$pathInfo = pathInfo($path);
$extension = strtolower($pathInfo['extension']);
switch ($extension)
{
case 'bmp':
case 'gif':
case 'jpg':
case 'jpeg':
case 'png':
case 'wbmp':
case 'xpm':
case 'webp':
return true;
default:
return false;
}
}
}