Files
gouki 3cec4c5e18
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped
wip: article AI polish, category SEO fields, cover generator, membership plan seeder
2026-09-07 18:48:37 +00:00

138 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Domain\Blog\ContentFormat;
use App\Domain\Blog\ContentRenderer;
use App\Domain\Media\ArticleCoverService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Article extends Model
{
protected $fillable = [
'category_id',
'user_id',
'title',
'content',
'content_format',
'description',
'keywords',
'published_at',
'views',
'comments_count',
'slug',
'stick',
'visible',
'close_comment',
'read_password',
'ai_summary',
'ai_suggestions',
'ai_polished_content',
'legacy_attachments',
'cover_disk',
'cover_path',
'cover_source',
'cover_status',
'cover_generated_at',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
'views' => 'integer',
'comments_count' => 'integer',
'stick' => 'boolean',
'visible' => 'boolean',
'close_comment' => 'boolean',
'ai_suggestions' => 'array',
'legacy_attachments' => 'array',
'cover_generated_at' => 'datetime',
];
}
public function hasCover(): bool
{
return $this->cover_status === 'ready' && filled($this->cover_path);
}
public function coverUrl(): ?string
{
return app(ArticleCoverService::class)->publicUrl($this);
}
protected function contentFormat(): Attribute
{
return Attribute::make(
get: fn (?string $value): string => ContentFormat::normalize($value, ContentFormat::HTML),
set: fn (?string $value): string => ContentFormat::normalize($value, ContentFormat::HTML),
);
}
public function renderedHtml(): string
{
return $this->rendered()['html'];
}
/**
* @return array{html: string, toc: list<array{level: int, id: string, text: string}>}
*/
public function rendered(): array
{
return app(ContentRenderer::class)->render(
(string) $this->content,
$this->content_format,
$this->id,
);
}
public function isMarkdown(): bool
{
return $this->content_format === ContentFormat::MARKDOWN;
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class);
}
public function scopeVisible(Builder $query): Builder
{
return $query->where('visible', true);
}
public function scopePublished(Builder $query): Builder
{
return $query
->whereNotNull('published_at')
->where('published_at', '<=', now());
}
}