183 lines
5.4 KiB
PHP
183 lines
5.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Media;
|
|
|
|
use App\Models\Article;
|
|
use App\Models\Attachment;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Resolve / assign article covers from content, attachments, or template generation.
|
|
*/
|
|
class ArticleCoverService
|
|
{
|
|
public const SOURCE_NONE = 'none';
|
|
|
|
public const SOURCE_MANUAL = 'manual';
|
|
|
|
public const SOURCE_ATTACHMENT = 'attachment';
|
|
|
|
public const SOURCE_CONTENT_IMAGE = 'content_image';
|
|
|
|
public const SOURCE_GENERATED = 'generated';
|
|
|
|
public const STATUS_NONE = 'none';
|
|
|
|
public const STATUS_PENDING = 'pending';
|
|
|
|
public const STATUS_READY = 'ready';
|
|
|
|
public const STATUS_FAILED = 'failed';
|
|
|
|
public function __construct(
|
|
protected ArticleCoverGenerator $generator,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{disk: ?string, path: string, source: string}|null
|
|
*/
|
|
public function resolve(Article $article, string $strategy = 'auto'): ?array
|
|
{
|
|
return match ($strategy) {
|
|
'attachment' => $this->fromAttachments($article),
|
|
'from_content', 'content_image' => $this->fromContent($article),
|
|
'generate' => $this->generator->generate($article),
|
|
default => $this->fromContent($article) ?? $this->fromAttachments($article),
|
|
};
|
|
}
|
|
|
|
public function apply(Article $article, string $strategy = 'auto'): Article
|
|
{
|
|
$article->forceFill([
|
|
'cover_status' => self::STATUS_PENDING,
|
|
])->save();
|
|
|
|
try {
|
|
$resolved = $this->resolve($article, $strategy);
|
|
} catch (\Throwable $exception) {
|
|
$article->forceFill([
|
|
'cover_status' => self::STATUS_FAILED,
|
|
'cover_source' => $strategy === 'generate' ? self::SOURCE_GENERATED : self::SOURCE_NONE,
|
|
'cover_generated_at' => now(),
|
|
])->save();
|
|
|
|
throw $exception;
|
|
}
|
|
|
|
if ($resolved === null) {
|
|
$article->forceFill([
|
|
'cover_disk' => null,
|
|
'cover_path' => null,
|
|
'cover_source' => self::SOURCE_NONE,
|
|
'cover_status' => self::STATUS_FAILED,
|
|
'cover_generated_at' => now(),
|
|
])->save();
|
|
|
|
return $article->refresh();
|
|
}
|
|
|
|
$article->forceFill([
|
|
'cover_disk' => $resolved['disk'],
|
|
'cover_path' => $resolved['path'],
|
|
'cover_source' => $resolved['source'],
|
|
'cover_status' => self::STATUS_READY,
|
|
'cover_generated_at' => now(),
|
|
])->save();
|
|
|
|
return $article->refresh();
|
|
}
|
|
|
|
public function publicUrl(Article $article): ?string
|
|
{
|
|
if (! $article->hasCover()) {
|
|
return null;
|
|
}
|
|
|
|
$path = (string) $article->cover_path;
|
|
|
|
if ($article->cover_disk === 'url' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
|
|
return $path;
|
|
}
|
|
|
|
$disk = (string) ($article->cover_disk ?: config('larablog.attachments_disk', 'attachments'));
|
|
|
|
try {
|
|
return Storage::disk($disk)->url($path);
|
|
} catch (\Throwable) {
|
|
return url('/'.$path);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{disk: ?string, path: string, source: string}|null
|
|
*/
|
|
protected function fromContent(Article $article): ?array
|
|
{
|
|
$content = (string) $article->content;
|
|
|
|
if (preg_match('/!\[[^\]]*\]\(attach:(\d+)\)/i', $content, $matches) === 1
|
|
|| preg_match('/\[attach=(\d+)\]/i', $content, $matches) === 1) {
|
|
$attachment = Attachment::query()->find((int) $matches[1]);
|
|
if ($attachment !== null && $this->isImage($attachment)) {
|
|
return [
|
|
'disk' => $attachment->disk,
|
|
'path' => $attachment->path,
|
|
'source' => self::SOURCE_CONTENT_IMAGE,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (preg_match('/!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/i', $content, $matches) === 1) {
|
|
return [
|
|
'disk' => 'url',
|
|
'path' => $matches[1],
|
|
'source' => self::SOURCE_CONTENT_IMAGE,
|
|
];
|
|
}
|
|
|
|
if (preg_match('/<img[^>]+src=["\'](https?:\/\/[^"\']+)["\']/i', $content, $matches) === 1) {
|
|
return [
|
|
'disk' => 'url',
|
|
'path' => $matches[1],
|
|
'source' => self::SOURCE_CONTENT_IMAGE,
|
|
];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array{disk: ?string, path: string, source: string}|null
|
|
*/
|
|
protected function fromAttachments(Article $article): ?array
|
|
{
|
|
$attachment = $article->attachments()
|
|
->orderBy('id')
|
|
->get()
|
|
->first(fn (Attachment $item): bool => $this->isImage($item));
|
|
|
|
if ($attachment === null) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'disk' => $attachment->disk,
|
|
'path' => $attachment->path,
|
|
'source' => self::SOURCE_ATTACHMENT,
|
|
];
|
|
}
|
|
|
|
protected function isImage(Attachment $attachment): bool
|
|
{
|
|
if (is_string($attachment->mime) && str_starts_with($attachment->mime, 'image/')) {
|
|
return true;
|
|
}
|
|
|
|
$ext = strtolower(pathinfo((string) $attachment->filename, PATHINFO_EXTENSION));
|
|
|
|
return in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'], true);
|
|
}
|
|
}
|