exec('CREATE TABLE IF NOT EXISTS 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, cover_media_id INTEGER DEFAULT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (cover_media_id) REFERENCES media(id) ON DELETE SET NULL )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC)'); } public static function emptyForm(): array { return [ 'title' => '', 'excerpt' => '', 'cover_media_id' => '', 'body_markdown' => '', ]; } public function paginateList(int $page = 1, int $perPage = 12): array { $result = $this->paginate( max(0, $page - 1), $perPage, null, ['order' => 'created_at DESC, id DESC'] ); $posts = array_map(fn (self $p): array => $this->summaryRow($p->cast()), $result['subset']); $covers = $this->loadCovers($posts); foreach ($posts as &$post) { $cover = $covers[$post['cover_media_id']] ?? null; $post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : ''; } return [ 'posts' => $posts, 'page' => max(1, min($page, $result['count'] ?: 1)), 'pages' => $result['count'] ?: 1, ]; } public function findBySlug(string $slug): ?array { $this->load(['slug = ?', $slug]); if ($this->dry()) { return null; } $post = $this->summaryRow($this->cast()); $covers = $this->loadCovers([$post]); $cover = $covers[$post['cover_media_id']] ?? null; $post['cover_url'] = $cover ? app_media_url((string) $cover['file_name']) : ''; $post['body_html'] = (string) $this->body_html; return $post; } public function findForEdit(int $id): ?array { if ($id <= 0) { return null; } $this->load(['id = ?', $id]); if ($this->dry()) { return null; } return [ 'id' => (int) $this->get('id'), 'title' => (string) $this->title, 'excerpt' => (string) $this->excerpt, 'body_markdown' => (string) $this->body_markdown, 'cover_media_id' => $this->cover_media_id !== null ? (string) ((int) $this->cover_media_id) : '', ]; } public function create(array $input): int { $payload = $this->payload($input); $slug = app_unique_slug($payload['title'], fn (string $candidate): bool => $this->slugExists($candidate)); $now = app_now(); $this->reset(); $this->copyfrom($payload + [ 'slug' => $slug, 'created_at' => $now, 'updated_at' => $now, ]); $this->save(); return (int) $this->get('id'); } public function updatePost(int $id, array $input): bool { $this->load(['id = ?', $id]); if ($this->dry()) { return false; } $payload = $this->payload($input); $this->copyfrom($payload + ['updated_at' => app_now()]); $this->save(); return true; } public function delete(int $id): void { $this->load(['id = ?', $id]); if (!$this->dry()) { $this->erase(); } } private function payload(array $input): array { $title = trim((string) ($input['title'] ?? '')); $excerpt = trim((string) ($input['excerpt'] ?? '')); $bodyMarkdown = trim((string) ($input['body_markdown'] ?? '')); $coverMediaId = trim((string) ($input['cover_media_id'] ?? '')); 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."); } $media = new Media($this->db); $coverId = null; if ($coverMediaId !== '') { $coverId = (int) $coverMediaId; if ($media->findById($coverId) === null) { throw new RuntimeException('Image de couverture introuvable.'); } } $bodyHtml = MarkdownService::compile($bodyMarkdown, $media); return [ 'title' => $title, 'excerpt' => $excerpt, 'body_markdown' => $bodyMarkdown, 'body_html' => $bodyHtml, 'cover_media_id' => $coverId, ]; } private function slugExists(string $slug): bool { return $this->count(['slug = ?', $slug]) > 0; } private function summaryRow(array $row): array { return [ 'id' => (int) $row['id'], 'title' => (string) $row['title'], 'slug' => (string) $row['slug'], 'excerpt' => (string) $row['excerpt'], 'cover_media_id' => (int) ($row['cover_media_id'] ?? 0), 'created_at' => (string) $row['created_at'], 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), 'updated_at' => (string) $row['updated_at'], 'updated_at_label' => app_format_datetime_fr((string) $row['updated_at']), 'has_updated_at' => (string) $row['updated_at'] !== (string) $row['created_at'], ]; } private function loadCovers(array $posts): array { $ids = array_filter(array_unique(array_column($posts, 'cover_media_id'))); if ($ids === []) { return []; } $placeholders = implode(',', array_fill(0, count($ids), '?')); $rows = $this->db->exec( "SELECT id, file_name FROM media WHERE id IN ($placeholders)", array_values($ids) ); $map = []; foreach ($rows as $row) { $map[(int) $row['id']] = $row; } return $map; } }