Refacto du code coté Python, java et coté angular afin de mieux séparer les responsabilité et d'avoir moins de répétitivité dans le code.
Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s

Mise en place de tests unitaires coté Python et Angular
Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue
Passage en v0.16.2 en conséquence
This commit is contained in:
2026-06-18 15:59:10 +02:00
parent eb78a75621
commit 4d049274f9
68 changed files with 4433 additions and 1360 deletions

View File

@@ -14,7 +14,6 @@ import com.loremind.domain.campaigncontext.ports.CampaignPdfImporter;
import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.MediaType;
import org.springframework.http.client.MultipartBodyBuilder;
import org.springframework.http.codec.ServerSentEvent;
@@ -23,7 +22,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -43,7 +41,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
new ParameterizedTypeReference<>() {};
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final BrainSseImportSupport sse;
private final long importTimeoutSeconds;
public BrainCampaignImportClient(
@@ -52,7 +50,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
@Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.sse = new BrainSseImportSupport(objectMapper);
this.importTimeoutSeconds = importTimeoutSeconds;
}
@@ -67,7 +65,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
parts.part("file", sse.filePart(pdfBytes, filename, "campaign.pdf"))
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
@@ -83,31 +81,16 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast();
if (!terminated[0]) {
onError.accept(new CampaignImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, le diagnostic
// est impossible (timeout WebClient, connexion coupée, réponse non-2xx…).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new CampaignImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
sse.runStream(
flux, importTimeoutSeconds, terminated,
event -> handleEvent(
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, CampaignImportException::new);
}
private void handleEvent(
ServerSentEvent<String> sse,
ServerSentEvent<String> ssEvent,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
@@ -117,8 +100,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -129,23 +112,17 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
onStatus.accept(sse.readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
onStatus.accept(sse.chunkFailedStatus(data));
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
return;
}
if ("extracting".equals(event)) {
@@ -153,7 +130,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
return;
}
JsonNode node = readJson(data);
JsonNode node = sse.readJson(data);
if (node == null) return;
if ("start".equals(event)) {
@@ -246,33 +223,8 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
// --- Helpers -------------------------------------------------------------
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "campaign.pdf" : filename;
}
};
}
private static String text(JsonNode node, String field) {
JsonNode v = node.path(field);
return v.isMissingNode() || v.isNull() ? "" : v.asText();
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
}

View File

@@ -108,9 +108,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", q.name());
map.put("arc_name", q.arcName());
if (q.description() != null && !q.description().isBlank()) {
map.put("description", q.description());
}
putIfText(map, "description", q.description());
return map;
}
@@ -121,18 +119,14 @@ public class BrainChatPayloadBuilder {
if (e.occurredAt() != null) {
map.put("occurred_at", e.occurredAt().toString());
}
if (e.sourceSessionName() != null && !e.sourceSessionName().isBlank()) {
map.put("source_session_name", e.sourceSessionName());
}
putIfText(map, "source_session_name", e.sourceSessionName());
return map;
}
private Map<String, Object> gameSystemContextToMap(GameSystemContext gs) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("system_name", gs.systemName());
if (gs.systemDescription() != null && !gs.systemDescription().isBlank()) {
map.put("system_description", gs.systemDescription());
}
putIfText(map, "system_description", gs.systemDescription());
map.put("sections", gs.sections() != null ? gs.sections() : Map.of());
return map;
}
@@ -211,18 +205,14 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> characterSummaryToMap(CharacterSummary c) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", c.name());
if (c.snippet() != null && !c.snippet().isBlank()) {
map.put("snippet", c.snippet());
}
putIfText(map, "snippet", c.snippet());
return map;
}
private Map<String, Object> npcSummaryToMap(NpcSummary n) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", n.name());
if (n.snippet() != null && !n.snippet().isBlank()) {
map.put("snippet", n.snippet());
}
putIfText(map, "snippet", n.snippet());
return map;
}
@@ -300,9 +290,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>();
map.put("label", b.label());
map.put("target_scene_name", b.targetSceneName());
if (b.condition() != null && !b.condition().isBlank()) {
map.put("condition", b.condition());
}
putIfText(map, "condition", b.condition());
return map;
}
@@ -310,14 +298,14 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor());
if (r.description() != null && !r.description().isBlank()) map.put("description", r.description());
if (r.enemies() != null && !r.enemies().isBlank()) map.put("enemies", r.enemies());
putIfText(map, "description", r.description());
putIfText(map, "enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> {
Map<String, Object> bm = new LinkedHashMap<>();
bm.put("label", b.label());
bm.put("target_room_name", b.targetRoomName());
if (b.condition() != null && !b.condition().isBlank()) bm.put("condition", b.condition());
putIfText(bm, "condition", b.condition());
return bm;
}).collect(Collectors.toList()));
}
@@ -331,4 +319,15 @@ public class BrainChatPayloadBuilder {
map.put("fields", ne.fields());
return map;
}
/**
* Ajoute {@code key → value} uniquement si {@code value} est une chaîne non
* nulle et non blanche. Centralise l'omission des champs Optional « texte »
* pour s'aligner sur le schéma Pydantic du Brain (champ absent si vide).
*/
private static void putIfText(Map<String, Object> map, String key, String value) {
if (value != null && !value.isBlank()) {
map.put(key, value);
}
}
}

View File

@@ -10,7 +10,6 @@ import com.loremind.infrastructure.web.config.UserLanguageHolder;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -26,7 +25,6 @@ import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -56,7 +54,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
private final RestTemplate restTemplate;
private final WebClient webClient;
private final ObjectMapper objectMapper;
private final BrainSseImportSupport sse;
private final String baseUrl;
private final long importTimeoutSeconds;
@@ -68,7 +66,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper;
this.sse = new BrainSseImportSupport(objectMapper);
this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds;
}
@@ -81,7 +79,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart(pdfBytes, filename));
body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf"));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try {
@@ -121,7 +119,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", filePart(pdfBytes, filename))
parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf"))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post()
@@ -139,33 +137,16 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] ocrPageCount = {0};
boolean[] terminated = {false};
try {
flux
.timeout(Duration.ofSeconds(importTimeoutSeconds))
.doOnNext(sse -> handleEvent(
sse, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError))
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(new RulesImportException(
"Le flux d'import s'est interrompu avant la fin."));
}
} catch (Exception e) {
if (!terminated[0]) {
// On EXPOSE la cause réelle (type + message) : sans ça, l'UI n'a qu'un
// message générique et le diagnostic est impossible (timeout WebClient,
// connexion coupée, réponse non-2xx du Brain, etc.).
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(new RulesImportException(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
sse.runStream(
flux, importTimeoutSeconds, terminated,
event -> handleEvent(
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, RulesImportException::new);
}
private void handleEvent(
ServerSentEvent<String> sse,
ServerSentEvent<String> ssEvent,
int[] pageCount,
int[] ocrPageCount,
boolean[] terminated,
@@ -175,8 +156,8 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = sse.event();
String data = sse.data() == null ? "" : sse.data();
String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data();
if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
@@ -188,23 +169,17 @@ public class BrainRulesImportClient implements RulesPdfImporter {
if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(readMessage(data));
onStatus.accept(sse.readMessage(data));
return;
}
if ("chunk_failed".equals(event)) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg));
onStatus.accept(sse.chunkFailedStatus(data));
return;
}
if ("error".equals(event)) {
terminated[0] = true;
onError.accept(new RulesImportException(
"Le Brain a signalé une erreur : " + readMessage(data)));
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
return;
}
if ("extracting".equals(event)) {
@@ -213,7 +188,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
return;
}
JsonNode node = readJson(data);
JsonNode node = sse.readJson(data);
if (node == null) return;
if ("start".equals(event)) {
@@ -239,32 +214,6 @@ public class BrainRulesImportClient implements RulesPdfImporter {
// --- Helpers -------------------------------------------------------------
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
private ByteArrayResource filePart(byte[] pdfBytes, String filename) {
return new ByteArrayResource(pdfBytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? "rules.pdf" : filename;
}
};
}
private JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null; // morceau de flux non-JSON inattendu : on l'ignore.
}
}
private String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
private List<String> toStringList(JsonNode array) {
List<String> out = new ArrayList<>();
if (array != null && array.isArray()) {

View File

@@ -0,0 +1,107 @@
package com.loremind.infrastructure.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.codec.ServerSentEvent;
import reactor.core.publisher.Flux;
import java.time.Duration;
import java.util.function.BiFunction;
import java.util.function.Consumer;
/**
* Helper d'infrastructure mutualisé entre les clients d'import SSE du Brain
* ({@link BrainRulesImportClient} et {@link BrainCampaignImportClient}), qui
* partagent la même mécanique de transport (multipart → flux SSE WebClient) et
* les mêmes événements transverses (heartbeat / status / chunk_failed).
* <p>
* Volontairement instancié en interne par chaque client (et non injecté) pour
* préserver leurs signatures de constructeur. Le parsing métier des événements
* {@code start} / {@code progress} / {@code done} reste propre à chaque client.
*/
final class BrainSseImportSupport {
private final ObjectMapper objectMapper;
BrainSseImportSupport(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/** ByteArrayResource avec nom de fichier : sans nom, l'upload n'est pas vu comme un fichier. */
ByteArrayResource filePart(byte[] bytes, String filename, String defaultName) {
return new ByteArrayResource(bytes) {
@Override
public String getFilename() {
return (filename == null || filename.isBlank()) ? defaultName : filename;
}
};
}
/** Parse le JSON, ou {@code null} si illisible (morceau de flux non-JSON inattendu : ignoré). */
JsonNode readJson(String data) {
try {
return objectMapper.readTree(data);
} catch (Exception e) {
return null;
}
}
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
String readMessage(String data) {
JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) {
return node.get("message").asText();
}
return data;
}
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
String chunkFailedStatus(String data) {
JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message")
? node.get("message").asText() : "";
int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0;
return "Morceau " + current + "/" + total + " ignoré"
+ (msg.isEmpty() ? "." : " : " + msg);
}
/**
* Consomme le flux SSE jusqu'au bout ({@code blockLast}) en dispatchant chaque
* événement vers {@code handler}, et traduit les fins anormales en {@code onError} :
* <ul>
* <li>flux clos sans event {@code done}/{@code error} ({@code terminated[0]==false}) ;</li>
* <li>exception de transport (timeout WebClient, connexion coupée, réponse non-2xx)
* — la cause réelle (type + message) est exposée dans le message.</li>
* </ul>
* {@code errorFactory} fabrique l'exception de domaine à partir d'un message et
* d'une cause (nullable pour l'interruption silencieuse).
*/
void runStream(
Flux<ServerSentEvent<String>> flux,
long timeoutSeconds,
boolean[] terminated,
Consumer<ServerSentEvent<String>> handler,
Consumer<Throwable> onError,
BiFunction<String, Throwable, ? extends RuntimeException> errorFactory) {
try {
flux
.timeout(Duration.ofSeconds(timeoutSeconds))
.doOnNext(handler)
.blockLast();
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
if (!terminated[0]) {
onError.accept(errorFactory.apply(
"Le flux d'import s'est interrompu avant la fin.", null));
}
} catch (Exception e) {
if (!terminated[0]) {
String cause = e.getClass().getSimpleName()
+ (e.getMessage() != null ? "" + e.getMessage() : "");
onError.accept(errorFactory.apply(
"Erreur lors du streaming d'import depuis le Brain : " + cause, e));
}
}
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.SceneBranch;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Logique de remapping des identifiants pour l'import en mode FUSION
* (cf. {@link ImportService}).
* <p>
* Fonctions PURES (sans état ni I/O) : chaque entité importée reçoit un nouvel id,
* et toutes les références — FK {@code Long} comme refs faibles stockées en
* {@code String} — sont réécrites {@code oldId → newId} via les maps fournies.
* Une référence absente de la map est CONSERVÉE telle quelle (choix : ne jamais
* perdre d'info, ne jamais planter sur une référence hors périmètre d'export).
*/
final class IdRemapper {
private IdRemapper() {
}
/** Remap d'une FK Long : si absente de la map, on garde l'ancienne valeur ; {@code null → null}. */
static Long remapId(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'un id stocké en String ({@code "oldLong" → "newLong"}) via une map Long. */
static String remapStringId(Map<Long, Long> map, String oldId) {
if (oldId == null || oldId.isBlank()) return oldId;
try {
Long newId = map.get(Long.parseLong(oldId.trim()));
return newId != null ? String.valueOf(newId) : oldId;
} catch (NumberFormatException ex) {
return oldId; // pas un Long : on laisse tel quel
}
}
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
if (ids == null) return null;
List<String> out = new ArrayList<>(ids.size());
for (String id : ids) out.add(remapStringId(map, id));
return out;
}
static List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null;
List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
} else {
out.add(p); // FlagSet / SessionReached : inchangés
}
}
return out;
}
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null;
List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
}
return out;
}
/** Parse un {@link ArcType} tolérant : type inconnu ou {@code null} → {@code LINEAR}. */
static ArcType parseArcType(String type) {
if (type == null) return ArcType.LINEAR;
try {
return ArcType.valueOf(type);
} catch (IllegalArgumentException ex) {
return ArcType.LINEAR;
}
}
}

View File

@@ -0,0 +1,91 @@
package com.loremind.infrastructure.transfer;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Réécriture des images lors d'un import (cf. {@link ImportService}).
* <p>
* Les binaires sont stockés sous LEUR CLÉ D'ORIGINE (pas de remapping de clé) :
* une image dont la clé existe déjà est RÉUTILISÉE (pas de réupload), pour éviter
* les doublons quand on agrège plusieurs exports dans la même base.
*/
@Component
class ImageImporter {
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
ImageImporter(ImageJpaRepository imageRepo, ImageStorage imageStorage) {
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
}
/**
* Réécrit les binaires d'images (clé préservée) et leurs métadonnées.
*
* @param export contenu importé (source des métadonnées par clé)
* @param imageBinaries {@code storageKey → binaire} lus depuis le zip
* @param result compteurs d'images (uploadées / réutilisées) à incrémenter
*/
void importImages(ContentExport export,
Map<String, byte[]> imageBinaries,
ImportResult.Builder result) {
// Index des métadonnées d'image par clé (depuis le data.json).
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
for (ContentExport.ImageDto img : nullSafe(export.images())) {
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
}
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
String storageKey = bin.getKey();
byte[] data = bin.getValue();
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
// Image déjà présente : on réutilise, pas de réupload (éviter doublon).
result.imageReused();
continue;
}
ContentExport.ImageDto meta = metaByKey.get(storageKey);
String contentType = meta != null && meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta != null ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta != null && meta.filename() != null
? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
imageRepo.save(e);
result.imageUploaded();
}
}
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
private static String fileNameOf(String storageKey) {
int slash = storageKey.lastIndexOf('/');
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
}
private static String guessContentType(String storageKey) {
String lower = storageKey.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
}

View File

@@ -1,9 +1,6 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
@@ -11,7 +8,6 @@ import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -65,8 +61,7 @@ public class ImportService {
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
private final ImageImporter imageImporter;
private final ObjectMapper objectMapper;
public ImportService(GameSystemJpaRepository gameSystemRepo,
@@ -83,8 +78,7 @@ public class ImportService {
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ImageJpaRepository imageRepo,
ImageStorage imageStorage,
ImageImporter imageImporter,
ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
@@ -100,42 +94,20 @@ public class ImportService {
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
this.imageImporter = imageImporter;
this.objectMapper = objectMapper;
}
@Transactional
public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip.
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
}
// manifest.json : ignore a l'import (info seulement).
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
ParsedArchive archive = parseArchive(zipStream);
ContentExport export = archive.export();
ImportResult.Builder result = new ImportResult.Builder();
// 2. Reecriture des images (cle preservee).
importImages(export, imageBinaries, result);
imageImporter.importImages(export, archive.imageBinaries(), result);
// 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>();
@@ -184,7 +156,7 @@ public class ImportService {
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe plus bas
e.setLoreId(remapRequired(loreMap, d.loreId()));
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) loreNodesToFix.add(saved);
@@ -195,7 +167,7 @@ public class ImportService {
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
e.setLoreId(remapRequired(loreMap, d.loreId()));
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
@@ -208,9 +180,9 @@ public class ImportService {
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
e.setLoreId(remapRequired(loreMap, d.loreId()));
e.setNodeId(remapRequired(loreNodeMap, d.nodeId()));
e.setTemplateId(remapNullable(templateMap, d.templateId()));
e.setLoreId(IdRemapper.remapId(loreMap, d.loreId()));
e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId()));
e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
@@ -240,9 +212,9 @@ public class ImportService {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
e.setType(parseArcType(d.type()));
e.setType(IdRemapper.parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
@@ -263,7 +235,7 @@ public class ImportService {
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<CatalogItemJpaEntity> items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
@@ -292,7 +264,7 @@ public class ImportService {
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
@@ -318,7 +290,7 @@ public class ImportService {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(remapRequired(arcMap, d.arcId()));
e.setArcId(IdRemapper.remapId(arcMap, d.arcId()));
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
e.setIcon(d.icon());
@@ -341,7 +313,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setFolder(d.folder());
e.setOrder(d.order());
@@ -360,7 +332,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
@@ -375,7 +347,7 @@ public class ImportService {
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapNullable(campaignMap, d.campaignId()));
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
@@ -387,7 +359,7 @@ public class ImportService {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setChapterId(remapRequired(chapterMap, d.chapterId()));
e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setLocation(d.location());
@@ -434,8 +406,8 @@ public class ImportService {
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = remapStringId(loreMap, c.getLoreId());
String newGs = remapStringId(gameSystemMap, c.getGameSystemId());
String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId());
String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
@@ -445,7 +417,7 @@ public class ImportService {
// Page.relatedPageIds
for (Long newPageId : pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds()));
p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
@@ -453,7 +425,7 @@ public class ImportService {
// Arc.relatedPageIds
for (Long newArcId : arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds()));
a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
@@ -461,8 +433,8 @@ public class ImportService {
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds()));
c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites()));
c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds()));
c.setPrerequisites(IdRemapper.remapPrerequisites(chapterMap, c.getPrerequisites()));
chapterRepo.save(c);
});
}
@@ -470,7 +442,7 @@ public class ImportService {
// Npc.relatedPageIds
for (Long newNpcId : npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds()));
n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
@@ -478,9 +450,9 @@ public class ImportService {
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds()));
s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds()));
s.setBranches(remapBranches(sceneMap, s.getBranches()));
s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds()));
s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds()));
s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
@@ -488,104 +460,40 @@ public class ImportService {
return result.build();
}
// ----- Images -----
// ----- Lecture de l'archive -----
private void importImages(ContentExport export,
Map<String, byte[]> imageBinaries,
ImportResult.Builder result) {
// Index des metadonnees d'image par cle (depuis le data.json).
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
for (ContentExport.ImageDto img : nullSafe(export.images())) {
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
}
/** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */
private record ParsedArchive(ContentExport export, Map<String, byte[]> imageBinaries) {
}
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
String storageKey = bin.getKey();
byte[] data = bin.getValue();
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
// Image deja presente : on reutilise, pas de reupload (eviter doublon).
result.imageReused();
continue;
/**
* Déballe le zip : {@code data.json → ContentExport} et {@code images/<clé> → binaire}
* (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque.
*/
private ParsedArchive parseArchive(InputStream zipStream) {
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
}
zip.closeEntry();
}
ContentExport.ImageDto meta = metaByKey.get(storageKey);
String contentType = meta != null && meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta != null ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta != null && meta.filename() != null
? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
imageRepo.save(e);
result.imageUploaded();
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
}
// ----- Helpers de remapping -----
/** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */
private Long remapRequired(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'une FK Long nullable : null reste null. */
private Long remapNullable(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */
private String remapStringId(Map<Long, Long> map, String oldId) {
if (oldId == null || oldId.isBlank()) return oldId;
try {
Long newId = map.get(Long.parseLong(oldId.trim()));
return newId != null ? String.valueOf(newId) : oldId;
} catch (NumberFormatException ex) {
return oldId; // pas un Long : on laisse tel quel
}
}
private List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
if (ids == null) return null;
List<String> out = new ArrayList<>(ids.size());
for (String id : ids) out.add(remapStringId(map, id));
return out;
}
private List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null;
List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
} else {
out.add(p); // FlagSet / SessionReached : inchanges
}
}
return out;
}
private List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null;
List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
}
return out;
}
private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) {
if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR;
try {
return com.loremind.domain.campaigncontext.ArcType.valueOf(type);
} catch (IllegalArgumentException ex) {
return com.loremind.domain.campaigncontext.ArcType.LINEAR;
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
return new ParsedArchive(export, imageBinaries);
}
// ----- Helpers divers -----
@@ -599,18 +507,4 @@ public class ImportService {
in.transferTo(buffer);
return buffer.toByteArray();
}
private static String fileNameOf(String storageKey) {
int slash = storageKey.lastIndexOf('/');
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
}
private static String guessContentType(String storageKey) {
String lower = storageKey.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,81 @@
package com.loremind.infrastructure.updates;
import org.springframework.lang.Nullable;
import java.util.List;
/**
* Parsing et comparaison de versions semver (stratégie de {@link UpdateCheckService}).
* <p>
* Volontairement tolérant : préfixe {@code v}/{@code V} accepté, pré-release
* ({@code -beta.1}) et build metadata ({@code +build.42}) strippés avant comparaison.
* Un tag non parsable est simplement ignoré (jamais d'exception). Fonctions PURES.
*/
final class SemverComparator {
private SemverComparator() {
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 à 3 chiffres séparés par des points, préfixe {@code v} optionnel),
* retourne le plus élevé, ou {@code null} si aucun n'est valide.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return {@code [major, minor, patch]} ou {@code null} si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (préfixe toléré). Négatif si {@code a < b}. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
}

View File

@@ -25,7 +25,6 @@ import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -114,32 +113,9 @@ public class UpdateCheckService {
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
}
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : images) {
String latest = null;
try {
latest = fetchLatestSemverTag(registry, image, null);
} catch (Exception e) {
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else {
int cmp = compareSemver(currentVersion, latest);
if (cmp >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
anyUpdate = true;
}
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
ImagesEvaluation ev = evaluateImages(images, registry, null);
return new UpdateStatus(true, ev.anyUpdate(), ev.anyUnknown(),
currentVersion, ev.statuses(), Instant.now());
}
/**
@@ -169,21 +145,37 @@ public class UpdateCheckService {
(creds.get().username() + ":" + creds.get().password()).getBytes(StandardCharsets.UTF_8));
String betaRegistry = normalizeRegistry(creds.get().registry());
ImagesEvaluation ev = evaluateImages(betaImages, betaRegistry, basicAuth);
return new BetaStatus(true, ev.anyUpdate(), ev.anyUnknown(),
ev.statuses(), Instant.now(), null);
}
/** Résultat de l'évaluation d'une liste d'images : statuts + drapeaux agrégés. */
private record ImagesEvaluation(List<ImageStatus> statuses, boolean anyUpdate, boolean anyUnknown) {
}
/**
* Boucle commune à {@link #check()} et {@link #checkBeta()} : pour chaque image,
* récupère le tag semver le plus haut et le compare à la version courante.
* Une image dont les tags sont injoignables ou non semver → {@code UNKNOWN}.
*/
private ImagesEvaluation evaluateImages(List<String> imageList, String registryUrl,
@Nullable String authHeader) {
List<ImageStatus> statuses = new ArrayList<>();
boolean anyUpdate = false;
boolean anyUnknown = false;
for (String image : betaImages) {
for (String image : imageList) {
String latest = null;
try {
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
latest = fetchLatestSemverTag(registryUrl, image, authHeader);
} catch (Exception e) {
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
}
ImageStatusKind kind;
if (latest == null) {
kind = ImageStatusKind.UNKNOWN;
anyUnknown = true;
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
} else if (currentVersion != null && SemverComparator.compareSemver(currentVersion, latest) >= 0) {
kind = ImageStatusKind.UP_TO_DATE;
} else {
kind = ImageStatusKind.UPDATE_AVAILABLE;
@@ -191,7 +183,7 @@ public class UpdateCheckService {
}
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
}
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
return new ImagesEvaluation(statuses, anyUpdate, anyUnknown);
}
public void apply() {
@@ -209,6 +201,10 @@ public class UpdateCheckService {
// -----------------------------------------------------------------------
// Registry HTTP API v2 - tags listing + auth bearer
//
// NB : le RestTemplate (champ `http`) reste porté par ce service — les tests
// l'injectent par réflexion. Le parsing semver et le parsing du challenge
// WWW-Authenticate sont, eux, externalisés (SemverComparator, WwwAuthenticate).
// -----------------------------------------------------------------------
/**
@@ -244,7 +240,7 @@ public class UpdateCheckService {
body = tagsCall(url, bearerHeaders);
}
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
return findMaxSemver(body.tags);
return SemverComparator.findMaxSemver(body.tags);
}
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
@@ -253,69 +249,6 @@ public class UpdateCheckService {
return resp.getBody();
}
/**
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
* Pre-release / build metadata sont strippes pour la comparaison.
*/
@Nullable
static String findMaxSemver(List<String> tags) {
String maxTag = null;
int[] maxParts = null;
for (String t : tags) {
if (t == null || t.isBlank()) continue;
int[] parts = parseSemver(t);
if (parts == null) continue;
if (maxParts == null || compareParts(parts, maxParts) > 0) {
maxParts = parts;
maxTag = t;
}
}
return maxTag;
}
/** @return [major, minor, patch] ou null si non parsable. */
@Nullable
static int[] parseSemver(String tag) {
if (tag == null) return null;
String s = tag.trim();
if (s.isEmpty()) return null;
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
int dashIdx = s.indexOf('-');
if (dashIdx > 0) s = s.substring(0, dashIdx);
int plusIdx = s.indexOf('+');
if (plusIdx > 0) s = s.substring(0, plusIdx);
String[] parts = s.split("\\.");
if (parts.length < 1 || parts.length > 3) return null;
int[] result = new int[]{0, 0, 0};
for (int i = 0; i < parts.length; i++) {
try {
int v = Integer.parseInt(parts[i]);
if (v < 0) return null;
result[i] = v;
} catch (NumberFormatException e) {
return null;
}
}
return result;
}
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
static int compareSemver(String a, String b) {
int[] aParts = parseSemver(a);
int[] bParts = parseSemver(b);
if (aParts == null || bParts == null) return 0;
return compareParts(aParts, bParts);
}
private static int compareParts(int[] a, int[] b) {
for (int i = 0; i < 3; i++) {
int diff = Integer.compare(a[i], b[i]);
if (diff != 0) return diff;
}
return 0;
}
/**
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
@@ -326,7 +259,7 @@ public class UpdateCheckService {
if (wwwAuth == null) return null;
String prefix = "Bearer ";
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
Map<String, String> params = WwwAuthenticate.parseParams(wwwAuth.substring(prefix.length()));
String realm = params.get("realm");
if (realm == null) return null;
StringBuilder url = new StringBuilder(realm);
@@ -359,34 +292,6 @@ public class UpdateCheckService {
}
}
/** Parser minimaliste pour {@code key="value", key2="value2"}. */
private static Map<String, String> parseAuthParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
private static String normalizeRegistry(String value) {
if (value == null || value.isBlank()) return "";
String v = value.trim();

View File

@@ -0,0 +1,46 @@
package com.loremind.infrastructure.updates;
import java.util.HashMap;
import java.util.Map;
/**
* Parseur minimaliste des paramètres d'un challenge HTTP
* {@code WWW-Authenticate: Bearer realm="...", service="...", scope="..."}
* (cf. le flux de token du registry Docker dans {@link UpdateCheckService}).
* <p>
* Accepte les valeurs entre guillemets ({@code key="value"}) comme non quotées
* ({@code key=value}), séparées par des virgules et/ou espaces. Fonction PURE.
*/
final class WwwAuthenticate {
private WwwAuthenticate() {
}
/** Parse {@code key="value", key2=value2} en map clé→valeur (sans les guillemets). */
static Map<String, String> parseParams(String s) {
Map<String, String> out = new HashMap<>();
int i = 0;
int n = s.length();
while (i < n) {
while (i < n && (s.charAt(i) == ',' || s.charAt(i) == ' ')) i++;
int eq = s.indexOf('=', i);
if (eq < 0) break;
String key = s.substring(i, eq).trim();
int valStart = eq + 1;
String val;
if (valStart < n && s.charAt(valStart) == '"') {
int valEnd = s.indexOf('"', valStart + 1);
if (valEnd < 0) break;
val = s.substring(valStart + 1, valEnd);
i = valEnd + 1;
} else {
int valEnd = s.indexOf(',', valStart);
if (valEnd < 0) valEnd = n;
val = s.substring(valStart, valEnd).trim();
i = valEnd;
}
out.put(key, val);
}
return out;
}
}

View File

@@ -9,6 +9,7 @@ import com.loremind.infrastructure.web.dto.generationcontext.ChatMessageDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamCampaignRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamRequestDTO;
import com.loremind.infrastructure.web.dto.generationcontext.ChatStreamSessionRequestDTO;
import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.MediaType;
@@ -162,7 +163,7 @@ public class AiChatController {
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event()
.data("{\"token\":" + jsonEscape(token) + "}"));
.data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException e) {
emitter.completeWithError(e);
}
@@ -182,7 +183,7 @@ public class AiChatController {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event()
.name("error")
.data("{\"message\":" + jsonEscape(message) + "}"));
.data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException ioe) {
emitter.completeWithError(ioe);
@@ -191,31 +192,6 @@ public class AiChatController {
// --- Utilitaires --------------------------------------------------------
/** Encadre une chaîne de guillemets et échappe les caractères JSON dangereux. */
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2);
sb.append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
return sb.toString();
}
private List<ChatMessage> toDomainMessages(List<ChatMessageDTO> dtos) {
if (dtos == null) return List.of();
return dtos.stream()

View File

@@ -5,10 +5,8 @@ import com.loremind.application.gamesystemcontext.GameSystemService;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.gamesystemcontext.RulesImportResult;
import com.loremind.domain.gamesystemcontext.ports.RulesImportException;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.gamesystemcontext.RulesImportResponseDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
import org.slf4j.Logger;
@@ -23,7 +21,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -261,18 +258,11 @@ public class GameSystemController {
dto.getName(),
dto.getDescription(),
dto.getRulesMarkdown(),
toDomainFields(dto.getCharacterTemplate()),
toDomainFields(dto.getNpcTemplate()),
toDomainFields(dto.getEnemyTemplate()),
templateFieldMapper.toDomainList(dto.getCharacterTemplate()),
templateFieldMapper.toDomainList(dto.getNpcTemplate()),
templateFieldMapper.toDomainList(dto.getEnemyTemplate()),
dto.getAuthor(),
dto.isPublic()
);
}
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
return out;
}
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.campaigncontext.Notebook;
import com.loremind.domain.campaigncontext.NotebookSource;
import com.loremind.domain.campaigncontext.ports.NotebookChatStreamer;
import com.loremind.domain.campaigncontext.ports.NotebookException;
import com.loremind.infrastructure.web.sse.SseJson;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.task.TaskExecutor;
import org.springframework.http.HttpStatus;
@@ -211,7 +212,7 @@ public class NotebookController {
private void sendToken(SseEmitter emitter, String token) {
try {
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + jsonEscape(token) + "}"));
emitter.send(SseEmitter.event().name("token").data("{\"token\":" + SseJson.escape(token) + "}"));
} catch (IOException | IllegalStateException e) {
// flux fermé/expiré : on cesse d'écrire
}
@@ -247,32 +248,13 @@ public class NotebookController {
private void fail(SseEmitter emitter, Throwable error) {
try {
String message = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName();
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + jsonEscape(message) + "}"));
emitter.send(SseEmitter.event().name("error").data("{\"message\":" + SseJson.escape(message) + "}"));
emitter.complete();
} catch (IOException | IllegalStateException e) {
// flux déjà fermé/expiré : rien à envoyer
}
}
private String jsonEscape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2).append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.append('"').toString();
}
public record CreateRequest(String campaignId, String name) {}
public record RenameRequest(String name) {}
/**

View File

@@ -1,14 +1,9 @@
package com.loremind.infrastructure.web.mapper;
import com.loremind.domain.gamesystemcontext.GameSystem;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class GameSystemMapper {
@@ -25,9 +20,9 @@ public class GameSystemMapper {
dto.setName(g.getName());
dto.setDescription(g.getDescription());
dto.setRulesMarkdown(g.getRulesMarkdown());
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(toDTOList(g.getEnemyTemplate()));
dto.setCharacterTemplate(fieldMapper.toDTOList(g.getCharacterTemplate()));
dto.setNpcTemplate(fieldMapper.toDTOList(g.getNpcTemplate()));
dto.setEnemyTemplate(fieldMapper.toDTOList(g.getEnemyTemplate()));
dto.setAuthor(g.getAuthor());
dto.setPublic(g.isPublic());
return dto;
@@ -40,25 +35,11 @@ public class GameSystemMapper {
.name(dto.getName())
.description(dto.getDescription())
.rulesMarkdown(dto.getRulesMarkdown())
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
.npcTemplate(toDomainList(dto.getNpcTemplate()))
.enemyTemplate(toDomainList(dto.getEnemyTemplate()))
.characterTemplate(fieldMapper.toDomainList(dto.getCharacterTemplate()))
.npcTemplate(fieldMapper.toDomainList(dto.getNpcTemplate()))
.enemyTemplate(fieldMapper.toDomainList(dto.getEnemyTemplate()))
.author(dto.getAuthor())
.isPublic(dto.isPublic())
.build();
}
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
return out;
}
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
return out;
}
}

View File

@@ -60,4 +60,20 @@ public class TemplateFieldMapper {
}
return new TemplateField(dto.getName(), type, layout, labels);
}
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
public List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
if (fields == null) return new ArrayList<>();
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
for (TemplateField f : fields) out.add(toDTO(f));
return out;
}
/** Mappe une liste de champs DTO → domaine ({@code null} → liste vide). */
public List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
if (dtos == null) return new ArrayList<>();
List<TemplateField> out = new ArrayList<>(dtos.size());
for (TemplateFieldDTO d : dtos) out.add(toDomain(d));
return out;
}
}

View File

@@ -0,0 +1,45 @@
package com.loremind.infrastructure.web.sse;
/**
* Petits utilitaires d'écriture JSON pour les payloads SSE bâtis à la main par
* les controllers de streaming (chat IA, notebook…).
* <p>
* Volontairement minimaliste (pas de Jackson) : les payloads concernés sont des
* objets plats à un ou deux champs ({@code {"token":"…"}}, {@code {"message":"…"}})
* écrits dans la boucle de streaming, où l'on veut éviter l'allocation d'un
* ObjectMapper par token.
*/
public final class SseJson {
private SseJson() {
}
/**
* Encadre {@code raw} de guillemets et échappe les caractères JSON dangereux
* (guillemet, antislash, retours chariot, tabulation, caractères de contrôle).
* Renvoie {@code "\"\""} (chaîne JSON vide) si {@code raw} est {@code null}.
*/
public static String escape(String raw) {
if (raw == null) return "\"\"";
StringBuilder sb = new StringBuilder(raw.length() + 2);
sb.append('"');
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
switch (c) {
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
return sb.toString();
}
}