Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Console\Commands;
|
||||
|
||||
use Functional\Exercises\Models\Exercise;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Importe le dataset d'exercices (fork hasaneyldrm/exercises-dataset).
|
||||
* JSON : upsert par external_id. --media : pousse aussi images+gifs vers RustFS/S3.
|
||||
*/
|
||||
class ImportExercisesCommand extends Command
|
||||
{
|
||||
protected $signature = 'exercises:import
|
||||
{--path= : racine du dataset (contient data/exercises.json, images/, videos/)}
|
||||
{--media : uploade aussi les images/gifs vers le disque S3 "media"}
|
||||
{--langs=en,fr : langues d instructions à conserver (pt-BR retombe sur en)}
|
||||
{--fresh : vide la table avant import}';
|
||||
|
||||
protected $description = "Importe le dataset d'exercices (JSON) et, avec --media, les médias vers RustFS/S3.";
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$root = rtrim($this->option('path') ?: storage_path('app/dataset'), '/');
|
||||
$json = is_file("$root/data/exercises.json") ? "$root/data/exercises.json" : "$root/exercises.json";
|
||||
|
||||
if (! is_file($json)) {
|
||||
$this->error("exercises.json introuvable sous {$root}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$langs = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('langs')))));
|
||||
$records = json_decode((string) file_get_contents($json), true);
|
||||
|
||||
if (! is_array($records)) {
|
||||
$this->error('JSON invalide');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('fresh')) {
|
||||
Exercise::query()->delete();
|
||||
$this->warn('Table exercises vidée.');
|
||||
}
|
||||
|
||||
$media = (bool) $this->option('media');
|
||||
$prefix = trim((string) config('exercises.media_prefix', 'exercises'), '/');
|
||||
$disk = $media ? Storage::disk('media') : null;
|
||||
|
||||
$bar = $this->output->createProgressBar(count($records));
|
||||
$bar->start();
|
||||
$imported = 0;
|
||||
$uploaded = 0;
|
||||
|
||||
foreach ($records as $r) {
|
||||
$ext = (string) ($r['id'] ?? '');
|
||||
if ($ext === '') {
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Instructions : on garde les étapes (arrays) des langues demandées.
|
||||
$steps = $r['instruction_steps'] ?? [];
|
||||
$instructions = [];
|
||||
foreach ($langs as $l) {
|
||||
if (! empty($steps[$l])) {
|
||||
$instructions[$l] = array_values($steps[$l]);
|
||||
}
|
||||
}
|
||||
|
||||
$imgKey = ! empty($r['image']) ? "{$prefix}/{$ext}.jpg" : null;
|
||||
$gifKey = ! empty($r['gif_url']) ? "{$prefix}/{$ext}.gif" : null;
|
||||
|
||||
if ($media && $disk) {
|
||||
foreach ([[$r['image'] ?? null, $imgKey], [$r['gif_url'] ?? null, $gifKey]] as [$rel, $key]) {
|
||||
if ($rel && $key && is_file("{$root}/{$rel}") && ! $disk->exists($key)) {
|
||||
$disk->put($key, (string) file_get_contents("{$root}/{$rel}"), 'public');
|
||||
$uploaded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Exercise::updateOrCreate(
|
||||
['external_id' => $ext],
|
||||
[
|
||||
'name' => $r['name'] ?? $ext,
|
||||
'slug' => Str::slug(($r['name'] ?? $ext) . '-' . $ext),
|
||||
'category' => $r['category'] ?? null,
|
||||
'body_part' => $r['body_part'] ?? null,
|
||||
'equipment' => $r['equipment'] ?? null,
|
||||
'target' => $r['target'] ?? null,
|
||||
'muscle_group' => $r['muscle_group'] ?? null,
|
||||
'secondary_muscles' => $r['secondary_muscles'] ?? [],
|
||||
'instructions' => $instructions,
|
||||
'image_path' => $imgKey,
|
||||
'gif_path' => $gifKey,
|
||||
'attribution' => $r['attribution'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
$imported++;
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Importé/à jour : {$imported} exercices" . ($media ? " (+ {$uploaded} médias S3)" : ''));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Controllers;
|
||||
|
||||
use Functional\Exercises\Http\Resources\ExerciseListResource;
|
||||
use Functional\Exercises\Http\Resources\ExerciseResource;
|
||||
use Functional\Exercises\Models\Exercise;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* Catalogue d'exercices : lecture seule et public, aucun scoping par utilisateur.
|
||||
*/
|
||||
class ExercisesController
|
||||
{
|
||||
private const DEFAULT_LOCALE = 'fr';
|
||||
|
||||
private const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
/** GET /api/exercises — liste filtrable (search/body_part/equipment/target/category) et paginée. */
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$exercises = Exercise::query()
|
||||
->search($request->query('search'))
|
||||
->when($request->query('body_part'), fn ($query, $bodyPart) => $query->where('body_part', $bodyPart))
|
||||
->when($request->query('equipment'), fn ($query, $equipment) => $query->where('equipment', $equipment))
|
||||
->when($request->query('target'), fn ($query, $target) => $query->where('target', $target))
|
||||
->when($request->query('category'), fn ($query, $category) => $query->where('category', $category))
|
||||
->orderBy('name')
|
||||
->paginate((int) $request->query('per_page', self::DEFAULT_PER_PAGE));
|
||||
|
||||
return ExerciseListResource::collection($exercises);
|
||||
}
|
||||
|
||||
/** GET /api/exercises/{exercise} — détail avec instructions dans la locale demandée. */
|
||||
public function show(Request $request, Exercise $exercise): ExerciseResource
|
||||
{
|
||||
return new ExerciseResource($exercise, $this->resolveLocale($request));
|
||||
}
|
||||
|
||||
/** GET /api/exercises/facets — valeurs distinctes pour alimenter les filtres du front. */
|
||||
public function facets(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'body_parts' => Exercise::query()->whereNotNull('body_part')->distinct()->orderBy('body_part')->pluck('body_part')->values(),
|
||||
'equipments' => Exercise::query()->whereNotNull('equipment')->distinct()->orderBy('equipment')->pluck('equipment')->values(),
|
||||
'targets' => Exercise::query()->whereNotNull('target')->distinct()->orderBy('target')->pluck('target')->values(),
|
||||
'categories' => Exercise::query()->whereNotNull('category')->distinct()->orderBy('category')->pluck('category')->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Résout la locale demandée : paramètre `locale`, sinon header Accept-Language, sinon 'fr'. */
|
||||
private function resolveLocale(Request $request): string
|
||||
{
|
||||
$locale = $request->query('locale');
|
||||
|
||||
if ($locale) {
|
||||
return (string) $locale;
|
||||
}
|
||||
|
||||
$acceptLanguage = $request->header('Accept-Language');
|
||||
|
||||
if (! $acceptLanguage) {
|
||||
return self::DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
// Le header peut lister plusieurs langues pondérées ("fr-FR,fr;q=0.9,en;q=0.8") : on ne garde que la première.
|
||||
$firstLanguage = strtok($acceptLanguage, ',');
|
||||
$languageTag = strtok($firstLanguage, ';');
|
||||
|
||||
return $languageTag !== false ? $languageTag : self::DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Forme allégée d'un exercice pour les listes (GET /api/exercises).
|
||||
*/
|
||||
class ExerciseListResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'body_part' => $this->body_part,
|
||||
'equipment' => $this->equipment,
|
||||
'target' => $this->target,
|
||||
'image_url' => $this->image_url,
|
||||
'gif_url' => $this->gif_url,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* Détail complet d'un exercice (GET /api/exercises/{id}), instructions localisées.
|
||||
*/
|
||||
class ExerciseResource extends JsonResource
|
||||
{
|
||||
public function __construct($resource, private readonly string $locale)
|
||||
{
|
||||
parent::__construct($resource);
|
||||
}
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'category' => $this->category,
|
||||
'equipment' => $this->equipment,
|
||||
'target' => $this->target,
|
||||
'muscle_group' => $this->muscle_group,
|
||||
'secondary_muscles' => $this->secondary_muscles ?? [],
|
||||
'instructions' => $this->resource->instructionsFor($this->locale),
|
||||
'image_url' => $this->image_url,
|
||||
'gif_url' => $this->gif_url,
|
||||
'attribution' => $this->attribution,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Models;
|
||||
|
||||
use Functional\Exercises\Database\Factories\ExerciseFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\UseFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Catalogue d'exercices (lecture seule, alimenté par l'import du dataset).
|
||||
*
|
||||
* @property string $external_id
|
||||
* @property string $name
|
||||
* @property array|null $secondary_muscles
|
||||
* @property array|null $instructions
|
||||
*/
|
||||
#[UseFactory(ExerciseFactory::class)]
|
||||
class Exercise extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'external_id', 'name', 'slug', 'category', 'body_part', 'equipment',
|
||||
'target', 'muscle_group', 'secondary_muscles', 'instructions',
|
||||
'image_path', 'gif_path', 'attribution',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'secondary_muscles' => 'array',
|
||||
'instructions' => 'array',
|
||||
];
|
||||
|
||||
protected $appends = ['image_url', 'gif_url'];
|
||||
|
||||
/** URL publique du thumbnail (média servi depuis RustFS/S3). */
|
||||
public function imageUrl(): ?string
|
||||
{
|
||||
return $this->mediaUrl($this->image_path);
|
||||
}
|
||||
|
||||
public function gifUrl(): ?string
|
||||
{
|
||||
return $this->mediaUrl($this->gif_path);
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->imageUrl();
|
||||
}
|
||||
|
||||
public function getGifUrlAttribute(): ?string
|
||||
{
|
||||
return $this->gifUrl();
|
||||
}
|
||||
|
||||
private function mediaUrl(?string $path): ?string
|
||||
{
|
||||
if (! $path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim((string) config('exercises.media_base_url'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Instructions dans la locale demandée, avec repli.
|
||||
* pt-BR n'existe pas dans le dataset → repli en/fr.
|
||||
*/
|
||||
public function instructionsFor(string $locale): array
|
||||
{
|
||||
$data = $this->instructions ?? [];
|
||||
$chain = [$locale, substr($locale, 0, 2), 'en', 'fr'];
|
||||
|
||||
foreach ($chain as $lang) {
|
||||
if (! empty($data[$lang])) {
|
||||
return (array) $data[$lang];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Recherche plein-texte simple (nom / muscle cible / équipement). */
|
||||
public function scopeSearch(Builder $query, ?string $term): Builder
|
||||
{
|
||||
if (! $term) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$like = '%' . str_replace('%', '\%', $term) . '%';
|
||||
|
||||
return $query->where(function (Builder $q) use ($like) {
|
||||
$q->where('name', 'like', $like)
|
||||
->orWhere('target', 'like', $like)
|
||||
->orWhere('equipment', 'like', $like)
|
||||
->orWhere('body_part', 'like', $like);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Functional\Exercises\Providers;
|
||||
|
||||
use Functional\Exercises\Console\Commands\ImportExercisesCommand;
|
||||
use Functional\Exercises\Database\Seeders\ExercisesSeeder;
|
||||
use Xefi\LaravelOSDD\LayerServiceProvider;
|
||||
|
||||
class ExercisesServiceProvider extends LayerServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->loadMigrationsFrom(__DIR__ . '/../../database/migrations');
|
||||
$this->loadSeeders([ExercisesSeeder::class]);
|
||||
$this->commands([ImportExercisesCommand::class]);
|
||||
}
|
||||
|
||||
$this->withRouting(
|
||||
web: __DIR__ . '/../../routes/web.php',
|
||||
api: __DIR__ . '/../../routes/api.php',
|
||||
commands: __DIR__ . '/../../routes/console.php',
|
||||
channels: __DIR__ . '/../../routes/channels.php',
|
||||
);
|
||||
$this->loadSeeders([\Functional\Exercises\Database\Seeders\ExercisesSeeder::class]);
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/exercises.php', 'exercises');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user