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('~(]*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, ]; } }