Ajout de la possibilité d'importer les images des monstres issus de foundry
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m34s
Build & Push Images / build-switcher (push) Successful in 18s

This commit is contained in:
2026-06-25 17:28:09 +02:00
parent b999aa54ad
commit 3896c5b4cb
3 changed files with 65 additions and 13 deletions

View File

@@ -2,8 +2,14 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy; import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.campaigncontext.ports.EnemyRepository; import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
import com.loremind.application.images.ImageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -16,10 +22,14 @@ import java.util.Optional;
@Service @Service
public class EnemyService { public class EnemyService {
private final EnemyRepository enemyRepository; private static final Logger log = LoggerFactory.getLogger(EnemyService.class);
public EnemyService(EnemyRepository enemyRepository) { private final EnemyRepository enemyRepository;
private final ImageService imageService;
public EnemyService(EnemyRepository enemyRepository, ImageService imageService) {
this.enemyRepository = enemyRepository; this.enemyRepository = enemyRepository;
this.imageService = imageService;
} }
public record EnemyData( public record EnemyData(
@@ -83,8 +93,12 @@ public class EnemyService {
// --- Import de monstres depuis un compendium Foundry ------------------- // --- Import de monstres depuis un compendium Foundry -------------------
/** Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier. */ /**
public record MonsterImport(String name, String foundryRef, Map<String, String> stats, String folder) {} * Un monstre du catalogue Foundry : nom + référence + snapshot de stats + dossier
* + vignette du portrait (data URL base64, optionnelle).
*/
public record MonsterImport(String name, String foundryRef, Map<String, String> stats,
String folder, String imgData) {}
/** /**
* Dossier LoreMind d'un monstre importé : on conserve l'arborescence Foundry * Dossier LoreMind d'un monstre importé : on conserve l'arborescence Foundry
@@ -95,6 +109,38 @@ public class EnemyService {
return p.isEmpty() ? "Foundry" : "Foundry/" + p; return p.isEmpty() ? "Foundry" : "Foundry/" + p;
} }
/**
* Décode une vignette de portrait (data URL base64 "data:image/webp;base64,…")
* et l'enregistre comme image LoreMind. Renvoie l'id de l'image, ou null si
* absente/illisible (l'import ne doit jamais échouer à cause d'un portrait).
*/
private String storePortrait(String dataUrl, String name) {
if (dataUrl == null || !dataUrl.startsWith("data:")) return null;
int comma = dataUrl.indexOf(',');
if (comma < 0) return null;
String header = dataUrl.substring(5, comma); // "image/webp;base64"
String contentType = header.split(";")[0].toLowerCase();
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(dataUrl.substring(comma + 1));
} catch (IllegalArgumentException e) {
return null;
}
if (bytes.length == 0) return null;
String ext = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext;
try {
Image img = imageService.upload(filename, contentType,
new ByteArrayInputStream(bytes), bytes.length);
return img.getId();
} catch (RuntimeException e) {
log.warn("Portrait de monstre ignoré (\"{}\") : {}", name, e.getMessage());
return null;
}
}
public record MonsterImportResult(int created, int updated) {} public record MonsterImportResult(int created, int updated) {}
/** /**
@@ -124,6 +170,11 @@ public class EnemyService {
ex.setName(m.name()); ex.setName(m.name());
ex.setFoundryStats(stats); // rafraîchit le snapshot ex.setFoundryStats(stats); // rafraîchit le snapshot
ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry ex.setFolder(folder); // ré-aligne sur l'arborescence Foundry
// Portrait : seulement s'il manque (pas de ré-upload à chaque import).
if (ex.getPortraitImageId() == null) {
String portrait = storePortrait(m.imgData(), m.name());
if (portrait != null) ex.setPortraitImageId(portrait);
}
enemyRepository.save(ex); enemyRepository.save(ex);
updated++; updated++;
} else { } else {
@@ -132,6 +183,7 @@ public class EnemyService {
.foundryRef(m.foundryRef()) .foundryRef(m.foundryRef())
.foundryStats(stats) .foundryStats(stats)
.folder(folder) .folder(folder)
.portraitImageId(storePortrait(m.imgData(), m.name()))
.campaignId(campaignId) .campaignId(campaignId)
.order(order++) .order(order++)
.values(new HashMap<>()) .values(new HashMap<>())

View File

@@ -27,7 +27,7 @@ public class FoundryMonsterImportController {
public record MonsterCatalog(String system, List<MonsterEntry> monsters) {} public record MonsterCatalog(String system, List<MonsterEntry> monsters) {}
public record MonsterEntry(String name, String uuid, String type, String img, public record MonsterEntry(String name, String uuid, String type, String img,
java.util.Map<String, String> stats, String folder) {} java.util.Map<String, String> stats, String folder, String imgData) {}
@PostMapping("/import-foundry-monsters") @PostMapping("/import-foundry-monsters")
public ResponseEntity<EnemyService.MonsterImportResult> importMonsters( public ResponseEntity<EnemyService.MonsterImportResult> importMonsters(
@@ -35,7 +35,7 @@ public class FoundryMonsterImportController {
@RequestBody MonsterCatalog catalog) { @RequestBody MonsterCatalog catalog) {
List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters()) List<EnemyService.MonsterImport> monsters = (catalog.monsters() == null ? List.<MonsterEntry>of() : catalog.monsters())
.stream() .stream()
.map(m -> new EnemyService.MonsterImport(m.name(), m.uuid(), m.stats(), m.folder())) .map(m -> new EnemyService.MonsterImport(m.name(), m.uuid(), m.stats(), m.folder(), m.imgData()))
.toList(); .toList();
return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters)); return ResponseEntity.ok(enemyService.importFoundryMonsters(campaignId, monsters));
} }

View File

@@ -30,8 +30,8 @@ class EnemyServiceMonsterImportTest {
CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId(); CampaignJpaEntity.builder().name("Camp").arcsCount(0).build()).getId();
var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( var r1 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1", Map.of("level", "1"), "Briarban"), new EnemyService.MonsterImport("Goblin", "Compendium.nimble.monsters.Actor.g1", Map.of("level", "1"), "Briarban", null),
new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1", Map.of(), null))); new EnemyService.MonsterImport("Orc", "Compendium.nimble.monsters.Actor.o1", Map.of(), null, null)));
assertEquals(2, r1.created()); assertEquals(2, r1.created());
assertEquals(0, r1.updated()); assertEquals(0, r1.updated());
assertEquals(2, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); assertEquals(2, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size());
@@ -49,8 +49,8 @@ class EnemyServiceMonsterImportTest {
// Réimport : g1 déjà connu (renommé + redossiérisé) + un nouveau (k1). Pas de doublon. // Réimport : g1 déjà connu (renommé + redossiérisé) + un nouveau (k1). Pas de doublon.
var r2 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( var r2 = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1", Map.of(), "Briarban/Bosses"), new EnemyService.MonsterImport("Goblin Boss", "Compendium.nimble.monsters.Actor.g1", Map.of(), "Briarban/Bosses", null),
new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1", Map.of(), "Kobolds"))); new EnemyService.MonsterImport("Kobold", "Compendium.nimble.monsters.Actor.k1", Map.of(), "Kobolds", null)));
assertEquals(1, r2.created()); assertEquals(1, r2.created());
assertEquals(1, r2.updated()); assertEquals(1, r2.updated());
assertEquals("Foundry/Briarban/Bosses", enemyRepo.findByCampaignIdOrderByOrderAsc(cid).stream() assertEquals("Foundry/Briarban/Bosses", enemyRepo.findByCampaignIdOrderByOrderAsc(cid).stream()
@@ -70,9 +70,9 @@ class EnemyServiceMonsterImportTest {
CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId(); CampaignJpaEntity.builder().name("Camp2").arcsCount(0).build()).getId();
var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of( var r = enemyService.importFoundryMonsters(String.valueOf(cid), List.of(
new EnemyService.MonsterImport("", "Compendium.x.Actor.a", Map.of(), null), new EnemyService.MonsterImport("", "Compendium.x.Actor.a", Map.of(), null, null),
new EnemyService.MonsterImport("SansRef", " ", Map.of(), null), new EnemyService.MonsterImport("SansRef", " ", Map.of(), null, null),
new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b", Map.of(), null))); new EnemyService.MonsterImport("Valide", "Compendium.x.Actor.b", Map.of(), null, null)));
assertEquals(1, r.created()); assertEquals(1, r.created());
assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size()); assertEquals(1, enemyRepo.findByCampaignIdOrderByOrderAsc(cid).size());
} }