1
0
cms11/app/Classes/AttachmentsManager.php

424 lines
12 KiB
PHP

<?php
namespace App\Classes;
use App\Classes\Traits\ManagesMetadata;
use App\Classes\Traits\Repairs\RepairsImages;
use App\Classes\Traits\Repairs\RepairsSounds;
use App\Classes\Traits\Repairs\RepairsVideos;
use App\Exceptions\AttachmentNotFound;
use App\View\Components\Image;
use App\View\Components\Sound;
use App\View\Components\Video;
use Exception;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class AttachmentsManager
{
use ManagesMetadata, RepairsImages, RepairsSounds, RepairsVideos;
/**
* Manages images
*/
const Images = 'images';
/**
* Manages sounds
*/
const Sounds = 'sounds';
/**
* Manages videos
*/
const Videos = 'videos';
private string $attachmentsDir = 'attachments';
private string $targetForFiles;
private string $metadataFilePath;
private MetadataManager $manager;
private FilesystemAdapter $disk;
private bool $isLoaded = false;
public function __construct(public string $kind, protected Bundle $bundle)
{
$this->disk = $bundle->getDisk();
$root = $bundle->getDataDir();
$this->metadataFilePath = sprintf('%s%s.json', $root, $kind);
$this->targetForFiles = sprintf('%s%s/%s', $root, $this->attachmentsDir, $kind);
$this->manager = new MetadataManager($this->metadataFilePath, $bundle);
}
/**
* Add a file to a named history and return attachment's reference
*/
public function addToHistory(string $name, array $data): string
{
$reference = $this->add($data);
$this->manager->merge([
'history' => [
$name => [
'reference' => $reference,
'date' => now(),
],
],
]);
return $reference;
}
/**
* Provides direct access to underlying manager for better control
*/
public function manager(): MetadataManager
{
return $this->manager;
}
/**
* Add a single file to the bundle
*/
public function add(array $data): string
{
$reference = $this->generateNewReference();
if (!empty($data['contents'])) {
// Adding from an image resource of which $data['contents'] is a
// representation
$contents = $data['contents'];
$checksum = md5($contents);
$existingRef = $this->findAttachmentByChecksum($checksum);
if (!empty($existingRef)) {
return $existingRef;
}
$data['checksum'] = $checksum;
unset($data['contents']);
} elseif (!empty($data['original_url'])) {
$existingRef = $this->findAttachmentByOriginalUrl($data['original_url']);
if (!empty($existingRef)) {
return $existingRef;
}
// Adding from a URL which implies downloading the resource
$contents = Http::throw()->get($data['original_url'])->body();
$checksum = md5($contents);
$existingRef = $this->findAttachmentByChecksum($checksum);
if (!empty($existingRef)) {
return $existingRef;
}
$data['checksum'] = $checksum;
$data['filename'] = basename($data['original_url']);
}
$filename = $this->getFormatedFilename($data['filename']);
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$fullPath = sprintf('%s/%s/original.%s', $this->targetForFiles, $reference, $extension);
$relativePath = sprintf('%s/%s/%s/original.%s', $this->attachmentsDir, $this->kind, $reference, $extension);
$data['filename'] = $relativePath;
$this->disk->put($fullPath, $contents);
$this->manager->set(sprintf('files.%s', $reference), $data);
return $reference;
}
/**
* Return a boolean value indicating if the file actually exists on disk
*/
public function exists()
{
return $this->disk->exists($this->metadataFilePath);
}
/**
* Load data from disk
*/
public function load(bool $reload = false)
{
if (!$this->isLoaded || $reload) {
$this->manager->load();
$this->isLoaded = true;
}
}
/**
* Store file on disk
*/
public function save(): bool
{
return $this->manager->save();
}
/**
* Replace all attachments declared in specified markdown with appropriate
* Blade components
*/
public function replaceAttachmentsInMarkdown(string $markdown)
{
$pattern = '/<x-attachment\s+ref="([^"]+)"\s*\/>/';
preg_match_all($pattern, $markdown, $matches);
foreach ($matches[1] as $index => $ref) {
try {
$component = $this->getComponentByRef($ref, 'article');
} catch (AttachmentNotFound $ex) {
continue;
}
$replacement = Str::squish((string) $component->render());
$markdown = Str::replace($matches[0][$index], $replacement, $markdown);
}
return $markdown;
}
/**
* Return a Blade component for the attachment we passed the ref off
*/
public function getComponentByRef(string $ref, ?string $filter, ?array $options = [])
{
$attachment = $this->getAttachmentData($ref);
$variant = null;
if (!empty($filter)) {
$variant = $this->getVariantData($ref, $filter) ?? null;
}
// We set the fullname to the full path of the file in the filesystem
// so the Blade component does not have to worry about it
$attachment['filename'] = $this->getAttachmentFullPath($ref);
if (!empty($filter) && !empty($variant)) {
$variant['filename'] = $this->getVariantFullPath($ref, $filter);
}
$component = $this->getBladeComponent($attachment, $variant, $options);
return $component;
}
/**
* Remove an attachment from disk and any reference in this file
*/
public function deleteAttachment(string $ref)
{
$path = sprintf('%s%s/%s/%s', $this->bundle->getDataDir(), $this->attachmentsDir, $this->kind, $ref);
$this->disk->deleteDirectory($path);
$this->manager->remove([
sprintf('files.%s', $ref),
sprintf('variants.%s', $ref),
sprintf('history.%s', $ref),
]);
}
/**
* Remove an attachment from disk and any reference in this file
*/
public function deleteVariant(string $originalRef, string $filter)
{
$path = $this->getVariantFullPath($originalRef, $filter);
$parent = dirname($path);
$this->disk->delete($path);
if (empty($this->disk->listContents($parent, true))) {
$this->disk->delete($parent);
}
$this->manager->remove([
sprintf('variants.%s.%s', $originalRef, $filter),
]);
}
/**
* Return full path of specified attachment
*/
public function getAttachmentFullPath(string $ref)
{
$data = $this->getAttachmentData($ref);
return sprintf('%s%s', $this->bundle->getDataDir(), $data['filename']);
}
/**
* Return attachment's relative path to bundle's data directory
*/
public function getAttachmentRelativePath(string $ref)
{
$fullPath = $this->getAttachmentFullPath($ref);
return Str::remove($this->bundle->getDataDir(), $fullPath);
}
/**
* Return full path of specified attachment's variant
*/
public function getVariantFullPath(string $originalRef, string $filter)
{
$originalPath = $this->getAttachmentFullPath($originalRef);
$parentDir = dirname($originalPath);
$extension = pathinfo($originalPath, PATHINFO_EXTENSION);
$variantExtension = $extension === 'gif' ? 'gif' : 'webp';
return sprintf('%s/%s.%s', $parentDir, $filter, $variantExtension);
}
/**
* Return attachment variant's relative path to bundle's data directory
*/
public function getVariantRelativePath(string $ref, string $filter)
{
$fullPath = $this->getVariantFullPath($ref, $filter);
return Str::remove($this->bundle->getDataDir(), $fullPath);
}
/**
* Return specified attachment data
*/
public function getAttachmentData(string $ref): array
{
$data = $this->manager->get(sprintf('files.%s', $ref));
if (empty($data)) {
throw new AttachmentNotFound(sprintf('Unknown attachment %s', $ref));
}
return $data;
}
/**
* Return specified variant data
*/
public function getVariantData(string $originalRef, string $filter): array
{
return $this->manager->get(sprintf('variants.%s.%s', $originalRef, $filter), []);
}
/**
* Repair attachments
*/
public function repair()
{
switch ($this->kind) {
case self::Images:
$this->repairImages();
break;
case self::Sounds:
$this->repairSounds();
break;
case self::Videos:
$this->repairVideos();
break;
}
}
/**
* Collect and return unused attachments refs
*/
public function findUnusedAttachments(string $markdown, ?string $cover = null)
{
$unused = [];
foreach (array_keys($this->manager->get('files', [])) as $ref) {
if (!empty($cover) && $cover === $ref) {
continue;
}
if (!Str::contains($markdown, $ref)) {
$unused[] = $ref;
}
}
return $unused;
}
/**
* Find an attachment from its original URL, if specified
*/
private function findAttachmentByOriginalUrl(string $url)
{
foreach ($this->manager->get('files', []) as $ref => $data) {
if (array_key_exists('original_url', $data) && $data['original_url'] === $url) {
return $ref;
}
}
return null;
}
/**
* Find an attachment from file's checksum, if specified
*/
private function findAttachmentByChecksum(string $checksum)
{
foreach ($this->manager->get('files', []) as $ref => $data) {
if (array_key_exists('checksum', $data) && $data['checksum'] === $checksum) {
return $ref;
}
}
return null;
}
/**
* Generate new, random reference for a file
*/
private function generateNewReference(): string
{
return Str::random(6);
}
/**
* Return a suitable filename
*/
private function getFormatedFilename(string $path): string
{
$filename = pathinfo($path, PATHINFO_FILENAME);
$extension = pathinfo($path, PATHINFO_EXTENSION);
$slug = Str::slug($filename);
return sprintf('%s.%s', $slug, $extension);
}
/**
* Return a Blade component corresponding to the kind of data we are
* managing, and we pass it attachment data
*/
private function getBladeComponent(array $data, ?array $variant, ?array $options = [])
{
switch ($this->kind) {
case self::Images:
return new Image($this->bundle, $data, $variant, $options);
case self::Sounds:
return new Sound($this->bundle, $data);
case self::Videos:
return new Video($this->bundle, $data);
default:
throw new Exception(sprintf('Unknown Blade Component for attachment kind "%s"', $this->kind));
}
}
}