exec('CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL UNIQUE, alt TEXT NOT NULL DEFAULT \'\', width INTEGER NOT NULL, height INTEGER NOT NULL, created_at TEXT NOT NULL )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_media_created_at ON media(created_at DESC)'); } public function all(): array { return array_map( fn (self $m): array => $this->decorate($m->cast()), $this->find(null, ['order' => 'created_at DESC, id DESC']) ?: [] ); } public function findById(int $id): ?array { if ($id <= 0) { return null; } $this->load(['id = ?', $id]); return $this->dry() ? null : $this->decorate($this->cast()); } public function findByFileName(string $fileName): ?array { $this->load(['file_name = ?', $fileName]); return $this->dry() ? null : $this->decorate($this->cast()); } // Reçoit le chemin absolu déposé par Web::receive() et le nom d'origine // pour dériver un texte alternatif lisible. public function upload(string $srcPath, string $originalName = ''): int { // Image::dump() gère le chargement et la compression. // Attention : en JPEG, la transparence n'est pas conservée. try { $img = new Image($srcPath, false, ''); } catch (Throwable) { throw new RuntimeException('Fichier image invalide ou format source non supporté.'); } $data = $img->dump('jpeg', 85); $fileName = bin2hex(random_bytes(16)) . '.jpg'; $target = app_public_media_dir() . '/' . $fileName; if (!Base::instance()->write($target, $data)) { throw new RuntimeException('Impossible d\'enregistrer cette image.'); } @unlink($srcPath); $this->reset(); $this->file_name = $fileName; $this->alt = $originalName !== '' ? self::altFromFilename($originalName) : ''; $this->width = $img->width(); $this->height = $img->height(); $this->created_at = app_now(); $this->save(); return (int) $this->get('id'); } public function updateAlt(int $id, string $alt): void { $this->load(['id = ?', $id]); if ($this->dry()) { throw new RuntimeException('Image introuvable.'); } $this->alt = trim($alt); $this->save(); } public function delete(int $id): void { $item = $this->findById($id); if ($item === null) { throw new RuntimeException('Image introuvable.'); } if ($this->isUsed($item)) { throw new RuntimeException('Cette image est encore utilisée par un article.'); } $path = app_public_media_dir() . '/' . $item['file_name']; $this->db->begin(); try { $this->erase(); if (is_file($path) && !unlink($path)) { throw new RuntimeException('Impossible de supprimer le fichier image.'); } $this->db->commit(); } catch (Throwable $e) { $this->db->rollback(); throw $e instanceof RuntimeException ? $e : new RuntimeException('Suppression impossible.'); } } // Dérive un texte alternatif lisible depuis le nom de fichier d'origine. private static function altFromFilename(string $filename): string { $name = pathinfo($filename, PATHINFO_FILENAME); $name = trim((string) preg_replace('/[-_]+/', ' ', $name)); // mb_ucfirst() n'existe qu'en PHP 8.4 — on l'émule. return mb_strtoupper(mb_substr($name, 0, 1)) . mb_strtolower(mb_substr($name, 1)); } // Une seule requête SQL pour les deux cas d'utilisation (couverture et body). private function isUsed(array $item): bool { return $this->db->exec( 'SELECT 1 FROM posts WHERE cover_media_id = ? OR body_markdown LIKE ? LIMIT 1', [$item['id'], '%media:' . $item['file_name'] . '%'] ) !== []; } private function decorate(array $row): array { $alt = (string) $row['alt']; return [ 'id' => (int) $row['id'], 'file_name' => (string) $row['file_name'], 'alt' => $alt, 'width' => (int) $row['width'], 'height' => (int) $row['height'], 'created_at' => (string) $row['created_at'], 'created_at_label' => app_format_datetime_fr((string) $row['created_at']), 'url' => app_media_url((string) $row['file_name']), 'markdown' => '![' . $alt . '](media:' . $row['file_name'] . ')', ]; } }