203 lines
5.6 KiB
PHP
203 lines
5.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
class Post extends DB\SQL\Mapper
|
||
{
|
||
public const TITLE_MAX_LENGTH = 120;
|
||
public const EXCERPT_MAX_LENGTH = 240;
|
||
|
||
public function __construct()
|
||
{
|
||
parent::__construct(Base::instance()->get('DB'), 'posts');
|
||
}
|
||
|
||
public static function bootstrap(DB\SQL $db): void
|
||
{
|
||
if ($db->schema('posts', null, 0)) {
|
||
return;
|
||
}
|
||
|
||
$db->exec('CREATE TABLE posts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
title TEXT NOT NULL,
|
||
slug TEXT NOT NULL UNIQUE,
|
||
excerpt TEXT NOT NULL,
|
||
body_markdown TEXT NOT NULL,
|
||
body_html TEXT NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL
|
||
)');
|
||
$db->exec('CREATE INDEX idx_posts_created_at ON posts(created_at DESC)');
|
||
}
|
||
|
||
public static function blank(): array
|
||
{
|
||
return [
|
||
'title' => '',
|
||
'excerpt' => '',
|
||
'body_markdown' => '',
|
||
];
|
||
}
|
||
|
||
public function page(int $page, int $perPage): array
|
||
{
|
||
$result = $this->paginate(max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC']);
|
||
$items = array_map(fn(self $row): array => $this->summary($row->cast()), $result['subset'] ?: []);
|
||
|
||
return [
|
||
'items' => $items,
|
||
'pagination' => [
|
||
'page' => max(1, min($page, $result['count'] ?: 1)),
|
||
'pages' => max(1, (int) ($result['count'] ?: 1)),
|
||
],
|
||
];
|
||
}
|
||
|
||
public function findBySlug(string $slug): ?array
|
||
{
|
||
$this->load(['slug = ?', $slug]);
|
||
if ($this->dry()) {
|
||
return null;
|
||
}
|
||
|
||
$row = $this->cast();
|
||
$post = $this->summary($row) + ['body_html' => (string) $row['body_html']];
|
||
|
||
return $post;
|
||
}
|
||
|
||
public function findForForm(int $id): ?array
|
||
{
|
||
$this->load(['id = ?', $id]);
|
||
if ($this->dry()) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'id' => (int) $this->id,
|
||
'title' => (string) $this->title,
|
||
'excerpt' => (string) $this->excerpt,
|
||
'body_markdown' => (string) $this->body_markdown,
|
||
];
|
||
}
|
||
|
||
public function savePost(array $input, ?int $id = null): int
|
||
{
|
||
$payload = $this->payload($input);
|
||
$now = app_now();
|
||
|
||
if ($id === null) {
|
||
$this->reset();
|
||
$payload['slug'] = $this->uniqueSlug($payload['title']);
|
||
$payload['created_at'] = $now;
|
||
} else {
|
||
$this->load(['id = ?', $id]);
|
||
if ($this->dry()) {
|
||
throw new RuntimeException('Article introuvable.');
|
||
}
|
||
}
|
||
|
||
$payload['updated_at'] = $now;
|
||
$this->copyfrom($payload);
|
||
$this->save();
|
||
|
||
return (int) $this->id;
|
||
}
|
||
|
||
public function deleteById(int $id): void
|
||
{
|
||
$this->load(['id = ?', $id]);
|
||
if ($this->dry()) {
|
||
throw new RuntimeException('Article introuvable.');
|
||
}
|
||
|
||
$this->erase();
|
||
}
|
||
|
||
public function usesMedia(string $fileName): bool
|
||
{
|
||
return $this->count(['body_markdown LIKE ?', '%media:' . $fileName . '%']) > 0;
|
||
}
|
||
|
||
private function payload(array $input): array
|
||
{
|
||
$title = trim((string) ($input['title'] ?? ''));
|
||
$excerpt = trim((string) ($input['excerpt'] ?? ''));
|
||
$body = trim((string) ($input['body_markdown'] ?? ''));
|
||
|
||
if ($title === '') {
|
||
throw new RuntimeException('Ajoute un titre.');
|
||
}
|
||
if (mb_strlen($title) > self::TITLE_MAX_LENGTH) {
|
||
throw new RuntimeException('Le titre est trop long.');
|
||
}
|
||
if ($excerpt === '') {
|
||
throw new RuntimeException('Ajoute un extrait.');
|
||
}
|
||
if (mb_strlen($excerpt) > self::EXCERPT_MAX_LENGTH) {
|
||
throw new RuntimeException('L’extrait est trop long.');
|
||
}
|
||
|
||
return [
|
||
'title' => $title,
|
||
'excerpt' => $excerpt,
|
||
'body_markdown' => $body,
|
||
'body_html' => MarkdownService::instance()->compile($body, new Media()),
|
||
];
|
||
}
|
||
|
||
private function uniqueSlug(string $title): string
|
||
{
|
||
$base = app_slug($title);
|
||
$slug = $base;
|
||
$n = 2;
|
||
|
||
while ($this->count(['slug = ?', $slug]) > 0) {
|
||
$slug = $base . '-' . $n;
|
||
$n++;
|
||
}
|
||
|
||
return $slug;
|
||
}
|
||
|
||
private function summary(array $row): array
|
||
{
|
||
$thumbnail = $this->firstImage((string) ($row['body_html'] ?? ''));
|
||
|
||
return [
|
||
'id' => (int) $row['id'],
|
||
'title' => (string) $row['title'],
|
||
'slug' => (string) $row['slug'],
|
||
'excerpt' => (string) $row['excerpt'],
|
||
'thumbnail_url' => $thumbnail['url'],
|
||
'thumbnail_alt' => $thumbnail['alt'],
|
||
'created_at' => (string) $row['created_at'],
|
||
'updated_at' => (string) $row['updated_at'],
|
||
];
|
||
}
|
||
|
||
private function firstImage(string $html): array
|
||
{
|
||
if ($html === '') {
|
||
return ['url' => '', 'alt' => ''];
|
||
}
|
||
|
||
if (!preg_match('~(<img\s[^>]*src="([^"]+)"[^>]*>)~i', $html, $match)) {
|
||
return ['url' => '', 'alt' => ''];
|
||
}
|
||
|
||
$tag = $match[1];
|
||
$alt = '';
|
||
|
||
if (preg_match('~alt="([^"]*)"~i', $tag, $altMatch)) {
|
||
$alt = html_entity_decode($altMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
}
|
||
|
||
return [
|
||
'url' => html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||
'alt' => $alt,
|
||
];
|
||
}
|
||
}
|