Compare commits

...

2 Commits

Author SHA1 Message Date
51eb907078 Mise en place de la version 1.0.2-bêta
Some checks failed
Build & Push Images / build (core) (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-07-07 00:12:40 +02:00
9f0359d8c0 Dernière fournée de corrections Sonar
Mise en place de MegaLinter ; web-lint pour la partie code quality sur la CI, Trivy pour la partie CVE
2026-07-07 00:11:52 +02:00
212 changed files with 5350 additions and 2296 deletions

View File

@@ -0,0 +1,77 @@
name: Qualité & Sécurité
# Analyse statique (MegaLinter, config racine .mega-linter.yml) + CVE des
# dépendances (Trivy). Workflow SÉPARÉ de ci.yml : un rouge qualité ne bloque
# pas la chaîne tests → release pendant la phase de rodage. Une fois la base
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
megalinter:
name: MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Historique complet : requis par gitleaks (scan de l'historique)
# et par le mode "fichiers modifiés seulement" en PR.
fetch-depth: 0
# Flavor "cupcake" : image allégée couvrant les langages courants
# (Java/Python/TS inclus). Si un linter activé manquait à la flavor,
# MegaLinter échoue en l'indiquant → remplacer par oxsecurity/megalinter@v9
# (image complète, plus lourde).
- name: MegaLinter
uses: oxsecurity/megalinter/flavors/cupcake@v9
env:
# push sur main → tout le dépôt ; PR → seulement les fichiers modifiés.
VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' }}
- name: Publier les rapports
if: always()
uses: actions/upload-artifact@v4
with:
name: megalinter-reports
path: megalinter-reports/
web-lint:
name: Web (ESLint · angular-eslint + sonarjs)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: web/package-lock.json
- name: npm ci
working-directory: web
run: npm ci --no-audit --no-fund
# Lint via la toolchain du projet (et non via MegaLinter : ESLint a
# besoin des plugins de web/node_modules et du contexte Angular).
# Config + règles : web/eslint.config.js (sonarjs = règles "à la Sonar").
- name: ng lint
working-directory: web
run: npm run lint
trivy:
name: Trivy (CVE dépendances Maven / pip / npm)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Installer Trivy
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
# bloque que sur les vulnérabilités qui ONT un correctif publié.
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
run: |
trivy fs . \
--scanners vuln \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--skip-dirs node_modules \
--skip-dirs docusaurus \
--exit-code 1

37
.mega-linter.yml Normal file
View File

@@ -0,0 +1,37 @@
# Configuration MegaLinter — https://megalinter.io/latest/config-file/
#
# Périmètre volontairement resserré (liste ENABLE_LINTERS exclusive) : on
# active les linters au fil de l'eau plutôt que de subir les ~100 par défaut.
# Le scan CVE des dépendances n'est PAS ici : il est porté par le job Trivy
# (cf. .gitea/workflows/quality.yml).
APPLY_FIXES: none
DEFAULT_BRANCH: main
SHOW_ELAPSED_TIME: true
PRINT_ALPACA: false
# Liste EXCLUSIVE : tout linter absent d'ici est désactivé.
ENABLE_LINTERS:
# --- core (Java) : bugs + code smells "à la Sonar" sur les sources.
# (Sonar for IDE reste pertinent en local pour l'analyse de flux fine.)
- JAVA_PMD
# --- brain (Python) ---
- PYTHON_RUFF # remplace flake8/pylint/isort, très rapide
- PYTHON_BANDIT # sécurité du code Python
# - PYTHON_MYPY # à activer quand le brain aura des annotations de types
# --- repo entier ---
- REPOSITORY_GITLEAKS # secrets committés (tokens, mots de passe…)
- DOCKERFILE_HADOLINT # bonnes pratiques Dockerfile
# --- web (TypeScript) : PAS via MegaLinter. Le lint tourne avec la
# toolchain du projet (job "web-lint" de quality.yml → ng lint,
# config web/eslint.config.js avec angular-eslint + sonarjs).
# Jamais d'analyse des artefacts de build, dépendances et sites docs.
FILTER_REGEX_EXCLUDE: '(^|/)(node_modules|target|dist|coverage|\.angular|\.mvn|docusaurus)/'
# Rapports déposés là où le workflow les publie en artefact CI.
REPORT_OUTPUT_FOLDER: megalinter-reports
# Phase de rodage : passer temporairement à true pour rendre le job
# informatif (rapport sans échec CI) le temps de purger l'existant.
DISABLE_ERRORS: false

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="1.0.1-beta", version="1.0.2-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>1.0.1-beta</version> <version>1.0.2-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -1,45 +0,0 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.quest.Quest;
import com.loremind.domain.campaigncontext.ports.QuestRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.TreeSet;
/**
* Service applicatif : énumère les noms de faits ({@link Prerequisite.FlagSet})
* référencés par les quêtes d'une Campagne.
*
* <p>Modèle "déclaration implicite" : il n'existe pas de table de faits déclarés
* globalement. Un fait existe dès qu'au moins une quête le référence dans ses
* prérequis. Ce service expose la liste dédupliquée pour les UIs (toggle dans
* la Partie, autocomplete dans l'éditeur de prérequis de quête).</p>
*
* <p>Niveau 1 : lit désormais les quêtes (entité de première classe), plus les
* chapitres HUB.</p>
*/
@Service
public class CampaignReferencedFlagsService {
private final QuestRepository questRepository;
public CampaignReferencedFlagsService(QuestRepository questRepository) {
this.questRepository = questRepository;
}
/** Retourne la liste triée alphabétiquement des noms de faits référencés. */
public List<String> listForCampaign(String campaignId) {
TreeSet<String> unique = new TreeSet<>();
for (Quest quest : questRepository.findByCampaignId(campaignId)) {
if (quest.getPrerequisites() == null) continue;
for (Prerequisite p : quest.getPrerequisites()) {
if (p instanceof Prerequisite.FlagSet f && f.flagName() != null && !f.flagName().isBlank()) {
unique.add(f.flagName());
}
}
}
return List.copyOf(unique);
}
}

View File

@@ -11,12 +11,13 @@ import java.util.List;
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) { public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
/** /**
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la * {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le * <p>
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer. * {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : si présent,
* le nœud existe DÉJÀ dans la campagne (rempli côté UI lors de la revue pré-chargée)
* → l'apply ne le recrée pas, il l'utilise comme parent des nouveaux enfants.
* Null = à créer.
*/ */
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
public record ArcProposal( public record ArcProposal(
String name, String description, String type, String name, String description, String type,
List<ChapterProposal> chapters, String existingId) { List<ChapterProposal> chapters, String existingId) {

View File

@@ -2,11 +2,9 @@ package com.loremind.domain.campaigncontext.quest;
/** /**
* Condition de déblocage d'une quête (Chapter dans un Arc HUB). * Condition de déblocage d'une quête (Chapter dans un Arc HUB).
*
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter * Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans * un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
* {@link PrerequisiteEvaluator}. * {@link PrerequisiteEvaluator}.
*
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique * Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
* (pas de OR pour le moment). * (pas de OR pour le moment).
*/ */

View File

@@ -7,7 +7,6 @@ import java.util.Set;
/** /**
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête * Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
* et en dérive le {@link QuestStatus} effectif. * et en dérive le {@link QuestStatus} effectif.
*
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que * NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité * switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
* compile-time est compensée par le throw final qui fait crasher tout nouvel * compile-time est compensée par le throw final qui fait crasher tout nouvel
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
List<Prerequisite> prerequisites, List<Prerequisite> prerequisites,
EvaluationContext ctx EvaluationContext ctx
) { ) {
switch (progression) { return switch (progression) {
case COMPLETED: return QuestStatus.COMPLETED; case COMPLETED -> QuestStatus.COMPLETED;
case IN_PROGRESS: return QuestStatus.IN_PROGRESS; case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
case NOT_STARTED: case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
return areAllSatisfied(prerequisites, ctx) ? QuestStatus.AVAILABLE
? QuestStatus.AVAILABLE : QuestStatus.LOCKED;
: QuestStatus.LOCKED; };
default:
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
}
} }
} }

View File

@@ -2,12 +2,10 @@ package com.loremind.domain.campaigncontext.quest;
/** /**
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ. * Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
*
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED) * NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
* selon les prérequis — voir {@link QuestStatus}. * selon les prérequis — voir {@link QuestStatus}.
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête". * IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
* COMPLETED : marquée terminée par le MJ. * COMPLETED : marquée terminée par le MJ.
*
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible. * NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
*/ */
public enum ProgressionStatus { public enum ProgressionStatus {

View File

@@ -4,7 +4,6 @@ package com.loremind.domain.campaigncontext.quest;
* Statut effectif d'une quête tel qu'affiché dans la vue Hub. * Statut effectif d'une quête tel qu'affiché dans la vue Hub.
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir * DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis. * de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
*
* Table de vérité : * Table de vérité :
* NOT_STARTED + prérequis non remplis -> LOCKED * NOT_STARTED + prérequis non remplis -> LOCKED
* NOT_STARTED + prérequis remplis -> AVAILABLE * NOT_STARTED + prérequis remplis -> AVAILABLE

View File

@@ -158,7 +158,7 @@ public class GameSystem {
} }
private static boolean equalsIgnoreCase(String a, String b) { private static boolean equalsIgnoreCase(String a, String b) {
if (a == null || b == null) return a == b; if (a == null || b == null) return a == null && b == null;
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT)); return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
} }
} }

View File

@@ -26,22 +26,25 @@ import java.util.function.Consumer;
@Component @Component
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor { public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
private static final String ADAPT_PATH = "/adapt/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE = private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final long timeoutSeconds; private final long timeoutSeconds;
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
private final String adaptPath;
public BrainCampaignAdaptClient( public BrainCampaignAdaptClient(
WebClient.Builder webClientBuilder, WebClient.Builder webClientBuilder,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl, @Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds,
@Value("${brain.paths.adapt-campaign:/adapt/campaign/stream}") String adaptPath) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds; this.timeoutSeconds = timeoutSeconds;
this.adaptPath = adaptPath;
} }
@Override @Override
@@ -65,7 +68,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson); parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(ADAPT_PATH) .uri(adaptPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)

View File

@@ -36,24 +36,37 @@ import java.util.function.Consumer;
@Component @Component
public class BrainCampaignImportClient implements CampaignPdfImporter { public class BrainCampaignImportClient implements CampaignPdfImporter {
private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE = private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
/** Champ JSON répété du proposal d'arbre (arc/chapitre/scène/salle/PNJ). */
private static final String FIELD_DESCRIPTION = "description";
private final WebClient webClient; private final WebClient webClient;
private final BrainSseImportSupport sse; private final BrainSseImportSupport sse;
private final long importTimeoutSeconds; private final long importTimeoutSeconds;
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
private final String importCampaignStreamPath;
public BrainCampaignImportClient( public BrainCampaignImportClient(
WebClient.Builder webClientBuilder, WebClient.Builder webClientBuilder,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl, @Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.paths.import-campaign:/import/campaign/stream}") String importCampaignStreamPath) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.sse = new BrainSseImportSupport(objectMapper); this.sse = new BrainSseImportSupport(objectMapper);
this.importTimeoutSeconds = importTimeoutSeconds; this.importTimeoutSeconds = importTimeoutSeconds;
this.importCampaignStreamPath = importCampaignStreamPath;
} }
/** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */
private record ImportCallbacks(
Consumer<CampaignImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {}
@Override @Override
public void importCampaignStreaming( public void importCampaignStreaming(
byte[] pdfBytes, byte[] pdfBytes,
@@ -69,7 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename); .filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_CAMPAIGN_STREAM_PATH) .uri(importCampaignStreamPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
@@ -80,12 +93,11 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] pageCount = {0}; int[] pageCount = {0};
int[] ocrPageCount = {0}; int[] ocrPageCount = {0};
boolean[] terminated = {false}; boolean[] terminated = {false};
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
sse.runStream( sse.runStream(
flux, importTimeoutSeconds, terminated, flux, importTimeoutSeconds, terminated,
event -> handleEvent( event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, CampaignImportException::new); onError, CampaignImportException::new);
} }
@@ -94,11 +106,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
int[] pageCount, int[] pageCount,
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<CampaignImportProgress> onProgress, ImportCallbacks callbacks) {
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<CampaignImportProposal> onDone,
Consumer<Throwable> onError) {
String event = ssEvent.event(); String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data(); String data = ssEvent.data() == null ? "" : ssEvent.data();
@@ -106,27 +114,27 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("heartbeat".equals(event)) { if ("heartbeat".equals(event)) {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front. // navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
onHeartbeat.run(); callbacks.onHeartbeat().run();
return; return;
} }
if ("status".equals(event)) { if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau // Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(sse.readMessage(data)); callbacks.onStatus().accept(sse.readMessage(data));
return; return;
} }
if ("chunk_failed".equals(event)) { if ("chunk_failed".equals(event)) {
onStatus.accept(sse.chunkFailedStatus(data)); callbacks.onStatus().accept(sse.chunkFailedStatus(data));
return; return;
} }
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new CampaignImportException( callbacks.onError().accept(new CampaignImportException(
"Le Brain a signalé une erreur : " + sse.readMessage(data))); "Le Brain a signalé une erreur : " + sse.readMessage(data)));
return; return;
} }
if ("extracting".equals(event)) { if ("extracting".equals(event)) {
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0)); callbacks.onProgress().accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
return; return;
} }
@@ -136,10 +144,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if ("start".equals(event)) { if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt(); pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt(); ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new CampaignImportProgress( callbacks.onProgress().accept(new CampaignImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0)); 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
} else if ("progress".equals(event)) { } else if ("progress".equals(event)) {
onProgress.accept(new CampaignImportProgress( callbacks.onProgress().accept(new CampaignImportProgress(
node.path("current").asInt(), node.path("current").asInt(),
node.path("total").asInt(), node.path("total").asInt(),
pageCount[0], pageCount[0],
@@ -150,7 +158,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
node.path("npc_count").asInt())); node.path("npc_count").asInt()));
} else if ("done".equals(event)) { } else if ("done".equals(event)) {
terminated[0] = true; terminated[0] = true;
onDone.accept(new CampaignImportProposal( callbacks.onDone().accept(new CampaignImportProposal(
toArcs(node.path("arcs")), toNpcs(node.path("npcs")))); toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
} }
} }
@@ -163,7 +171,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
for (JsonNode arc : arcsNode) { for (JsonNode arc : arcsNode) {
arcs.add(new ArcProposal( arcs.add(new ArcProposal(
text(arc, "name"), text(arc, "name"),
text(arc, "description"), text(arc, FIELD_DESCRIPTION),
text(arc, "type"), text(arc, "type"),
toChapters(arc.path("chapters")), toChapters(arc.path("chapters")),
null)); null));
@@ -178,7 +186,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
for (JsonNode ch : chaptersNode) { for (JsonNode ch : chaptersNode) {
chapters.add(new ChapterProposal( chapters.add(new ChapterProposal(
text(ch, "name"), text(ch, "name"),
text(ch, "description"), text(ch, FIELD_DESCRIPTION),
toScenes(ch.path("scenes")), toScenes(ch.path("scenes")),
null)); null));
} }
@@ -191,7 +199,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if (scenesNode != null && scenesNode.isArray()) { if (scenesNode != null && scenesNode.isArray()) {
for (JsonNode sc : scenesNode) { for (JsonNode sc : scenesNode) {
scenes.add(new SceneProposal( scenes.add(new SceneProposal(
text(sc, "name"), text(sc, "description"), text(sc, "name"), text(sc, FIELD_DESCRIPTION),
text(sc, "player_narration"), text(sc, "gm_notes"), text(sc, "player_narration"), text(sc, "gm_notes"),
toRooms(sc.path("rooms")), null)); toRooms(sc.path("rooms")), null));
} }
@@ -204,7 +212,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
if (roomsNode != null && roomsNode.isArray()) { if (roomsNode != null && roomsNode.isArray()) {
for (JsonNode rm : roomsNode) { for (JsonNode rm : roomsNode) {
rooms.add(new RoomProposal( rooms.add(new RoomProposal(
text(rm, "name"), text(rm, "description"), text(rm, "name"), text(rm, FIELD_DESCRIPTION),
text(rm, "enemies"), text(rm, "loot"))); text(rm, "enemies"), text(rm, "loot")));
} }
} }
@@ -215,7 +223,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
List<NpcProposal> npcs = new ArrayList<>(); List<NpcProposal> npcs = new ArrayList<>();
if (npcsNode != null && npcsNode.isArray()) { if (npcsNode != null && npcsNode.isArray()) {
for (JsonNode n : npcsNode) { for (JsonNode n : npcsNode) {
npcs.add(new NpcProposal(text(n, "name"), text(n, "description"))); npcs.add(new NpcProposal(text(n, "name"), text(n, FIELD_DESCRIPTION)));
} }
} }
return npcs; return npcs;

View File

@@ -24,7 +24,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.BiConsumer; import java.util.function.BiConsumer;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.function.ToIntFunction;
/** /**
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON * Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
@@ -40,11 +40,14 @@ import java.util.stream.Collectors;
@Component @Component
public class BrainChatPayloadBuilder { public class BrainChatPayloadBuilder {
private static final String KEY_DESCRIPTION = "description";
private static final String KEY_TITLE = "title";
public Map<String, Object> build(ChatRequest request) { public Map<String, Object> build(ChatRequest request) {
Map<String, Object> root = new LinkedHashMap<>(); Map<String, Object> root = new LinkedHashMap<>();
root.put("messages", request.messages().stream() root.put("messages", request.messages().stream()
.map(this::messageToMap) .map(this::messageToMap)
.collect(Collectors.toList())); .toList());
if (request.loreContext() != null) { if (request.loreContext() != null) {
root.put("lore_context", loreContextToMap(request.loreContext())); root.put("lore_context", loreContextToMap(request.loreContext()));
@@ -75,25 +78,25 @@ public class BrainChatPayloadBuilder {
map.put("started_at", sc.startedAt().toString()); map.put("started_at", sc.startedAt().toString());
} }
map.put("entries", sc.entries() != null map.put("entries", sc.entries() != null
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList()) ? sc.entries().stream().map(this::journalEntryToMap).toList()
: List.of()); : List.of());
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session). // Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) { if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
map.put("previous_events", sc.previousEvents().stream() map.put("previous_events", sc.previousEvents().stream()
.map(this::journalEntryToMap) .map(this::journalEntryToMap)
.collect(Collectors.toList())); .toList());
} }
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas // État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
// saturer le prompt sur les campagnes sans Hub. // saturer le prompt sur les campagnes sans Hub.
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) { if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
map.put("available_quests", sc.availableQuests().stream() map.put("available_quests", sc.availableQuests().stream()
.map(this::questSummaryToMap) .map(this::questSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) { if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
map.put("in_progress_quests", sc.inProgressQuests().stream() map.put("in_progress_quests", sc.inProgressQuests().stream()
.map(this::questSummaryToMap) .map(this::questSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) { if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
map.put("locked_quest_titles", sc.lockedQuestTitles()); map.put("locked_quest_titles", sc.lockedQuestTitles());
@@ -108,7 +111,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", q.name()); map.put("name", q.name());
map.put("arc_name", q.arcName()); map.put("arc_name", q.arcName());
putIfText(map, "description", q.description()); putIfText(map, KEY_DESCRIPTION, q.description());
return map; return map;
} }
@@ -147,7 +150,7 @@ public class BrainChatPayloadBuilder {
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) { for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
foldersMap.put(e.getKey(), e.getValue().stream() foldersMap.put(e.getKey(), e.getValue().stream()
.map(this::pageSummaryToMap) .map(this::pageSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
map.put("folders", foldersMap); map.put("folders", foldersMap);
map.put("tags", ctx.tags()); map.put("tags", ctx.tags());
@@ -156,7 +159,7 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> pageSummaryToMap(PageSummary ps) { private Map<String, Object> pageSummaryToMap(PageSummary ps) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("title", ps.title()); map.put(KEY_TITLE, ps.title());
map.put("template_name", ps.templateName()); map.put("template_name", ps.templateName());
// values/tags/related_page_titles : omis si vides pour alléger le payload. // values/tags/related_page_titles : omis si vides pour alléger le payload.
if (ps.values() != null && !ps.values().isEmpty()) { if (ps.values() != null && !ps.values().isEmpty()) {
@@ -173,7 +176,7 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> pageContextToMap(PageContext pc) { private Map<String, Object> pageContextToMap(PageContext pc) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("title", pc.title()); map.put(KEY_TITLE, pc.title());
map.put("template_name", pc.templateName()); map.put("template_name", pc.templateName());
map.put("template_fields", pc.templateFields()); map.put("template_fields", pc.templateFields());
map.put("values", pc.values()); map.put("values", pc.values());
@@ -186,18 +189,18 @@ public class BrainChatPayloadBuilder {
map.put("campaign_description", ctx.campaignDescription()); map.put("campaign_description", ctx.campaignDescription());
map.put("arcs", ctx.arcs().stream() map.put("arcs", ctx.arcs().stream()
.map(this::arcSummaryToMap) .map(this::arcSummaryToMap)
.collect(Collectors.toList())); .toList());
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches. // Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
if (ctx.characters() != null && !ctx.characters().isEmpty()) { if (ctx.characters() != null && !ctx.characters().isEmpty()) {
map.put("characters", ctx.characters().stream() map.put("characters", ctx.characters().stream()
.map(this::characterSummaryToMap) .map(this::characterSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload. // Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) { if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
map.put("npcs", ctx.npcs().stream() map.put("npcs", ctx.npcs().stream()
.map(this::npcSummaryToMap) .map(this::npcSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
return map; return map;
} }
@@ -224,13 +227,14 @@ public class BrainChatPayloadBuilder {
T entity, T entity,
Function<T, String> nameExtractor, Function<T, String> nameExtractor,
Function<T, String> descriptionExtractor, Function<T, String> descriptionExtractor,
Function<T, Integer> illustrationCountExtractor, ToIntFunction<T> illustrationCountExtractor,
BiConsumer<Map<String, Object>, T> childSerializer) { BiConsumer<Map<String, Object>, T> childSerializer) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", nameExtractor.apply(entity)); map.put("name", nameExtractor.apply(entity));
map.put("description", descriptionExtractor.apply(entity)); map.put(KEY_DESCRIPTION, descriptionExtractor.apply(entity));
if (illustrationCountExtractor.apply(entity) > 0) { int illustrationCount = illustrationCountExtractor.applyAsInt(entity);
map.put("illustration_count", illustrationCountExtractor.apply(entity)); if (illustrationCount > 0) {
map.put("illustration_count", illustrationCount);
} }
childSerializer.accept(map, entity); childSerializer.accept(map, entity);
return map; return map;
@@ -249,7 +253,7 @@ public class BrainChatPayloadBuilder {
} }
map.put("chapters", arc.chapters().stream() map.put("chapters", arc.chapters().stream()
.map(this::chapterSummaryToMap) .map(this::chapterSummaryToMap)
.collect(Collectors.toList())); .toList());
}); });
} }
@@ -261,7 +265,7 @@ public class BrainChatPayloadBuilder {
ChapterSummary::illustrationCount, ChapterSummary::illustrationCount,
(map, chapter) -> map.put("scenes", chapter.scenes().stream() (map, chapter) -> map.put("scenes", chapter.scenes().stream()
.map(this::sceneSummaryToMap) .map(this::sceneSummaryToMap)
.collect(Collectors.toList()))); .toList()));
} }
private Map<String, Object> sceneSummaryToMap(SceneSummary s) { private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
@@ -275,13 +279,13 @@ public class BrainChatPayloadBuilder {
if (s.branches() != null && !s.branches().isEmpty()) { if (s.branches() != null && !s.branches().isEmpty()) {
map.put("branches", s.branches().stream() map.put("branches", s.branches().stream()
.map(this::branchHintToMap) .map(this::branchHintToMap)
.collect(Collectors.toList())); .toList());
} }
// Pièces du lieu explorable : omises si scène classique. // Pièces du lieu explorable : omises si scène classique.
if (s.rooms() != null && !s.rooms().isEmpty()) { if (s.rooms() != null && !s.rooms().isEmpty()) {
map.put("rooms", s.rooms().stream() map.put("rooms", s.rooms().stream()
.map(this::roomSummaryToMap) .map(this::roomSummaryToMap)
.collect(Collectors.toList())); .toList());
} }
}); });
} }
@@ -298,7 +302,7 @@ public class BrainChatPayloadBuilder {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("name", r.name()); map.put("name", r.name());
if (r.floor() != null) map.put("floor", r.floor()); if (r.floor() != null) map.put("floor", r.floor());
putIfText(map, "description", r.description()); putIfText(map, KEY_DESCRIPTION, r.description());
putIfText(map, "enemies", r.enemies()); putIfText(map, "enemies", r.enemies());
if (r.branches() != null && !r.branches().isEmpty()) { if (r.branches() != null && !r.branches().isEmpty()) {
map.put("branches", r.branches().stream().map(b -> { map.put("branches", r.branches().stream().map(b -> {
@@ -307,7 +311,7 @@ public class BrainChatPayloadBuilder {
bm.put("target_room_name", b.targetRoomName()); bm.put("target_room_name", b.targetRoomName());
putIfText(bm, "condition", b.condition()); putIfText(bm, "condition", b.condition());
return bm; return bm;
}).collect(Collectors.toList())); }).toList());
} }
return map; return map;
} }
@@ -315,7 +319,7 @@ public class BrainChatPayloadBuilder {
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) { private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("entity_type", ne.entityType()); map.put("entity_type", ne.entityType());
map.put("title", ne.title()); map.put(KEY_TITLE, ne.title());
map.put("fields", ne.fields()); map.put("fields", ne.fields());
return map; return map;
} }

View File

@@ -12,7 +12,6 @@ import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour * Adaptateur : appelle le Brain POST /summarize/conversation-title pour
@@ -45,7 +44,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
.map(m -> Map.<String, Object>of( .map(m -> Map.<String, Object>of(
"role", m.getRole(), "role", m.getRole(),
"content", m.getContent() == null ? "" : m.getContent())) "content", m.getContent() == null ? "" : m.getContent()))
.collect(Collectors.toList())); .toList());
try { try {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@@ -24,6 +24,7 @@ import java.util.Map;
public class BrainItemCatalogClient implements ItemCatalogGenerator { public class BrainItemCatalogClient implements ItemCatalogGenerator {
private static final String GENERATE_PATH = "/generate/item-catalog"; private static final String GENERATE_PATH = "/generate/item-catalog";
private static final String KEY_DESCRIPTION = "description";
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final String baseUrl; private final String baseUrl;
@@ -36,10 +37,25 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
} }
@Override @Override
@SuppressWarnings("unchecked")
public GeneratedCatalog generate(String description, String context) { public GeneratedCatalog generate(String description, String context) {
Map<String, Object> resp = callBrain(description, context);
List<CatalogItem> items = parseItems(resp.get("items"));
if (items.isEmpty()) {
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
}
String name = asString(resp.get("name"));
return new GeneratedCatalog(
name != null && !name.isBlank() ? name : description,
asString(resp.get(KEY_DESCRIPTION)),
items);
}
/** POST one-shot vers le Brain ; toute erreur devient une ItemCatalogGenerationException parlante. */
@SuppressWarnings("unchecked")
private Map<String, Object> callBrain(String description, String context) {
Map<String, Object> req = new LinkedHashMap<>(); Map<String, Object> req = new LinkedHashMap<>();
req.put("description", description == null ? "" : description); req.put(KEY_DESCRIPTION, description == null ? "" : description);
req.put("context", context == null ? "" : context); req.put("context", context == null ? "" : context);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
@@ -60,30 +76,38 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
if (resp == null) { if (resp == null) {
throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide."); throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide.");
} }
return resp;
}
/** Items valides de la réponse (entrées malformées ou sans nom ignorées). */
private static List<CatalogItem> parseItems(Object rawItems) {
List<CatalogItem> items = new ArrayList<>(); List<CatalogItem> items = new ArrayList<>();
Object rawItems = resp.get("items");
if (rawItems instanceof List<?> list) { if (rawItems instanceof List<?> list) {
for (Object item : list) { for (Object raw : list) {
if (!(item instanceof Map<?, ?> m)) continue; CatalogItem item = toItem(raw);
String name = asString(m.get("name")); if (item != null) {
if (name == null || name.isBlank()) continue; items.add(item);
items.add(CatalogItem.builder() }
.name(name)
.price(asString(m.get("price")))
.category(asString(m.get("category")))
.description(asString(m.get("description")))
.build());
} }
} }
if (items.isEmpty()) { return items;
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule."); }
/** Un CatalogItem depuis une entrée brute, ou null si malformée / sans nom. */
private static CatalogItem toItem(Object raw) {
if (!(raw instanceof Map<?, ?> m)) {
return null;
} }
String name = asString(resp.get("name")); String name = asString(m.get("name"));
return new GeneratedCatalog( if (name == null || name.isBlank()) {
name != null && !name.isBlank() ? name : description, return null;
asString(resp.get("description")), }
items); return CatalogItem.builder()
.name(name)
.price(asString(m.get("price")))
.category(asString(m.get("category")))
.description(asString(m.get(KEY_DESCRIPTION)))
.build();
} }
private static String asString(Object o) { private static String asString(Object o) {

View File

@@ -39,20 +39,9 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
} }
@Override @Override
@SuppressWarnings("unchecked")
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) { public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
List<Map<String, String>> fieldPayload = new ArrayList<>(); List<Map<String, String>> fieldPayload = buildFieldPayload(fields);
Set<String> allowed = new HashSet<>(); Set<String> allowed = allowedKeys(fields);
if (fields != null) {
for (FieldSpec f : fields) {
if (f == null || f.key() == null) continue;
allowed.add(f.key());
Map<String, String> fm = new LinkedHashMap<>();
fm.put("key", f.key());
fm.put("label", f.label() == null ? f.key() : f.label());
fieldPayload.add(fm);
}
}
Map<String, Object> req = new LinkedHashMap<>(); Map<String, Object> req = new LinkedHashMap<>();
req.put("entity_type", entityType == null ? "" : entityType); req.put("entity_type", entityType == null ? "" : entityType);
@@ -60,6 +49,38 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
req.put("instruction", instruction == null ? "" : instruction); req.put("instruction", instruction == null ? "" : instruction);
req.put("fields", fieldPayload); req.put("fields", fieldPayload);
Map<String, Object> resp = callBrain(req);
return parseProposedFields(resp.get("fields"), allowed);
}
/** Payload des champs à étoffer : {key, label} par champ valide (libellé = clé à défaut). */
private static List<Map<String, String>> buildFieldPayload(List<FieldSpec> fields) {
List<Map<String, String>> payload = new ArrayList<>();
for (FieldSpec f : nullSafe(fields)) {
if (f != null && f.key() != null) {
Map<String, String> fm = new LinkedHashMap<>();
fm.put("key", f.key());
fm.put("label", f.label() == null ? f.key() : f.label());
payload.add(fm);
}
}
return payload;
}
/** Whitelist des clés autorisées en retour (garde-fou contre les clés inventées). */
private static Set<String> allowedKeys(List<FieldSpec> fields) {
Set<String> allowed = new HashSet<>();
for (FieldSpec f : nullSafe(fields)) {
if (f != null && f.key() != null) {
allowed.add(f.key());
}
}
return allowed;
}
/** POST one-shot vers le Brain ; toute erreur devient une NarrativeAssistException parlante. */
@SuppressWarnings("unchecked")
private Map<String, Object> callBrain(Map<String, Object> req) {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
@@ -78,18 +99,37 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
if (resp == null) { if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
} }
return resp;
}
/** Champs proposés retenus : clé dans la whitelist ET valeur non vide (trim). */
private static List<ProposedField> parseProposedFields(Object rawFields, Set<String> allowed) {
List<ProposedField> out = new ArrayList<>(); List<ProposedField> out = new ArrayList<>();
Object rawFields = resp.get("fields");
if (rawFields instanceof Map<?, ?> m) { if (rawFields instanceof Map<?, ?> m) {
for (Map.Entry<?, ?> e : m.entrySet()) { for (Map.Entry<?, ?> e : m.entrySet()) {
String key = e.getKey() == null ? null : e.getKey().toString(); ProposedField field = toProposedField(e, allowed);
if (key == null || !allowed.contains(key)) continue; // whitelist stricte if (field != null) {
String value = e.getValue() == null ? null : e.getValue().toString(); out.add(field);
if (value == null || value.isBlank()) continue; // pas de remplissage vide }
out.add(new ProposedField(key, value.trim()));
} }
} }
return out; return out;
} }
/** Un ProposedField depuis une entrée brute, ou null si clé hors whitelist / valeur vide. */
private static ProposedField toProposedField(Map.Entry<?, ?> e, Set<String> allowed) {
String key = e.getKey() == null ? null : e.getKey().toString();
if (key == null || !allowed.contains(key)) {
return null; // whitelist stricte
}
String value = e.getValue() == null ? null : e.getValue().toString();
if (value == null || value.isBlank()) {
return null; // pas de remplissage vide
}
return new ProposedField(key, value.trim());
}
private static List<FieldSpec> nullSafe(List<FieldSpec> fields) {
return fields != null ? fields : List.of();
}
} }

View File

@@ -17,7 +17,6 @@ import java.time.Duration;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
/** /**
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient). * Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
@@ -26,23 +25,28 @@ import java.util.stream.Collectors;
@Component @Component
public class BrainNotebookChatClient implements NotebookChatStreamer { public class BrainNotebookChatClient implements NotebookChatStreamer {
private static final String PATH = "/chat/notebook/stream";
private static final String DEEP_PATH = "/chat/notebook/deep/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE = private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final long timeoutSeconds; private final long timeoutSeconds;
// Routes du Brain surchargeables par config (défauts = contrat d'API actuel).
private final String chatPath;
private final String deepChatPath;
public BrainNotebookChatClient( public BrainNotebookChatClient(
WebClient.Builder webClientBuilder, WebClient.Builder webClientBuilder,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl, @Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long timeoutSeconds,
@Value("${brain.paths.notebook-chat:/chat/notebook/stream}") String chatPath,
@Value("${brain.paths.notebook-chat-deep:/chat/notebook/deep/stream}") String deepChatPath) {
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.timeoutSeconds = timeoutSeconds; this.timeoutSeconds = timeoutSeconds;
this.chatPath = chatPath;
this.deepChatPath = deepChatPath;
} }
@Override @Override
@@ -57,11 +61,11 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
payload.put("source_ids", sourceIds); payload.put("source_ids", sourceIds);
payload.put("messages", messages.stream() payload.put("messages", messages.stream()
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content())) .map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
.collect(Collectors.toList())); .toList());
payload.put("context", context == null ? "" : context); payload.put("context", context == null ? "" : context);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(deep ? DEEP_PATH : PATH) .uri(deep ? deepChatPath : chatPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)

View File

@@ -55,7 +55,7 @@ public class BrainNotebookIndexClient implements NotebookIndexer {
if (resp == null) { if (resp == null) {
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation."); throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
} }
return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount); return new IndexResult(resp.getChunks(), resp.getPageCount(), resp.getOcrPageCount());
} catch (ResourceAccessException e) { } catch (ResourceAccessException e) {
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e); throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
} catch (RestClientResponseException e) { } catch (RestClientResponseException e) {
@@ -89,12 +89,22 @@ public class BrainNotebookIndexClient implements NotebookIndexer {
}; };
} }
/** Réponse JSON du Brain (snake_case). */ /**
* Réponse JSON du Brain (snake_case). Champs privés + @JsonProperty explicite
* sur CHAQUE champ : Jackson n'auto-détecte que les champs publics par défaut,
* l'annotation reste nécessaire pour que la désérialisation continue de fonctionner
* une fois les champs rendus privés.
*/
private static class IndexResponse { private static class IndexResponse {
public int chunks; @JsonProperty("chunks")
private int chunks;
@JsonProperty("page_count") @JsonProperty("page_count")
public int pageCount; private int pageCount;
@JsonProperty("ocr_page_count") @JsonProperty("ocr_page_count")
public int ocrPageCount; private int ocrPageCount;
int getChunks() { return chunks; }
int getPageCount() { return pageCount; }
int getOcrPageCount() { return ocrPageCount; }
} }
} }

View File

@@ -49,23 +49,7 @@ public class BrainRandomTableClient implements RandomTableGenerator {
if (resp == null) { if (resp == null) {
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide."); throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
} }
List<RandomTableEntry> entries = new ArrayList<>(); List<RandomTableEntry> entries = parseEntries(resp.get("entries"));
Object rawEntries = resp.get("entries");
if (rawEntries instanceof List<?> list) {
for (Object item : list) {
if (!(item instanceof Map<?, ?> m)) continue;
Integer min = asInt(m.get("min_roll"));
Integer max = asInt(m.get("max_roll"));
String label = asString(m.get("label"));
if (min == null || max == null || label == null || label.isBlank()) continue;
entries.add(RandomTableEntry.builder()
.minRoll(min)
.maxRoll(Math.max(min, max))
.label(label)
.detail(asString(m.get("detail")))
.build());
}
}
if (entries.isEmpty()) { if (entries.isEmpty()) {
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule."); throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
} }
@@ -76,6 +60,39 @@ public class BrainRandomTableClient implements RandomTableGenerator {
entries); entries);
} }
/** Entrées valides de la réponse (entrées malformées ou incomplètes ignorées). */
private static List<RandomTableEntry> parseEntries(Object rawEntries) {
List<RandomTableEntry> entries = new ArrayList<>();
if (rawEntries instanceof List<?> list) {
for (Object raw : list) {
RandomTableEntry entry = toEntry(raw);
if (entry != null) {
entries.add(entry);
}
}
}
return entries;
}
/** Une RandomTableEntry depuis une entrée brute, ou null si malformée / bornes ou label absents. */
private static RandomTableEntry toEntry(Object raw) {
if (!(raw instanceof Map<?, ?> m)) {
return null;
}
Integer min = asInt(m.get("min_roll"));
Integer max = asInt(m.get("max_roll"));
String label = asString(m.get("label"));
if (min == null || max == null || label == null || label.isBlank()) {
return null;
}
return RandomTableEntry.builder()
.minRoll(min)
.maxRoll(Math.max(min, max))
.label(label)
.detail(asString(m.get("detail")))
.build();
}
@Override @Override
public String improvise(String tableName, String resultLabel, String resultDetail, String context) { public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
Map<String, Object> req = new LinkedHashMap<>(); Map<String, Object> req = new LinkedHashMap<>();

View File

@@ -47,30 +47,45 @@ import java.util.function.Consumer;
@Component @Component
public class BrainRulesImportClient implements RulesPdfImporter { public class BrainRulesImportClient implements RulesPdfImporter {
private static final String IMPORT_RULES_PATH = "/import/rules";
private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream";
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE = private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
new ParameterizedTypeReference<>() {}; new ParameterizedTypeReference<>() {};
/** Nom de fichier par défaut du PDF de règles (filename absent/vide). */
private static final String DEFAULT_FILENAME = "rules.pdf";
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final WebClient webClient; private final WebClient webClient;
private final BrainSseImportSupport sse; private final BrainSseImportSupport sse;
private final String baseUrl; private final String baseUrl;
private final long importTimeoutSeconds; private final long importTimeoutSeconds;
// Routes du Brain surchargeables par config (défauts = contrat d'API actuel).
private final String importRulesPath;
private final String importRulesStreamPath;
public BrainRulesImportClient( public BrainRulesImportClient(
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate, @Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
WebClient.Builder webClientBuilder, WebClient.Builder webClientBuilder,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${brain.base-url}") String baseUrl, @Value("${brain.base-url}") String baseUrl,
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) { @Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
@Value("${brain.paths.import-rules:/import/rules}") String importRulesPath,
@Value("${brain.paths.import-rules-stream:/import/rules/stream}") String importRulesStreamPath) {
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.webClient = webClientBuilder.baseUrl(baseUrl).build(); this.webClient = webClientBuilder.baseUrl(baseUrl).build();
this.sse = new BrainSseImportSupport(objectMapper); this.sse = new BrainSseImportSupport(objectMapper);
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.importTimeoutSeconds = importTimeoutSeconds; this.importTimeoutSeconds = importTimeoutSeconds;
this.importRulesPath = importRulesPath;
this.importRulesStreamPath = importRulesStreamPath;
} }
/** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */
private record ImportCallbacks(
Consumer<RulesImportProgress> onProgress,
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {}
// --- One-shot (bloquant) ------------------------------------------------- // --- One-shot (bloquant) -------------------------------------------------
@Override @Override
@@ -79,12 +94,12 @@ public class BrainRulesImportClient implements RulesPdfImporter {
headers.setContentType(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>(); MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf")); body.add("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME));
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers); HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
try { try {
BrainRulesImportResponse response = restTemplate.postForObject( BrainRulesImportResponse response = restTemplate.postForObject(
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class); baseUrl + importRulesPath, entity, BrainRulesImportResponse.class);
if (response == null || response.getSections() == null) { if (response == null || response.getSections() == null) {
throw new RulesImportException("Le Brain a renvoyé une réponse vide."); throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
} }
@@ -119,11 +134,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
Consumer<Throwable> onError) { Consumer<Throwable> onError) {
MultipartBodyBuilder parts = new MultipartBodyBuilder(); MultipartBodyBuilder parts = new MultipartBodyBuilder();
parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf")) parts.part("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME))
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename); .filename(filename == null || filename.isBlank() ? DEFAULT_FILENAME : filename);
Flux<ServerSentEvent<String>> flux = webClient.post() Flux<ServerSentEvent<String>> flux = webClient.post()
.uri(IMPORT_RULES_STREAM_PATH) .uri(importRulesStreamPath)
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get()) .header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
.contentType(MediaType.MULTIPART_FORM_DATA) .contentType(MediaType.MULTIPART_FORM_DATA)
.accept(MediaType.TEXT_EVENT_STREAM) .accept(MediaType.TEXT_EVENT_STREAM)
@@ -136,12 +151,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] pageCount = {0}; int[] pageCount = {0};
int[] ocrPageCount = {0}; int[] ocrPageCount = {0};
boolean[] terminated = {false}; boolean[] terminated = {false};
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
sse.runStream( sse.runStream(
flux, importTimeoutSeconds, terminated, flux, importTimeoutSeconds, terminated,
event -> handleEvent( event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
event, pageCount, ocrPageCount, terminated,
onProgress, onHeartbeat, onStatus, onDone, onError),
onError, RulesImportException::new); onError, RulesImportException::new);
} }
@@ -150,11 +164,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
int[] pageCount, int[] pageCount,
int[] ocrPageCount, int[] ocrPageCount,
boolean[] terminated, boolean[] terminated,
Consumer<RulesImportProgress> onProgress, ImportCallbacks callbacks) {
Runnable onHeartbeat,
Consumer<String> onStatus,
Consumer<RulesImportResult> onDone,
Consumer<Throwable> onError) {
String event = ssEvent.event(); String event = ssEvent.event();
String data = ssEvent.data() == null ? "" : ssEvent.data(); String data = ssEvent.data() == null ? "" : ssEvent.data();
@@ -163,28 +173,28 @@ public class BrainRulesImportClient implements RulesPdfImporter {
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au // Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front // navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
// resté silencieux pendant tout le traitement du morceau. // resté silencieux pendant tout le traitement du morceau.
onHeartbeat.run(); callbacks.onHeartbeat().run();
return; return;
} }
if ("status".equals(event)) { if ("status".equals(event)) {
// Message d'attente lisible (retry sur fournisseur saturé, morceau // Message d'attente lisible (retry sur fournisseur saturé, morceau
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs. // re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
onStatus.accept(sse.readMessage(data)); callbacks.onStatus().accept(sse.readMessage(data));
return; return;
} }
if ("chunk_failed".equals(event)) { if ("chunk_failed".equals(event)) {
onStatus.accept(sse.chunkFailedStatus(data)); callbacks.onStatus().accept(sse.chunkFailedStatus(data));
return; return;
} }
if ("error".equals(event)) { if ("error".equals(event)) {
terminated[0] = true; terminated[0] = true;
onError.accept(new RulesImportException( callbacks.onError().accept(new RulesImportException(
"Le Brain a signalé une erreur : " + sse.readMessage(data))); "Le Brain a signalé une erreur : " + sse.readMessage(data)));
return; return;
} }
if ("extracting".equals(event)) { if ("extracting".equals(event)) {
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…". // Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of())); callbacks.onProgress().accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
return; return;
} }
@@ -194,10 +204,10 @@ public class BrainRulesImportClient implements RulesPdfImporter {
if ("start".equals(event)) { if ("start".equals(event)) {
pageCount[0] = node.path("page_count").asInt(); pageCount[0] = node.path("page_count").asInt();
ocrPageCount[0] = node.path("ocr_page_count").asInt(); ocrPageCount[0] = node.path("ocr_page_count").asInt();
onProgress.accept(new RulesImportProgress( callbacks.onProgress().accept(new RulesImportProgress(
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of())); 0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
} else if ("progress".equals(event)) { } else if ("progress".equals(event)) {
onProgress.accept(new RulesImportProgress( callbacks.onProgress().accept(new RulesImportProgress(
node.path("current").asInt(), node.path("current").asInt(),
node.path("total").asInt(), node.path("total").asInt(),
pageCount[0], pageCount[0],
@@ -205,7 +215,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
toStringList(node.path("new_sections")))); toStringList(node.path("new_sections"))));
} else if ("done".equals(event)) { } else if ("done".equals(event)) {
terminated[0] = true; terminated[0] = true;
onDone.accept(new RulesImportResult( callbacks.onDone().accept(new RulesImportResult(
toStringMap(node.path("sections")), toStringMap(node.path("sections")),
node.path("page_count").asInt(), node.path("page_count").asInt(),
node.path("ocr_page_count").asInt())); node.path("ocr_page_count").asInt()));

View File

@@ -62,23 +62,38 @@ public class BrainSceneDraftClient implements SceneDraftAssistant {
if (resp == null) { if (resp == null) {
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide."); throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
} }
return parseScenes(resp.get("scenes"));
}
/** Ébauches valides de la réponse (entrées malformées ou sans titre ignorées). */
private static List<SceneDraft> parseScenes(Object rawScenes) {
List<SceneDraft> out = new ArrayList<>(); List<SceneDraft> out = new ArrayList<>();
Object rawScenes = resp.get("scenes");
if (rawScenes instanceof List<?> list) { if (rawScenes instanceof List<?> list) {
for (Object item : list) { for (Object raw : list) {
if (!(item instanceof Map<?, ?> m)) continue; SceneDraft draft = toSceneDraft(raw);
String name = asString(m.get("name")); if (draft != null) {
if (name == null || name.isBlank()) continue; // un titre est obligatoire out.add(draft);
out.add(new SceneDraft( }
name.trim(),
trimOrNull(asString(m.get("description"))),
trimOrNull(asString(m.get("playerNarration")))));
} }
} }
return out; return out;
} }
/** Une SceneDraft depuis une entrée brute, ou null si malformée / sans titre. */
private static SceneDraft toSceneDraft(Object raw) {
if (!(raw instanceof Map<?, ?> m)) {
return null;
}
String name = asString(m.get("name"));
if (name == null || name.isBlank()) {
return null; // un titre est obligatoire
}
return new SceneDraft(
name.trim(),
trimOrNull(asString(m.get("description"))),
trimOrNull(asString(m.get("playerNarration"))));
}
private static String asString(Object o) { private static String asString(Object o) {
return o != null ? o.toString() : null; return o != null ? o.toString() : null;
} }

View File

@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -41,7 +42,7 @@ public class BrainSidecar {
private final BrainSidecarProperties props; private final BrainSidecarProperties props;
private final String internalSecret; private final String internalSecret;
private volatile Process process; private final AtomicReference<Process> process = new AtomicReference<>();
public BrainSidecar(BrainSidecarProperties props, public BrainSidecar(BrainSidecarProperties props,
@Value("${brain.internal-secret:}") String internalSecret) { @Value("${brain.internal-secret:}") String internalSecret) {
@@ -75,9 +76,10 @@ public class BrainSidecar {
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET) // (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret); pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret);
this.process = pb.start(); Process started = pb.start();
this.process.set(started);
log.info("[Brain] Sidecar demarre (pid={}, cwd={}).", log.info("[Brain] Sidecar demarre (pid={}, cwd={}).",
process.pid(), workingDir != null ? workingDir : "<heritee>"); started.pid(), workingDir != null ? workingDir : "<heritee>");
} catch (IOException e) { } catch (IOException e) {
log.error("[Brain] Echec du lancement du sidecar (commande={}). " log.error("[Brain] Echec du lancement du sidecar (commande={}). "
+ "Les fonctions IA seront indisponibles. Cause : {}", + "Les fonctions IA seront indisponibles. Cause : {}",
@@ -87,7 +89,7 @@ public class BrainSidecar {
@PreDestroy @PreDestroy
public void stop() { public void stop() {
Process p = this.process; Process p = this.process.get();
if (p == null || !p.isAlive()) { if (p == null || !p.isAlive()) {
return; return;
} }

View File

@@ -22,6 +22,9 @@ import java.util.function.Consumer;
*/ */
final class BrainSseImportSupport { final class BrainSseImportSupport {
/** Champ JSON standard des events SSE du Brain portant un message lisible. */
private static final String MESSAGE_FIELD = "message";
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
BrainSseImportSupport(ObjectMapper objectMapper) { BrainSseImportSupport(ObjectMapper objectMapper) {
@@ -50,8 +53,8 @@ final class BrainSseImportSupport {
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */ /** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
String readMessage(String data) { String readMessage(String data) {
JsonNode node = readJson(data); JsonNode node = readJson(data);
if (node != null && node.hasNonNull("message")) { if (node != null && node.hasNonNull(MESSAGE_FIELD)) {
return node.get("message").asText(); return node.get(MESSAGE_FIELD).asText();
} }
return data; return data;
} }
@@ -59,8 +62,8 @@ final class BrainSseImportSupport {
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */ /** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
String chunkFailedStatus(String data) { String chunkFailedStatus(String data) {
JsonNode node = readJson(data); JsonNode node = readJson(data);
String msg = node != null && node.hasNonNull("message") String msg = node != null && node.hasNonNull(MESSAGE_FIELD)
? node.get("message").asText() : ""; ? node.get(MESSAGE_FIELD).asText() : "";
int current = node != null ? node.path("current").asInt() : 0; int current = node != null ? node.path("current").asInt() : 0;
int total = node != null ? node.path("total").asInt() : 0; int total = node != null ? node.path("total").asInt() : 0;
return "Morceau " + current + "/" + total + " ignoré" return "Morceau " + current + "/" + total + " ignoré"

View File

@@ -26,6 +26,8 @@ public class DesktopBrowserOpener {
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class); private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
// S125 : faux positif — commentaire explicatif (prose), pas de code mort.
@SuppressWarnings("java:S125")
public void onReady() { public void onReady() {
// Ferme le splash natif (affiche par la JVM via -splash des le double-clic) // Ferme le splash natif (affiche par la JVM via -splash des le double-clic)
// juste avant d'ouvrir le navigateur : le relais visuel est assure. // juste avant d'ouvrir le navigateur : le relais visuel est assure.

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.desktop; package com.loremind.infrastructure.desktop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.channels.FileLock; import java.nio.channels.FileLock;
@@ -26,12 +29,26 @@ import java.nio.file.StandardOpenOption;
* ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink. * ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink.
* On passe donc par la commande systeme d'ouverture d'URL. * On passe donc par la commande systeme d'ouverture d'URL.
*/ */
// S4036 : app desktop locale mono-utilisateur — lancement d'utilitaires systeme via PATH
// assume, sans contexte d'elevation ni d'input externe.
@SuppressWarnings("java:S4036")
public final class DesktopSingleInstance { public final class DesktopSingleInstance {
/** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */ // Utilisé avant SpringApplication.run() : Logback démarre en config console par
// défaut — même destination que l'ancien System.err (cf. DesktopUserConfig).
private static final Logger log = LoggerFactory.getLogger(DesktopSingleInstance.class);
private static final String OS_NAME_PROPERTY = "os.name";
/** Ouvre URL/fichier/dossier avec l'application par défaut sur Linux. */
private static final String XDG_OPEN = "xdg-open";
/**
* Conserve le CHANNEL ouvert pour toute la duree de vie du process : un FileLock
* reste detenu tant que son channel est ouvert (le verrou est libere a la fermeture
* du channel ou a la sortie de la JVM, pas au GC de l'objet FileLock).
*/
@SuppressWarnings("unused") @SuppressWarnings("unused")
private static FileChannel lockChannel; private static FileChannel lockChannel;
private static FileLock lock;
private DesktopSingleInstance() {} private DesktopSingleInstance() {}
@@ -71,11 +88,11 @@ public final class DesktopSingleInstance {
Path lockFile = dir.resolve(".instance.lock"); Path lockFile = dir.resolve(".instance.lock");
lockChannel = FileChannel.open(lockFile, lockChannel = FileChannel.open(lockFile,
StandardOpenOption.CREATE, StandardOpenOption.WRITE); StandardOpenOption.CREATE, StandardOpenOption.WRITE);
lock = lockChannel.tryLock(); FileLock lock = lockChannel.tryLock();
return lock != null; // null = deja verrouille par une autre instance return lock != null; // null = deja verrouille par une autre instance
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage() log.warn("[Desktop] Verrou d'instance indisponible ({}) — on tente de demarrer quand meme.",
+ ") — on tente de demarrer quand meme."); e.getMessage());
return true; return true;
} }
} }
@@ -88,7 +105,7 @@ public final class DesktopSingleInstance {
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */ /** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
public static void openUrl(String url) { public static void openUrl(String url) {
try { try {
String os = System.getProperty("os.name", "").toLowerCase(); String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
ProcessBuilder pb; ProcessBuilder pb;
if (os.contains("win")) { if (os.contains("win")) {
// rundll32 : ouverture d'URL fiable sans dependance graphique Java. // rundll32 : ouverture d'URL fiable sans dependance graphique Java.
@@ -96,12 +113,12 @@ public final class DesktopSingleInstance {
} else if (os.contains("mac")) { } else if (os.contains("mac")) {
pb = new ProcessBuilder("open", url); pb = new ProcessBuilder("open", url);
} else { } else {
pb = new ProcessBuilder("xdg-open", url); pb = new ProcessBuilder(XDG_OPEN, url);
} }
pb.start(); pb.start();
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url log.warn("[Desktop] Impossible d'ouvrir le navigateur sur {} : {}. Ouvrez-le manuellement.",
+ " : " + e.getMessage() + ". Ouvrez-le manuellement."); url, e.getMessage());
} }
} }
@@ -109,25 +126,25 @@ public final class DesktopSingleInstance {
public static void openFolder(Path dir) { public static void openFolder(Path dir) {
try { try {
Files.createDirectories(dir); Files.createDirectories(dir);
String os = System.getProperty("os.name", "").toLowerCase(); String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
ProcessBuilder pb; ProcessBuilder pb;
if (os.contains("win")) { if (os.contains("win")) {
pb = new ProcessBuilder("explorer.exe", dir.toString()); pb = new ProcessBuilder("explorer.exe", dir.toString());
} else if (os.contains("mac")) { } else if (os.contains("mac")) {
pb = new ProcessBuilder("open", dir.toString()); pb = new ProcessBuilder("open", dir.toString());
} else { } else {
pb = new ProcessBuilder("xdg-open", dir.toString()); pb = new ProcessBuilder(XDG_OPEN, dir.toString());
} }
pb.start(); pb.start();
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Ouverture du dossier impossible : " + e.getMessage()); log.warn("[Desktop] Ouverture du dossier impossible : {}", e.getMessage());
} }
} }
/** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */ /** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */
public static void openInEditor(Path file) { public static void openInEditor(Path file) {
try { try {
String os = System.getProperty("os.name", "").toLowerCase(); String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
ProcessBuilder pb; ProcessBuilder pb;
if (os.contains("win")) { if (os.contains("win")) {
// notepad : toujours present, ouvre proprement un .properties // notepad : toujours present, ouvre proprement un .properties
@@ -136,11 +153,11 @@ public final class DesktopSingleInstance {
} else if (os.contains("mac")) { } else if (os.contains("mac")) {
pb = new ProcessBuilder("open", "-t", file.toString()); pb = new ProcessBuilder("open", "-t", file.toString());
} else { } else {
pb = new ProcessBuilder("xdg-open", file.toString()); pb = new ProcessBuilder(XDG_OPEN, file.toString());
} }
pb.start(); pb.start();
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage()); log.warn("[Desktop] Ouverture du fichier impossible : {}", e.getMessage());
} }
} }

View File

@@ -1,5 +1,8 @@
package com.loremind.infrastructure.desktop; package com.loremind.infrastructure.desktop;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.InetAddress; import java.net.InetAddress;
@@ -30,6 +33,14 @@ import java.util.Properties;
*/ */
public final class DesktopUserConfig { public final class DesktopUserConfig {
// Utilisé AVANT SpringApplication.run() : Logback s'initialise avec sa config
// par défaut (console), puis Spring reprend la main — même destination que
// l'ancien System.out, mais horodaté et uniforme avec le reste des logs.
private static final Logger log = LoggerFactory.getLogger(DesktopUserConfig.class);
/** Clé partagée fichier utilisateur / propriété système / Spring. */
private static final String SERVER_PORT_PROPERTY = "server.port";
private DesktopUserConfig() {} private DesktopUserConfig() {}
private static final String DEFAULT_TEMPLATE = """ private static final String DEFAULT_TEMPLATE = """
@@ -69,10 +80,10 @@ public final class DesktopUserConfig {
try { try {
Files.createDirectories(file.getParent()); Files.createDirectories(file.getParent());
Files.writeString(file, DEFAULT_TEMPLATE); Files.writeString(file, DEFAULT_TEMPLATE);
System.out.println("[Desktop] Config utilisateur creee : " + file); log.info("[Desktop] Config utilisateur creee : {}", file);
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage() log.warn("[Desktop] Impossible de creer {} : {} — defauts utilises (port 8080, admin/admin).",
+ " — defauts utilises (port 8080, admin/admin)."); file, e.getMessage());
} }
} }
@@ -87,13 +98,13 @@ public final class DesktopUserConfig {
int wanted = configuredPort(); int wanted = configuredPort();
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted); int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
if (chosen != wanted) { if (chosen != wanted) {
System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + "."); log.info("[Desktop] Port {} occupe — repli sur le port libre {}.", wanted, chosen);
} }
System.setProperty("server.port", String.valueOf(chosen)); System.setProperty(SERVER_PORT_PROPERTY, String.valueOf(chosen));
try { try {
Files.writeString(portFile(), String.valueOf(chosen)); Files.writeString(portFile(), String.valueOf(chosen));
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ")."); log.warn("[Desktop] Ecriture du port impossible ({}).", e.getMessage());
} }
return chosen; return chosen;
} }
@@ -104,7 +115,7 @@ public final class DesktopUserConfig {
* → port configuré → 8080. * → port configuré → 8080.
*/ */
public static int runningPort() { public static int runningPort() {
String sys = System.getProperty("server.port"); String sys = System.getProperty(SERVER_PORT_PROPERTY);
if (sys != null && !sys.isBlank()) { if (sys != null && !sys.isBlank()) {
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ } try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
} }
@@ -124,7 +135,7 @@ public final class DesktopUserConfig {
Properties props = new Properties(); Properties props = new Properties();
try (InputStream in = Files.newInputStream(file)) { try (InputStream in = Files.newInputStream(file)) {
props.load(in); props.load(in);
String v = props.getProperty("server.port"); String v = props.getProperty(SERVER_PORT_PROPERTY);
if (v != null && !v.isBlank()) { if (v != null && !v.isBlank()) {
return Integer.parseInt(v.trim()); return Integer.parseInt(v.trim());
} }

View File

@@ -37,6 +37,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class); private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
/** Cle racine du config.json Docker portant les registres authentifies. */
private static final String AUTHS_KEY = "auths";
private final Path configPath; private final Path configPath;
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
@@ -62,9 +65,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
root = mapper.createObjectNode(); root = mapper.createObjectNode();
} }
ObjectNode auths = root.has("auths") && root.get("auths").isObject() ObjectNode auths = root.has(AUTHS_KEY) && root.get(AUTHS_KEY).isObject()
? (ObjectNode) root.get("auths") ? (ObjectNode) root.get(AUTHS_KEY)
: root.putObject("auths"); : root.putObject(AUTHS_KEY);
String b64 = Base64.getEncoder().encodeToString( String b64 = Base64.getEncoder().encodeToString(
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8)); (credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));

View File

@@ -8,7 +8,7 @@ import com.nimbusds.jose.crypto.Ed25519Verifier;
import com.nimbusds.jose.jwk.OctetKeyPair; import com.nimbusds.jose.jwk.OctetKeyPair;
import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.SignedJWT;
import org.bouncycastle.asn1.ASN1Sequence; import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -21,8 +21,8 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.ParseException; import java.text.ParseException;
import java.time.Instant;
import java.util.Base64; import java.util.Base64;
import java.util.Date;
/** /**
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon. * Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
@@ -88,25 +88,48 @@ public class NimbusJwtVerifier implements JwtVerifier {
@Override @Override
public LicenseClaims verify(String rawJwt) throws JwtVerificationException { public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
ensureConfigured();
ensureNotBlank(rawJwt);
SignedJWT signed = parseJwt(rawJwt);
ensureExpectedAlgorithm(signed);
verifySignature(signed);
JWTClaimsSet claims = parseClaims(signed);
ensureExpectedIssuer(claims);
ensureExpectedAudience(claims);
return toLicenseClaims(claims);
}
private void ensureConfigured() throws JwtVerificationException {
if (publicKey == null) { if (publicKey == null) {
throw new JwtVerificationException("JWT verifier not configured"); throw new JwtVerificationException("JWT verifier not configured");
} }
}
private static void ensureNotBlank(String rawJwt) throws JwtVerificationException {
if (rawJwt == null || rawJwt.isBlank()) { if (rawJwt == null || rawJwt.isBlank()) {
throw new JwtVerificationException("JWT is empty"); throw new JwtVerificationException("JWT is empty");
} }
}
SignedJWT signed; private static SignedJWT parseJwt(String rawJwt) throws JwtVerificationException {
try { try {
signed = SignedJWT.parse(rawJwt); return SignedJWT.parse(rawJwt);
} catch (ParseException e) { } catch (ParseException e) {
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e); throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
} }
}
private static void ensureExpectedAlgorithm(SignedJWT signed) throws JwtVerificationException {
JWSAlgorithm alg = signed.getHeader().getAlgorithm(); JWSAlgorithm alg = signed.getHeader().getAlgorithm();
if (!JWSAlgorithm.EdDSA.equals(alg)) { if (!JWSAlgorithm.EdDSA.equals(alg)) {
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg); throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
} }
}
private void verifySignature(SignedJWT signed) throws JwtVerificationException {
try { try {
JWSVerifier verifier = new Ed25519Verifier(publicKey); JWSVerifier verifier = new Ed25519Verifier(publicKey);
if (!signed.verify(verifier)) { if (!signed.verify(verifier)) {
@@ -115,31 +138,43 @@ public class NimbusJwtVerifier implements JwtVerifier {
} catch (Exception e) { } catch (Exception e) {
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e); throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
} }
}
JWTClaimsSet claims; private static JWTClaimsSet parseClaims(SignedJWT signed) throws JwtVerificationException {
try { try {
claims = signed.getJWTClaimsSet(); return signed.getJWTClaimsSet();
} catch (ParseException e) { } catch (ParseException e) {
throw new JwtVerificationException("JWT claims parse error", e); throw new JwtVerificationException("JWT claims parse error", e);
} }
}
private void ensureExpectedIssuer(JWTClaimsSet claims) throws JwtVerificationException {
if (!expectedIssuer.equals(claims.getIssuer())) { if (!expectedIssuer.equals(claims.getIssuer())) {
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer()); throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
} }
}
private void ensureExpectedAudience(JWTClaimsSet claims) throws JwtVerificationException {
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) { if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
throw new JwtVerificationException("JWT audience mismatch"); throw new JwtVerificationException("JWT audience mismatch");
} }
}
Date exp = claims.getExpirationTime(); /**
Date iat = claims.getIssueTime(); * Construit les LicenseClaims depuis les claims validés (issuer/audience/signature).
* Note : on ne refuse pas un JWT expiré ici. C'est au LicenseService de decider ce
* qu'il fait d'un JWT expire (grace period, refresh, etc.). La verification de
* signature reste valide tant que la cle existe.
*/
private static LicenseClaims toLicenseClaims(JWTClaimsSet claims) throws JwtVerificationException {
// Nimbus n'expose exp/iat qu'en java.util.Date : converti en Instant SANS variable
// Date intermediaire (aucune reference au type legacy dans cette classe).
String sub = claims.getSubject(); String sub = claims.getSubject();
if (exp == null || iat == null || sub == null) { if (claims.getExpirationTime() == null || claims.getIssueTime() == null || sub == null) {
throw new JwtVerificationException("JWT missing required claims"); throw new JwtVerificationException("JWT missing required claims");
} }
Instant exp = claims.getExpirationTime().toInstant();
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService Instant iat = claims.getIssueTime().toInstant();
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
// La verification de signature reste valide tant que la cle existe.
String tierId; String tierId;
String instanceId; String instanceId;
@@ -153,13 +188,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
throw new JwtVerificationException("JWT missing tier_id or instance_id"); throw new JwtVerificationException("JWT missing tier_id or instance_id");
} }
return new LicenseClaims( return new LicenseClaims(sub, tierId, instanceId, iat, exp);
sub,
tierId,
instanceId,
iat.toInstant(),
exp.toInstant()
);
} }
/** /**
@@ -174,7 +203,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
.replace("-----END PUBLIC KEY-----", "") .replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s+", ""); .replaceAll("\\s+", "");
byte[] der = Base64.getDecoder().decode(base64); byte[] der = Base64.getDecoder().decode(base64);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der)); SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(der));
byte[] keyBytes = spki.getPublicKeyData().getOctets(); byte[] keyBytes = spki.getPublicKeyData().getOctets();
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes); String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x)) return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))

View File

@@ -31,6 +31,24 @@ public class CharacterNpcMarkdownBackfill {
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class); private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
// SQL en CONSTANTES de compilation (pas de concaténation avec un paramètre) :
// les noms de table sont figés dans les littéraux, aucune donnée dynamique
// n'entre dans la requête hors placeholders '?'.
// Sélection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
private static final String BACKFILL_WHERE =
" WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
private static final String SELECT_CHARACTERS =
"SELECT id, markdown_content FROM characters" + BACKFILL_WHERE;
private static final String SELECT_NPCS =
"SELECT id, markdown_content FROM npcs" + BACKFILL_WHERE;
private static final String UPDATE_CHARACTERS =
"UPDATE characters SET field_values = ? WHERE id = ?";
private static final String UPDATE_NPCS =
"UPDATE npcs SET field_values = ? WHERE id = ?";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper mapper = new ObjectMapper(); private final ObjectMapper mapper = new ObjectMapper();
@@ -44,8 +62,8 @@ public class CharacterNpcMarkdownBackfill {
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre)."); log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
return; return;
} }
int chars = backfillTable("characters"); int chars = backfillTable("characters", SELECT_CHARACTERS, UPDATE_CHARACTERS);
int npcs = backfillTable("npcs"); int npcs = backfillTable("npcs", SELECT_NPCS, UPDATE_NPCS);
if (chars + npcs > 0) { if (chars + npcs > 0) {
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs); log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
} }
@@ -65,14 +83,7 @@ public class CharacterNpcMarkdownBackfill {
} }
} }
private int backfillTable(String table) { private int backfillTable(String label, String selectSql, String updateSql) {
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
String selectSql = "SELECT id, markdown_content FROM " + table
+ " WHERE markdown_content IS NOT NULL "
+ " AND markdown_content <> '' "
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
var rows = jdbc.queryForList(selectSql); var rows = jdbc.queryForList(selectSql);
int migrated = 0; int migrated = 0;
for (var row : rows) { for (var row : rows) {
@@ -82,10 +93,10 @@ public class CharacterNpcMarkdownBackfill {
try { try {
json = mapper.writeValueAsString(Map.of("Notes", markdown)); json = mapper.writeValueAsString(Map.of("Notes", markdown));
} catch (Exception e) { } catch (Exception e) {
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage()); log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", label, id, e.getMessage());
continue; continue;
} }
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id); jdbc.update(updateSql, json, id);
migrated++; migrated++;
} }
return migrated; return migrated;

View File

@@ -35,6 +35,10 @@ public class GameSystemSeeder {
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class); private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
private static final String AUTHOR_SEED = "LoreMind seed";
private static final String FIELD_HISTOIRE = "Histoire";
private static final String FIELD_GALERIE = "Galerie";
private final GameSystemRepository gameSystemRepository; private final GameSystemRepository gameSystemRepository;
public GameSystemSeeder(GameSystemRepository gameSystemRepository) { public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
@@ -81,7 +85,7 @@ public class GameSystemSeeder {
GameSystem.builder() GameSystem.builder()
.name("Nimble (extrait)") .name("Nimble (extrait)")
.description("Système léger et narratif, résolution rapide des combats.") .description("Système léger et narratif, résolution rapide des combats.")
.author("LoreMind seed") .author(AUTHOR_SEED)
.isPublic(false) .isPublic(false)
.rulesMarkdown(NIMBLE_RULES) .rulesMarkdown(NIMBLE_RULES)
.characterTemplate(nimbleCharacterTemplate()) .characterTemplate(nimbleCharacterTemplate())
@@ -90,7 +94,7 @@ public class GameSystemSeeder {
GameSystem.builder() GameSystem.builder()
.name("D&D 5e SRD (extrait)") .name("D&D 5e SRD (extrait)")
.description("Extrait libre des bases du System Reference Document 5.1.") .description("Extrait libre des bases du System Reference Document 5.1.")
.author("LoreMind seed") .author(AUTHOR_SEED)
.isPublic(false) .isPublic(false)
.rulesMarkdown(DND_SRD_RULES) .rulesMarkdown(DND_SRD_RULES)
.characterTemplate(dndCharacterTemplate()) .characterTemplate(dndCharacterTemplate())
@@ -99,7 +103,7 @@ public class GameSystemSeeder {
GameSystem.builder() GameSystem.builder()
.name("Homebrew Exemple") .name("Homebrew Exemple")
.description("Template minimaliste à dupliquer pour créer votre propre système.") .description("Template minimaliste à dupliquer pour créer votre propre système.")
.author("LoreMind seed") .author(AUTHOR_SEED)
.isPublic(false) .isPublic(false)
.rulesMarkdown(HOMEBREW_EXAMPLE) .rulesMarkdown(HOMEBREW_EXAMPLE)
.characterTemplate(genericCharacterTemplate()) .characterTemplate(genericCharacterTemplate())
@@ -113,10 +117,10 @@ public class GameSystemSeeder {
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */ /** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
private static List<TemplateField> genericCharacterTemplate() { private static List<TemplateField> genericCharacterTemplate() {
return List.of( return List.of(
TemplateField.text("Histoire"), TemplateField.text(FIELD_HISTOIRE),
TemplateField.text("Personnalite"), TemplateField.text("Personnalite"),
TemplateField.text("Apparence"), TemplateField.text("Apparence"),
TemplateField.image("Galerie", ImageLayout.GALLERY), TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY),
TemplateField.text("Notes") TemplateField.text("Notes")
); );
} }
@@ -137,9 +141,9 @@ public class GameSystemSeeder {
TemplateField.number("Blessures graves max"), TemplateField.number("Blessures graves max"),
TemplateField.text("Capacites de classe"), TemplateField.text("Capacites de classe"),
TemplateField.text("Equipement"), TemplateField.text("Equipement"),
TemplateField.text("Histoire"), TemplateField.text(FIELD_HISTOIRE),
TemplateField.text("Objectifs personnels"), TemplateField.text("Objectifs personnels"),
TemplateField.image("Galerie", ImageLayout.GALLERY) TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY)
); );
} }
@@ -157,8 +161,8 @@ public class GameSystemSeeder {
TemplateField.text("Competences"), TemplateField.text("Competences"),
TemplateField.text("Equipement"), TemplateField.text("Equipement"),
TemplateField.text("Sorts"), TemplateField.text("Sorts"),
TemplateField.text("Histoire"), TemplateField.text(FIELD_HISTOIRE),
TemplateField.image("Galerie", ImageLayout.GALLERY) TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY)
); );
} }

View File

@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
@@ -32,8 +33,11 @@ public class MapJsonConverter implements AttributeConverter<Map<String, Object>,
@Override @Override
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public Map<String, Object> convertToEntityAttribute(String dbData) { public Map<String, Object> convertToEntityAttribute(String dbData) {
if (dbData == null) { // Map vide (mutable) plutôt que null : les consommateurs itèrent/écrivent
return null; // sans avoir à se défendre contre le null (même contrat que les autres
// converters de ce package).
if (dbData == null || dbData.isBlank()) {
return new HashMap<>();
} }
try { try {
return objectMapper.readValue(dbData, Map.class); return objectMapper.readValue(dbData, Map.class);

View File

@@ -60,71 +60,8 @@ public class TemplateFieldListJsonConverter
} }
List<TemplateField> result = new ArrayList<>(); List<TemplateField> result = new ArrayList<>();
for (JsonNode item : root) { for (JsonNode item : root) {
if (item.isTextual()) { TemplateField field = parseField(item);
// Format legacy : chaine simple, on suppose TEXT par defaut. if (field != null) result.add(field);
// L'id stable est retro-rempli avec le nom (les valeurs de Page
// sont deja rangees par nom -> id == name, aucune migration).
String name = item.asText();
TemplateField legacy = TemplateField.text(name);
legacy.setId(name);
result.add(legacy);
} else if (item.isObject()) {
// Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}
String name = item.path("name").asText(null);
String typeStr = item.path("type").asText("TEXT");
FieldType type;
try {
type = FieldType.valueOf(typeStr);
} catch (IllegalArgumentException ex) {
// Type inconnu (ajoute par une version future) : fallback TEXT.
type = FieldType.TEXT;
}
ImageLayout layout = null;
if (type == FieldType.IMAGE) {
String layoutStr = item.path("layout").asText(null);
if (layoutStr != null && !layoutStr.isBlank()) {
try {
layout = ImageLayout.valueOf(layoutStr);
} catch (IllegalArgumentException ex) {
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
layout = null;
}
}
}
List<String> labels = null;
if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
JsonNode labelsNode = item.path("labels");
if (labelsNode.isArray()) {
labels = new ArrayList<>();
for (JsonNode label : labelsNode) {
if (label.isTextual()) labels.add(label.asText());
}
}
}
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
// (asText() renverrait la chaine "null"). Historiquement omis a la
// relecture -> il etait perdu au save+reload : corrige ici.
String foundryPath = item.hasNonNull("foundryPath")
? item.get("foundryPath").asText() : null;
// id : explicite si present, sinon retro-rempli avec le nom.
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
if (id == null || id.isBlank()) {
id = name;
}
BlockPosition pos = readPos(item.path("pos"));
if (name != null && !name.isBlank()) {
result.add(TemplateField.builder()
.id(id)
.name(name)
.type(type)
.layout(layout)
.labels(labels)
.foundryPath(foundryPath)
.pos(pos)
.build());
}
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
} }
return result; return result;
} catch (Exception e) { } catch (Exception e) {
@@ -133,6 +70,101 @@ public class TemplateFieldListJsonConverter
} }
} }
/** Un element du tableau JSON -> TemplateField, ou null si non convertible (ignore). */
private static TemplateField parseField(JsonNode item) {
if (item.isTextual()) {
return parseLegacyField(item.asText());
}
if (item.isObject()) {
return parseObjectField(item);
}
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
return null;
}
/**
* Format legacy : chaine simple, on suppose TEXT par defaut. L'id stable est
* retro-rempli avec le nom (les valeurs de Page sont deja rangees par nom ->
* id == name, aucune migration).
*/
private static TemplateField parseLegacyField(String name) {
TemplateField legacy = TemplateField.text(name);
legacy.setId(name);
return legacy;
}
/** Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}. Null si nom vide. */
private static TemplateField parseObjectField(JsonNode item) {
String name = item.path("name").asText(null);
if (name == null || name.isBlank()) {
return null;
}
FieldType type = parseType(item.path("type").asText("TEXT"));
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
// (asText() renverrait la chaine "null"). Historiquement omis a la
// relecture -> il etait perdu au save+reload : corrige ici.
String foundryPath = item.hasNonNull("foundryPath")
? item.get("foundryPath").asText() : null;
// id : explicite si present, sinon retro-rempli avec le nom.
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
if (id == null || id.isBlank()) {
id = name;
}
return TemplateField.builder()
.id(id)
.name(name)
.type(type)
.layout(parseLayout(item, type))
.labels(parseLabels(item, type))
.foundryPath(foundryPath)
.pos(readPos(item.path("pos")))
.build();
}
/** Type du champ ; fallback TEXT si inconnu (ajoute par une version future). */
private static FieldType parseType(String typeStr) {
try {
return FieldType.valueOf(typeStr);
} catch (IllegalArgumentException ex) {
return FieldType.TEXT;
}
}
/** Layout d'un champ IMAGE ; null si absent/inconnu (-> rendu GALLERY par defaut cote UI). */
private static ImageLayout parseLayout(JsonNode item, FieldType type) {
if (type != FieldType.IMAGE) {
return null;
}
String layoutStr = item.path("layout").asText(null);
if (layoutStr == null || layoutStr.isBlank()) {
return null;
}
try {
return ImageLayout.valueOf(layoutStr);
} catch (IllegalArgumentException ex) {
return null;
}
}
/** Libelles d'un champ KEY_VALUE_LIST/TABLE ; null pour les autres types. */
// S1168 : null est ici SEMANTIQUE ("champ sans libelles"), distinct d'une liste vide —
// le JSON persiste ecrit "labels":null (compat ascendante) et le front distingue null/[].
@SuppressWarnings("java:S1168")
private static List<String> parseLabels(JsonNode item, FieldType type) {
if (type != FieldType.KEY_VALUE_LIST && type != FieldType.TABLE) {
return null;
}
JsonNode labelsNode = item.path("labels");
if (!labelsNode.isArray()) {
return null;
}
List<String> labels = new ArrayList<>();
for (JsonNode label : labelsNode) {
if (label.isTextual()) labels.add(label.asText());
}
return labels;
}
/** /**
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le * Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes * noeud n'est pas un objet ou si toutes les coordonnees sont absentes

View File

@@ -9,6 +9,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -94,12 +95,12 @@ public class ArcJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -6,6 +6,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL. * Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
@@ -64,12 +65,12 @@ public class CampaignJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = createdAt;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -68,12 +69,12 @@ public class ChapterJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -10,6 +10,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -81,8 +82,8 @@ public class CharacterJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
if (values == null) values = new HashMap<>(); if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
@@ -90,6 +91,6 @@ public class CharacterJpaEntity {
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL. * Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
@@ -64,13 +65,13 @@ public class ClockJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -19,6 +19,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -77,13 +78,13 @@ public class ConversationJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -16,6 +16,7 @@ import lombok.NoArgsConstructor;
import lombok.ToString; import lombok.ToString;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}. * Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
@@ -54,6 +55,6 @@ public class ConversationMessageJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -10,6 +10,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -83,8 +84,8 @@ public class EnemyJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = createdAt;
if (values == null) values = new HashMap<>(); if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
@@ -92,6 +93,6 @@ public class EnemyJpaEntity {
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des Fronts en PostgreSQL. * Entité JPA pour la persistance des Fronts en PostgreSQL.
@@ -44,13 +45,13 @@ public class FrontJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -69,8 +70,8 @@ public class GameSystemJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = createdAt;
if (characterTemplate == null) characterTemplate = new ArrayList<>(); if (characterTemplate == null) characterTemplate = new ArrayList<>();
if (npcTemplate == null) npcTemplate = new ArrayList<>(); if (npcTemplate == null) npcTemplate = new ArrayList<>();
if (enemyTemplate == null) enemyTemplate = new ArrayList<>(); if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
@@ -78,6 +79,6 @@ public class GameSystemJpaEntity {
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entite JPA pour les metadonnees d'images en PostgreSQL. * Entite JPA pour les metadonnees d'images en PostgreSQL.
@@ -43,7 +44,7 @@ public class ImageJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (uploadedAt == null) { if (uploadedAt == null) {
uploadedAt = LocalDateTime.now(); uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -60,12 +61,12 @@ public class ItemCatalogJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -6,6 +6,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des Lores en base de données PostgreSQL. * Entité JPA pour la persistance des Lores en base de données PostgreSQL.
@@ -43,12 +44,12 @@ public class LoreJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -6,6 +6,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL. * Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
@@ -48,12 +49,12 @@ public class LoreNodeJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
@Entity @Entity
@Table(name = "notebooks", indexes = { @Table(name = "notebooks", indexes = {
@@ -36,12 +37,12 @@ public class NotebookJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
@Entity @Entity
@Table(name = "notebook_messages", indexes = { @Table(name = "notebook_messages", indexes = {
@@ -44,6 +45,6 @@ public class NotebookMessageJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
@Entity @Entity
@Table(name = "notebook_sources", indexes = { @Table(name = "notebook_sources", indexes = {
@@ -42,6 +43,6 @@ public class NotebookSourceJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (createdAt == null) createdAt = LocalDateTime.now(); if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -11,6 +11,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -74,8 +75,8 @@ public class NpcJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
if (values == null) values = new HashMap<>(); if (values == null) values = new HashMap<>();
if (imageValues == null) imageValues = new HashMap<>(); if (imageValues == null) imageValues = new HashMap<>();
if (keyValueValues == null) keyValueValues = new HashMap<>(); if (keyValueValues == null) keyValueValues = new HashMap<>();
@@ -83,6 +84,6 @@ public class NpcJpaEntity {
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -14,6 +14,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -94,12 +95,12 @@ public class PageJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne. * Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne.
@@ -43,12 +44,12 @@ public class PlaythroughJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -11,6 +11,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -90,12 +91,12 @@ public class QuestJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -62,12 +63,12 @@ public class RandomTableJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -14,6 +14,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -140,12 +141,12 @@ public class SceneJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -8,6 +8,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des entrées de journal de session. * Entité JPA pour la persistance des entrées de journal de session.
@@ -48,13 +49,13 @@ public class SessionEntryJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entité JPA pour la persistance des Sessions en PostgreSQL. * Entité JPA pour la persistance des Sessions en PostgreSQL.
@@ -61,13 +62,13 @@ public class SessionJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
createdAt = now; createdAt = now;
updatedAt = now; updatedAt = now;
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
/** /**
* Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL. * Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL.
@@ -44,7 +45,7 @@ public class StoredFileJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (uploadedAt == null) { if (uploadedAt == null) {
uploadedAt = LocalDateTime.now(); uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }
} }

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
/** /**
@@ -54,12 +55,12 @@ public class TemplateJpaEntity {
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now(ZoneId.systemDefault());
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
updatedAt = LocalDateTime.now(); updatedAt = LocalDateTime.now(ZoneId.systemDefault());
} }
} }

View File

@@ -10,6 +10,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
/** /**
@@ -35,7 +36,11 @@ public class PlaythroughMigrationRunner {
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class); private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale"; private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale";
private static final String COLUMN_CAMPAIGN_ID = "campaign_id";
// S6809 : auto-invocation assumee — "corriger" (auto-injection/@Lazy) changerait la semantique
// transactionnelle reelle de cette migration one-shot idempotente.
@SuppressWarnings("java:S6809")
@Bean @Bean
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) { public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
return args -> migrate(jdbc); return args -> migrate(jdbc);
@@ -73,7 +78,7 @@ public class PlaythroughMigrationRunner {
); );
if (campaignsWithoutPlaythrough.isEmpty()) return 0; if (campaignsWithoutPlaythrough.isEmpty()) return 0;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
for (Long campaignId : campaignsWithoutPlaythrough) { for (Long campaignId : campaignsWithoutPlaythrough) {
jdbc.update( jdbc.update(
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " + "INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
@@ -85,7 +90,7 @@ public class PlaythroughMigrationRunner {
} }
private int migrateSessions(JdbcTemplate jdbc) { private int migrateSessions(JdbcTemplate jdbc) {
if (!columnExists(jdbc, "sessions", "campaign_id")) return 0; if (!columnExists(jdbc, "sessions", COLUMN_CAMPAIGN_ID)) return 0;
return jdbc.update( return jdbc.update(
"UPDATE sessions s " + "UPDATE sessions s " +
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " +
@@ -94,7 +99,7 @@ public class PlaythroughMigrationRunner {
} }
private int migrateCharacters(JdbcTemplate jdbc) { private int migrateCharacters(JdbcTemplate jdbc) {
if (!columnExists(jdbc, "characters", "campaign_id")) return 0; if (!columnExists(jdbc, "characters", COLUMN_CAMPAIGN_ID)) return 0;
return jdbc.update( return jdbc.update(
"UPDATE characters c " + "UPDATE characters c " +
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " + "SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " +
@@ -145,11 +150,13 @@ public class PlaythroughMigrationRunner {
* Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE. * Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE.
*/ */
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) { private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
relaxNotNull(jdbc, "sessions", "campaign_id"); relaxNotNull(jdbc, "sessions", COLUMN_CAMPAIGN_ID);
relaxNotNull(jdbc, "characters", "campaign_id"); relaxNotNull(jdbc, "characters", COLUMN_CAMPAIGN_ID);
relaxNotNull(jdbc, "chapters", "progression_status"); relaxNotNull(jdbc, "chapters", "progression_status");
} }
// S2077 : DDL non parametrable ; table/colonne proviennent exclusivement d'appels internes hardcodes.
@SuppressWarnings("java:S2077")
private void relaxNotNull(JdbcTemplate jdbc, String table, String column) { private void relaxNotNull(JdbcTemplate jdbc, String table, String column) {
if (!columnExists(jdbc, table, column)) return; if (!columnExists(jdbc, table, column)) return;
try { try {

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port ArcRepository. * Adaptateur d'infrastructure qui implémente le Port ArcRepository.
@@ -42,14 +41,14 @@ public class PostgresArcRepository implements ArcRepository {
Long longCampaignId = Long.parseLong(campaignId); Long longCampaignId = Long.parseLong(campaignId);
return jpaRepository.findByCampaignId(longCampaignId).stream() return jpaRepository.findByCampaignId(longCampaignId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Arc> findAll() { public List<Arc> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository. * Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
@@ -43,7 +42,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
public List<Campaign> findAll() { public List<Campaign> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -62,7 +61,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
public List<Campaign> searchByName(String query) { public List<Campaign> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream() return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) { private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) {

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port ChapterRepository. * Adaptateur d'infrastructure qui implémente le Port ChapterRepository.
@@ -42,14 +41,14 @@ public class PostgresChapterRepository implements ChapterRepository {
Long longArcId = Long.parseLong(arcId); Long longArcId = Long.parseLong(arcId);
return jpaRepository.findByArcId(longArcId).stream() return jpaRepository.findByArcId(longArcId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Chapter> findAll() { public List<Chapter> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresCharacterRepository implements CharacterRepository { public class PostgresCharacterRepository implements CharacterRepository {
@@ -36,7 +35,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
public List<Character> findByPlaythroughId(String playthroughId) { public List<Character> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -53,7 +52,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
public List<Character> searchByName(String query) { public List<Character> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private Character toDomainEntity(CharacterJpaEntity e) { private Character toDomainEntity(CharacterJpaEntity e) {

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres. * Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres.
@@ -37,7 +36,7 @@ public class PostgresClockRepository implements ClockRepository {
public List<Clock> findByPlaythroughId(String playthroughId) { public List<Clock> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -7,13 +7,13 @@ import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity; import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository; import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.EntityNotFoundException;
import org.hibernate.Hibernate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur Postgres pour ConversationRepository. * Adaptateur Postgres pour ConversationRepository.
@@ -46,7 +46,7 @@ public class PostgresConversationRepository implements ConversationRepository {
return jpa.findById(Long.parseLong(id)) return jpa.findById(Long.parseLong(id))
.map(e -> { .map(e -> {
// Force l'initialisation LAZY avant de sortir de la transaction. // Force l'initialisation LAZY avant de sortir de la transaction.
e.getMessages().size(); Hibernate.initialize(e.getMessages());
return toDomain(e, true); return toDomain(e, true);
}); });
} }
@@ -66,7 +66,7 @@ public class PostgresConversationRepository implements ConversationRepository {
} else { } else {
return Collections.emptyList(); return Collections.emptyList();
} }
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList()); return rows.stream().map(e -> toDomain(e, false)).toList();
} }
@Override @Override
@@ -88,7 +88,7 @@ public class PostgresConversationRepository implements ConversationRepository {
.build(); .build();
conv.getMessages().add(msg); conv.getMessages().add(msg);
// Force updatedAt via @PreUpdate en modifiant la conv (touch). // Force updatedAt via @PreUpdate en modifiant la conv (touch).
conv.setUpdatedAt(java.time.LocalDateTime.now()); conv.setUpdatedAt(java.time.LocalDateTime.now(java.time.ZoneId.systemDefault()));
ConversationJpaEntity saved = jpa.save(conv); ConversationJpaEntity saved = jpa.save(conv);
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1); ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
@@ -122,7 +122,7 @@ public class PostgresConversationRepository implements ConversationRepository {
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) { private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
List<ConversationMessage> msgs = withMessages List<ConversationMessage> msgs = withMessages
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList()) ? e.getMessages().stream().map(this::toDomainMessage).toList()
: new java.util.ArrayList<>(); : new java.util.ArrayList<>();
return Conversation.builder() return Conversation.builder()
.id(e.getId().toString()) .id(e.getId().toString())

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresEnemyRepository implements EnemyRepository { public class PostgresEnemyRepository implements EnemyRepository {
@@ -34,7 +33,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
public List<Enemy> findByCampaignId(String campaignId) { public List<Enemy> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -46,7 +45,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
public List<Enemy> searchByName(String query) { public List<Enemy> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private Enemy toDomainEntity(EnemyJpaEntity e) { private Enemy toDomainEntity(EnemyJpaEntity e) {

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres. * Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres.
@@ -36,7 +35,7 @@ public class PostgresFrontRepository implements FrontRepository {
public List<Front> findByPlaythroughId(String playthroughId) { public List<Front> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream() return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresGameSystemRepository implements GameSystemRepository { public class PostgresGameSystemRepository implements GameSystemRepository {
@@ -35,7 +34,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
public List<GameSystem> findAll() { public List<GameSystem> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -52,7 +51,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
public List<GameSystem> searchByName(String query) { public List<GameSystem> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream() return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private GameSystem toDomainEntity(GameSystemJpaEntity e) { private GameSystem toDomainEntity(GameSystemJpaEntity e) {

View File

@@ -64,7 +64,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
public List<ItemCatalog> findByCampaignId(String campaignId) { public List<ItemCatalog> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -82,7 +82,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
public List<ItemCatalog> searchByName(String query) { public List<ItemCatalog> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) { private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository. * Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository.
@@ -41,7 +40,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
Long longLoreId = Long.parseLong(loreId); Long longLoreId = Long.parseLong(loreId);
return jpaRepository.findByLoreId(longLoreId).stream() return jpaRepository.findByLoreId(longLoreId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -49,19 +48,19 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
if (parentId == null || parentId.isEmpty()) { if (parentId == null || parentId.isEmpty()) {
return jpaRepository.findByParentId(null).stream() return jpaRepository.findByParentId(null).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
Long longParentId = Long.parseLong(parentId); Long longParentId = Long.parseLong(parentId);
return jpaRepository.findByParentId(longParentId).stream() return jpaRepository.findByParentId(longParentId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<LoreNode> findAll() { public List<LoreNode> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -85,7 +84,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
public List<LoreNode> searchByName(String query) { public List<LoreNode> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream() return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) { private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) {

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port LoreRepository. * Adaptateur d'infrastructure qui implémente le Port LoreRepository.
@@ -42,7 +41,7 @@ public class PostgresLoreRepository implements LoreRepository {
public List<Lore> findAll() { public List<Lore> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -61,7 +60,7 @@ public class PostgresLoreRepository implements LoreRepository {
public List<Lore> searchByName(String query) { public List<Lore> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream() return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
// Méthodes de conversion // Méthodes de conversion

View File

@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresNotebookRepository implements NotebookRepository { public class PostgresNotebookRepository implements NotebookRepository {
@@ -53,7 +52,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<Notebook> findByCampaignId(String campaignId) { public List<Notebook> findByCampaignId(String campaignId) {
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream() return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
.map(this::toNotebook).collect(Collectors.toList()); .map(this::toNotebook).toList();
} }
@Override @Override
@@ -93,7 +92,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookSource> findSourcesByNotebookId(String notebookId) { public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toSource).collect(Collectors.toList()); .map(this::toSource).toList();
} }
@Override @Override
@@ -116,19 +115,20 @@ public class PostgresNotebookRepository implements NotebookRepository {
@Override @Override
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).toList();
} }
@Override @Override
@Transactional @Transactional
public void archiveMessagesByNotebookId(String notebookId) { public void archiveMessagesByNotebookId(String notebookId) {
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now()); messageJpa.archiveActiveMessages(Long.parseLong(notebookId),
java.time.LocalDateTime.now(java.time.ZoneId.systemDefault()));
} }
@Override @Override
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) { public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream() return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
.map(this::toMessage).collect(Collectors.toList()); .map(this::toMessage).toList();
} }
// --- Mapping --- // --- Mapping ---

View File

@@ -10,7 +10,6 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresNpcRepository implements NpcRepository { public class PostgresNpcRepository implements NpcRepository {
@@ -37,7 +36,7 @@ public class PostgresNpcRepository implements NpcRepository {
public List<Npc> findByCampaignId(String campaignId) { public List<Npc> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -54,7 +53,7 @@ public class PostgresNpcRepository implements NpcRepository {
public List<Npc> searchByName(String query) { public List<Npc> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private Npc toDomainEntity(NpcJpaEntity e) { private Npc toDomainEntity(NpcJpaEntity e) {

View File

@@ -10,7 +10,6 @@ import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port PageRepository. * Adaptateur d'infrastructure qui implémente le Port PageRepository.
@@ -42,7 +41,7 @@ public class PostgresPageRepository implements PageRepository {
public List<Page> findByLoreId(String loreId) { public List<Page> findByLoreId(String loreId) {
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream() return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -50,14 +49,14 @@ public class PostgresPageRepository implements PageRepository {
Long longNodeId = Long.parseLong(nodeId); Long longNodeId = Long.parseLong(nodeId);
return jpaRepository.findByNodeId(longNodeId).stream() return jpaRepository.findByNodeId(longNodeId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Page> findAll() { public List<Page> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -81,7 +80,7 @@ public class PostgresPageRepository implements PageRepository {
public List<Page> searchByTitle(String query) { public List<Page> searchByTitle(String query) {
return jpaRepository.findByTitleContainingIgnoreCase(query).stream() return jpaRepository.findByTitleContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private Page toDomainEntity(PageJpaEntity e) { private Page toDomainEntity(PageJpaEntity e) {

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresPlaythroughRepository implements PlaythroughRepository { public class PostgresPlaythroughRepository implements PlaythroughRepository {
@@ -34,12 +33,12 @@ public class PostgresPlaythroughRepository implements PlaythroughRepository {
public List<Playthrough> findByCampaignId(String campaignId) { public List<Playthrough> findByCampaignId(String campaignId) {
return jpa.findByCampaignId(Long.parseLong(campaignId)).stream() return jpa.findByCampaignId(Long.parseLong(campaignId)).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Playthrough> findAll() { public List<Playthrough> findAll() {
return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList()); return jpa.findAll().stream().map(this::toDomain).toList();
} }
@Override @Override

View File

@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors;
@Repository @Repository
public class PostgresQuestProgressionRepository implements QuestProgressionRepository { public class PostgresQuestProgressionRepository implements QuestProgressionRepository {
@@ -25,7 +24,7 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos
public List<QuestProgression> findByPlaythroughId(String playthroughId) { public List<QuestProgression> findByPlaythroughId(String playthroughId) {
return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream() return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port QuestRepository. * Adaptateur d'infrastructure qui implémente le Port QuestRepository.
@@ -40,7 +39,7 @@ public class PostgresQuestRepository implements QuestRepository {
public List<Quest> findByCampaignId(String campaignId) { public List<Quest> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream() return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -48,14 +47,14 @@ public class PostgresQuestRepository implements QuestRepository {
if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée
return jpaRepository.findByArcId(Long.parseLong(arcId)).stream() return jpaRepository.findByArcId(Long.parseLong(arcId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Quest> findAll() { public List<Quest> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -68,7 +68,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
public List<RandomTable> findByCampaignId(String campaignId) { public List<RandomTable> findByCampaignId(String campaignId) {
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream() return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -86,7 +86,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
public List<RandomTable> searchByName(String query) { public List<RandomTable> searchByName(String query) {
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream() return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
private RandomTable toDomainEntity(RandomTableJpaEntity e) { private RandomTable toDomainEntity(RandomTableJpaEntity e) {

View File

@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port SceneRepository. * Adaptateur d'infrastructure qui implémente le Port SceneRepository.
@@ -43,14 +42,14 @@ public class PostgresSceneRepository implements SceneRepository {
Long longChapterId = Long.parseLong(chapterId); Long longChapterId = Long.parseLong(chapterId);
return jpaRepository.findByChapterId(longChapterId).stream() return jpaRepository.findByChapterId(longChapterId).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Scene> findAll() { public List<Scene> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -9,7 +9,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository. * Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
@@ -38,7 +37,7 @@ public class PostgresSessionEntryRepository implements SessionEntryRepository {
public List<SessionEntry> findBySessionId(String sessionId) { public List<SessionEntry> findBySessionId(String sessionId) {
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream() return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant * Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant
@@ -38,14 +37,14 @@ public class PostgresSessionRepository implements SessionRepository {
public List<Session> findAll() { public List<Session> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Session> findByPlaythroughId(String playthroughId) { public List<Session> findByPlaythroughId(String playthroughId) {
return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream() return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream()
.map(this::toDomain) .map(this::toDomain)
.collect(Collectors.toList()); .toList();
} }
@Override @Override

View File

@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors;
/** /**
* Adaptateur d'infrastructure qui implémente le Port TemplateRepository. * Adaptateur d'infrastructure qui implémente le Port TemplateRepository.
@@ -40,14 +39,14 @@ public class PostgresTemplateRepository implements TemplateRepository {
public List<Template> findAll() { public List<Template> findAll() {
return jpaRepository.findAll().stream() return jpaRepository.findAll().stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
public List<Template> findByLoreId(String loreId) { public List<Template> findByLoreId(String loreId) {
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream() return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
@Override @Override
@@ -64,7 +63,7 @@ public class PostgresTemplateRepository implements TemplateRepository {
public List<Template> searchByName(String query) { public List<Template> searchByName(String query) {
return jpaRepository.findByNameContainingIgnoreCase(query).stream() return jpaRepository.findByNameContainingIgnoreCase(query).stream()
.map(this::toDomainEntity) .map(this::toDomainEntity)
.collect(Collectors.toList()); .toList();
} }
// --- Mapping ---------------------------------------------------------- // --- Mapping ----------------------------------------------------------

View File

@@ -1,6 +1,8 @@
package com.loremind.infrastructure.storage; package com.loremind.infrastructure.storage;
import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.files.ports.FileStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -26,6 +28,8 @@ import java.util.UUID;
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem") @ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
public class FilesystemFileStorageAdapter implements FileStorage { public class FilesystemFileStorageAdapter implements FileStorage {
private static final Logger log = LoggerFactory.getLogger(FilesystemFileStorageAdapter.class);
private final Path root; private final Path root;
public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) { public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
@@ -36,7 +40,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
void ensureRootExists() { void ensureRootExists() {
try { try {
Files.createDirectories(root.resolve("files")); Files.createDirectories(root.resolve("files"));
System.out.println("[Storage] Backend filesystem (fichiers) actif — racine : " + root); log.info("[Storage] Backend filesystem (fichiers) actif — racine : {}", root);
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e); throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
} }
@@ -84,7 +88,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
try { try {
Files.deleteIfExists(resolveKey(storageKey)); Files.deleteIfExists(resolveKey(storageKey));
} catch (IOException e) { } catch (IOException e) {
System.err.println("[Storage] Erreur suppression fichier (non bloquante) : " + e.getMessage()); log.warn("[Storage] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
} }
} }

View File

@@ -1,6 +1,8 @@
package com.loremind.infrastructure.storage; package com.loremind.infrastructure.storage;
import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.images.ports.ImageStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -31,6 +33,8 @@ import java.util.UUID;
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem") @ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
public class FilesystemImageStorageAdapter implements ImageStorage { public class FilesystemImageStorageAdapter implements ImageStorage {
private static final Logger log = LoggerFactory.getLogger(FilesystemImageStorageAdapter.class);
private final Path root; private final Path root;
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) { public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
@@ -41,7 +45,7 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
void ensureRootExists() { void ensureRootExists() {
try { try {
Files.createDirectories(root); Files.createDirectories(root);
System.out.println("[Storage] Backend filesystem actif — racine : " + root); log.info("[Storage] Backend filesystem actif — racine : {}", root);
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e); throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
} }
@@ -92,7 +96,7 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
Files.deleteIfExists(resolveKey(storageKey)); Files.deleteIfExists(resolveKey(storageKey));
} catch (IOException e) { } catch (IOException e) {
// Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO). // Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO).
System.err.println("[Storage] Erreur suppression (non bloquante) : " + e.getMessage()); log.warn("[Storage] Erreur suppression (non bloquante) : {}", e.getMessage());
} }
} }

View File

@@ -6,6 +6,8 @@ import io.minio.MinioClient;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException; import io.minio.errors.ErrorResponseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -24,6 +26,8 @@ import java.util.UUID;
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) @ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioFileStorageAdapter implements FileStorage { public class MinioFileStorageAdapter implements FileStorage {
private static final Logger log = LoggerFactory.getLogger(MinioFileStorageAdapter.class);
private final MinioClient minioClient; private final MinioClient minioClient;
private final String bucket; private final String bucket;
@@ -90,7 +94,7 @@ public class MinioFileStorageAdapter implements FileStorage {
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build() RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
); );
} catch (Exception e) { } catch (Exception e) {
System.err.println("[MinIO] Erreur suppression fichier (non bloquante) : " + e.getMessage()); log.warn("[MinIO] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
} }
} }

View File

@@ -6,6 +6,8 @@ import io.minio.MinioClient;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import io.minio.errors.ErrorResponseException; import io.minio.errors.ErrorResponseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -27,6 +29,8 @@ import java.util.UUID;
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) @ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
public class MinioImageStorageAdapter implements ImageStorage { public class MinioImageStorageAdapter implements ImageStorage {
private static final Logger log = LoggerFactory.getLogger(MinioImageStorageAdapter.class);
private final MinioClient minioClient; private final MinioClient minioClient;
private final String bucket; private final String bucket;
@@ -95,7 +99,7 @@ public class MinioImageStorageAdapter implements ImageStorage {
); );
} catch (Exception e) { } catch (Exception e) {
// Suppression idempotente : on loggue mais on ne propage pas. // Suppression idempotente : on loggue mais on ne propage pas.
System.err.println("[MinIO] Erreur suppression (non bloquante) : " + e.getMessage()); log.warn("[MinIO] Erreur suppression (non bloquante) : {}", e.getMessage());
} }
} }

View File

@@ -78,7 +78,19 @@ class CampaignContentInserter {
} }
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) { void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
// -- Campaign (loreId/gameSystemId String remappes en 2e passe) insertCampaigns(export, maps, result);
insertArcs(export, maps, result);
insertItemCatalogs(export, maps, result);
insertRandomTables(export, maps, result);
insertChapters(export, maps, result);
insertQuests(export, maps, result);
insertNpcs(export, maps, result);
insertEnemies(export, maps, result);
insertScenes(export, maps, result);
}
/** Campaign (loreId/gameSystemId String remappes en 2e passe). */
private void insertCampaigns(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) { for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
CampaignJpaEntity e = new CampaignJpaEntity(); CampaignJpaEntity e = new CampaignJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -90,8 +102,10 @@ class CampaignContentInserter {
maps.campaignMap.put(d.id(), campaignRepo.save(e).getId()); maps.campaignMap.put(d.id(), campaignRepo.save(e).getId());
} }
result.count("campaigns", maps.campaignMap.size()); result.count("campaigns", maps.campaignMap.size());
}
// -- Arc (relatedPageIds remappe en 2e passe) /** Arc (relatedPageIds remappe en 2e passe). */
private void insertArcs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.ArcDto d : nullSafe(export.arcs())) { for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity(); ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -110,9 +124,12 @@ class CampaignContentInserter {
maps.arcMap.put(d.id(), arcRepo.save(e).getId()); maps.arcMap.put(d.id(), arcRepo.save(e).getId());
} }
result.count("arcs", maps.arcMap.size()); result.count("arcs", maps.arcMap.size());
}
// -- ItemCatalog (+ items en cascade) /** ItemCatalog (+ items en cascade). */
int catalogCount = 0, itemCount = 0; private void insertItemCatalogs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
int catalogCount = 0;
int itemCount = 0;
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) { for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity(); ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -138,9 +155,12 @@ class CampaignContentInserter {
} }
result.count("itemCatalogs", catalogCount); result.count("itemCatalogs", catalogCount);
result.count("catalogItems", itemCount); result.count("catalogItems", itemCount);
}
// -- RandomTable (+ entries en cascade) /** RandomTable (+ entries en cascade). */
int tableCount = 0, entryCount = 0; private void insertRandomTables(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
int tableCount = 0;
int entryCount = 0;
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) { for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
RandomTableJpaEntity e = new RandomTableJpaEntity(); RandomTableJpaEntity e = new RandomTableJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -167,9 +187,13 @@ class CampaignContentInserter {
} }
result.count("randomTables", tableCount); result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount); result.count("randomTableEntries", entryCount);
}
// -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de /**
// prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy. * Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
* prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
*/
private void insertChapters(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity(); ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -185,9 +209,16 @@ class CampaignContentInserter {
maps.chapterMap.put(d.id(), chapterRepo.save(e).getId()); maps.chapterMap.put(d.id(), chapterRepo.save(e).getId());
} }
result.count("chapters", maps.chapterMap.size()); result.count("chapters", maps.chapterMap.size());
}
// -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ; /**
// prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt). * Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
* prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
* Sans champ quests (bundle legacy) : conversion des chapitres qui jouaient le rôle
* de quête — questMap est alors clé par ANCIEN CHAPTER id (pas de collision : on
* compare chapter-id à chapter-id).
*/
private void insertQuests(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.QuestDto d : nullSafe(export.quests())) { for (ContentExport.QuestDto d : nullSafe(export.quests())) {
QuestJpaEntity e = new QuestJpaEntity(); QuestJpaEntity e = new QuestJpaEntity();
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
@@ -206,15 +237,14 @@ class CampaignContentInserter {
maps.questMap.put(d.id(), questRepo.save(e).getId()); maps.questMap.put(d.id(), questRepo.save(e).getId());
} }
// -- Quest legacy (bundle SANS champ quests) : conversion des chapitres qui jouaient
// le rôle de quête. Ici questMap est clé par ANCIEN CHAPTER id (pas de collision :
// on compare chapter-id à chapter-id).
if (export.quests() == null) { if (export.quests() == null) {
legacyQuestConverter.convertLegacyChaptersToQuests(export, maps); legacyQuestConverter.convertLegacyChaptersToQuests(export, maps);
} }
result.count("quests", maps.questMap.size()); result.count("quests", maps.questMap.size());
}
// -- Npc (relatedPageIds remappe en 2e passe) /** Npc (relatedPageIds remappe en 2e passe). */
private void insertNpcs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.NpcDto d : nullSafe(export.npcs())) { for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity(); NpcJpaEntity e = new NpcJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -230,8 +260,10 @@ class CampaignContentInserter {
maps.npcMap.put(d.id(), npcRepo.save(e).getId()); maps.npcMap.put(d.id(), npcRepo.save(e).getId());
} }
result.count("npcs", maps.npcMap.size()); result.count("npcs", maps.npcMap.size());
}
// -- Enemy /** Enemy. */
private void insertEnemies(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) { for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
EnemyJpaEntity e = new EnemyJpaEntity(); EnemyJpaEntity e = new EnemyJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -249,8 +281,10 @@ class CampaignContentInserter {
maps.enemyMap.put(d.id(), enemyRepo.save(e).getId()); maps.enemyMap.put(d.id(), enemyRepo.save(e).getId());
} }
result.count("enemies", maps.enemyMap.size()); result.count("enemies", maps.enemyMap.size());
}
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe) /** Scene (enemyIds + relatedPageIds + branches remappes en 2e passe). */
private void insertScenes(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
for (ContentExport.SceneDto d : nullSafe(export.scenes())) { for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
SceneJpaEntity e = new SceneJpaEntity(); SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name()); e.setName(d.name());
@@ -270,15 +304,7 @@ class CampaignContentInserter {
e.setEnemyIds(d.enemyIds()); // remappe en 2e passe e.setEnemyIds(d.enemyIds()); // remappe en 2e passe
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
e.setIllustrationImageIds(IdRemapper.remapStringList(maps.imageMap, d.illustrationImageIds())); e.setIllustrationImageIds(IdRemapper.remapStringList(maps.imageMap, d.illustrationImageIds()));
// Battlemaps : ids StoredFile remappes via storedFileMap. Les exports anterieurs e.setBattlemaps(resolveBattlemaps(d, maps));
// a V22 portaient une paire unique -> reconstituee en premiere entree de liste.
List<SceneBattlemap> battlemaps = d.battlemaps();
if ((battlemaps == null || battlemaps.isEmpty())
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
}
battlemaps = IdRemapper.remapBattlemaps(maps.storedFileMap, battlemaps);
e.setBattlemaps(battlemaps != null ? battlemaps : List.of());
e.setGraphX(d.graphX()); e.setGraphX(d.graphX());
e.setGraphY(d.graphY()); e.setGraphY(d.graphY());
e.setBranches(d.branches()); // remappe en 2e passe e.setBranches(d.branches()); // remappe en 2e passe
@@ -290,6 +316,20 @@ class CampaignContentInserter {
result.count("scenes", maps.sceneMap.size()); result.count("scenes", maps.sceneMap.size());
} }
/**
* Battlemaps d'une scène, ids StoredFile remappes via storedFileMap. Les exports
* anterieurs a V22 portaient une paire unique -> reconstituee en premiere entree de liste.
*/
private static List<SceneBattlemap> resolveBattlemaps(ContentExport.SceneDto d, ImportIdMaps maps) {
List<SceneBattlemap> battlemaps = d.battlemaps();
if ((battlemaps == null || battlemaps.isEmpty())
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
}
battlemaps = IdRemapper.remapBattlemaps(maps.storedFileMap, battlemaps);
return battlemaps != null ? battlemaps : List.of();
}
private static <T> List<T> nullSafe(List<T> list) { private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of(); return list != null ? list : List.of();
} }

View File

@@ -191,30 +191,96 @@ public class ExportService {
CampaignJpaEntity campaign = campaignRepo.findById(cid) CampaignJpaEntity campaign = campaignRepo.findById(cid)
.orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid)); .orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid));
// Prep : clôture structurelle de la campagne. StructuralClosure structure = loadStructuralClosure(cid);
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
LoreClosure lore = loadLoreClosure(campaign, req);
PlayClosure play = loadPlayClosure(cid, req);
// Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture.
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid);
// Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active).
BinaryClosure binaries = req.includeImages()
? loadBinaryClosure(structure, lore, play, campaignQuests) : BinaryClosure.EMPTY;
ContentExport.CampaignDto campaignDto = campaignDto(campaign, req.includeLore());
ContentExport.Manifest manifest =
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName());
return new ContentExport(manifest,
map(gsEntities, this::toGameSystemDto),
map(lore.lores(), this::toLoreDto),
map(lore.loreNodes(), this::toLoreNodeDto),
map(lore.templates(), this::toTemplateDto),
map(lore.pages(), this::toPageDto),
List.of(campaignDto),
map(structure.arcs(), this::toArcDto),
map(structure.chapters(), this::toChapterDto),
map(structure.scenes(), this::toSceneDto),
map(play.characters(), this::toCharacterDto),
map(structure.npcs(), this::toNpcDto),
map(structure.enemies(), this::toEnemyDto),
map(structure.catalogs(), this::toItemCatalogDto),
map(structure.tables(), this::toRandomTableDto),
map(binaries.images(), this::toImageDto),
map(binaries.files(), this::toStoredFileDto),
map(play.playthroughs(), this::toPlaythroughDto),
map(play.sessions(), this::toSessionDto),
map(play.entries(), this::toSessionEntryDto),
map(play.flags(), this::toFlagDto),
map(play.questProgressions(), this::toQuestProgressionDto),
map(campaignQuests, this::toQuestDto),
map(play.clocks(), this::toClockDto),
map(play.fronts(), this::toFrontDto));
}
/** Clôture structurelle de la campagne : arcs -> chapitres -> scènes, PNJ, ennemis, catalogues, tables. */
private record StructuralClosure(
List<ArcJpaEntity> arcs, List<ChapterJpaEntity> chapters, List<SceneJpaEntity> scenes,
List<NpcJpaEntity> npcs, List<EnemyJpaEntity> enemies,
List<ItemCatalogJpaEntity> catalogs, List<RandomTableJpaEntity> tables) {}
private StructuralClosure loadStructuralClosure(Long cid) {
List<ArcJpaEntity> arcEntities = arcRepo.findByCampaignId(cid); List<ArcJpaEntity> arcEntities = arcRepo.findByCampaignId(cid);
List<ChapterJpaEntity> chapterEntities = arcEntities.stream() List<ChapterJpaEntity> chapterEntities = arcEntities.stream()
.flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList(); .flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
List<SceneJpaEntity> sceneEntities = chapterEntities.stream() List<SceneJpaEntity> sceneEntities = chapterEntities.stream()
.flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList(); .flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
List<NpcJpaEntity> npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid); return new StructuralClosure(arcEntities, chapterEntities, sceneEntities,
List<EnemyJpaEntity> enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid); npcRepo.findByCampaignIdOrderByOrderAsc(cid), enemyRepo.findByCampaignIdOrderByOrderAsc(cid),
List<ItemCatalogJpaEntity> catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid); itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid), randomTableRepo.findByCampaignIdOrderByOrderAsc(cid));
List<RandomTableJpaEntity> tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid); }
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent). /** Univers (lore) lié à la campagne : optionnel selon {@code req.includeLore()}. */
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId())); private record LoreClosure(
List<LoreJpaEntity> lores, List<LoreNodeJpaEntity> loreNodes,
List<TemplateJpaEntity> templates, List<PageJpaEntity> pages) {
private static final LoreClosure EMPTY = new LoreClosure(List.of(), List.of(), List.of(), List.of());
}
// Univers (lore) lié : optionnel. private LoreClosure loadLoreClosure(CampaignJpaEntity campaign, ExportRequest req) {
Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null; Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null;
List<LoreJpaEntity> loreEntities = lid != null ? singleton(loreRepo, lid) : List.of(); if (lid == null) return LoreClosure.EMPTY;
List<LoreNodeJpaEntity> loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of(); return new LoreClosure(singleton(loreRepo, lid), loreNodeRepo.findByLoreId(lid),
List<TemplateJpaEntity> templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of(); templateRepo.findByLoreId(lid), pageRepo.findByLoreId(lid));
List<PageJpaEntity> pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of(); }
// Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie, /**
// donc « sans jeu » = sans feuilles de perso. * Espace de jeu (parties -> séances/journal/flags/quêtes + feuilles de perso) : optionnel
List<PlaythroughJpaEntity> ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of(); * selon {@code req.includePlay()}. Les feuilles de perso appartiennent à une Partie, donc
* « sans jeu » = sans feuilles de perso.
*/
private record PlayClosure(
List<PlaythroughJpaEntity> playthroughs, List<SessionJpaEntity> sessions,
List<SessionEntryJpaEntity> entries, List<PlaythroughFlagJpaEntity> flags,
List<QuestProgressionJpaEntity> questProgressions, List<ClockJpaEntity> clocks,
List<FrontJpaEntity> fronts, List<CharacterJpaEntity> characters) {
private static final PlayClosure EMPTY = new PlayClosure(
List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of());
}
private PlayClosure loadPlayClosure(Long cid, ExportRequest req) {
if (!req.includePlay()) return PlayClosure.EMPTY;
List<PlaythroughJpaEntity> ptEntities = playthroughRepo.findByCampaignId(cid);
List<SessionJpaEntity> sessionEntities = ptEntities.stream() List<SessionJpaEntity> sessionEntities = ptEntities.stream()
.flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList(); .flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream() List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream()
@@ -229,74 +295,52 @@ public class ExportService {
.flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); .flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
List<CharacterJpaEntity> characterEntities = ptEntities.stream() List<CharacterJpaEntity> characterEntities = ptEntities.stream()
.flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList(); .flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
return new PlayClosure(ptEntities, sessionEntities, entryEntities, flagEntities,
questEntities, clockEntities, frontEntities, characterEntities);
}
// Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture. /** Binaires (images/fichiers) référencés par la clôture exportée. */
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid); private record BinaryClosure(List<ImageJpaEntity> images, List<StoredFileJpaEntity> files) {
private static final BinaryClosure EMPTY = new BinaryClosure(List.of(), List.of());
}
// Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active). private BinaryClosure loadBinaryClosure(StructuralClosure structure, LoreClosure lore, PlayClosure play,
List<ImageJpaEntity> imageEntities = List.of(); List<QuestJpaEntity> campaignQuests) {
List<StoredFileJpaEntity> fileEntities = List.of(); Set<String> imageRefs = new LinkedHashSet<>();
if (req.includeImages()) { structure.arcs().forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
Set<String> imageRefs = new LinkedHashSet<>(); structure.chapters().forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds())); campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds()));
chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds())); structure.scenes().forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds())); structure.scenes().forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
sceneEntities.forEach(s -> addAll(imageRefs, s.getIllustrationImageIds())); structure.npcs().forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
sceneEntities.forEach(s -> addRoomImageRefs(imageRefs, s.getRooms())); structure.enemies().forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
npcEntities.forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); }); play.characters().forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
enemyEntities.forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); }); lore.pages().forEach(p -> addImageValues(imageRefs, p.getImageValues()));
characterEntities.forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); }); List<ImageJpaEntity> imageEntities = imageRefs.stream()
pageEntities.forEach(p -> addImageValues(imageRefs, p.getImageValues())); .map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
imageEntities = imageRefs.stream() .map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
.map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull) .distinct().toList();
.map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
.distinct().toList();
Set<Long> fileRefs = new LinkedHashSet<>(); Set<Long> fileRefs = new LinkedHashSet<>();
sceneEntities.forEach(s -> { structure.scenes().forEach(s -> {
if (s.getBattlemaps() == null) return; if (s.getBattlemaps() == null) return;
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); }); s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
}); });
fileEntities = fileRefs.stream() List<StoredFileJpaEntity> fileEntities = fileRefs.stream()
.map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList(); .map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
}
// Campaign DTO : si le lore n'est pas exporté, on neutralise loreId (évite une return new BinaryClosure(imageEntities, fileEntities);
// référence pendante vers un univers absent à l'import). }
ContentExport.CampaignDto campaignDto = toCampaignDto(campaign);
if (!req.includeLore()) {
campaignDto = new ContentExport.CampaignDto(campaignDto.id(), campaignDto.name(),
campaignDto.description(), campaignDto.arcsCount(), campaignDto.playerCount(),
null, campaignDto.gameSystemId());
}
ContentExport.Manifest manifest = /**
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName()); * DTO Campaign : si le lore n'est pas exporté, on neutralise loreId (évite une
return new ContentExport(manifest, * référence pendante vers un univers absent à l'import).
map(gsEntities, this::toGameSystemDto), */
map(loreEntities, this::toLoreDto), private ContentExport.CampaignDto campaignDto(CampaignJpaEntity campaign, boolean includeLore) {
map(loreNodeEntities, this::toLoreNodeDto), ContentExport.CampaignDto dto = toCampaignDto(campaign);
map(templateEntities, this::toTemplateDto), if (includeLore) return dto;
map(pageEntities, this::toPageDto), return new ContentExport.CampaignDto(dto.id(), dto.name(), dto.description(),
List.of(campaignDto), dto.arcsCount(), dto.playerCount(), null, dto.gameSystemId());
map(arcEntities, this::toArcDto),
map(chapterEntities, this::toChapterDto),
map(sceneEntities, this::toSceneDto),
map(characterEntities, this::toCharacterDto),
map(npcEntities, this::toNpcDto),
map(enemyEntities, this::toEnemyDto),
map(catalogEntities, this::toItemCatalogDto),
map(tableEntities, this::toRandomTableDto),
map(imageEntities, this::toImageDto),
map(fileEntities, this::toStoredFileDto),
map(ptEntities, this::toPlaythroughDto),
map(sessionEntities, this::toSessionDto),
map(entryEntities, this::toSessionEntryDto),
map(flagEntities, this::toFlagDto),
map(questEntities, this::toQuestProgressionDto),
map(campaignQuests, this::toQuestDto),
map(clockEntities, this::toClockDto),
map(frontEntities, this::toFrontDto));
} }
// ----- Helpers de chargement ----- // ----- Helpers de chargement -----
@@ -345,44 +389,63 @@ public class ExportService {
zip.closeEntry(); zip.closeEntry();
// Binaires images : uniquement ceux reellement references. // Binaires images : uniquement ceux reellement references.
Set<String> referenced = collectReferencedStorageKeys(export); writeImageBinaries(zip, collectReferencedStorageKeys(export));
Set<String> written = new LinkedHashSet<>();
for (String key : referenced) {
if (key == null || key.isBlank() || !written.add(key)) {
continue;
}
try (InputStream data = imageStorage.download(key)) {
if (data == null) {
continue; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("images/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
// Binaires fichiers (battlemaps : media + sidecar) : ceux references par // Binaires fichiers (battlemaps : media + sidecar) : ceux references par
// les scenes. Stockes a part sous "files/<storageKey>". // les scenes. Stockes a part sous "files/<storageKey>".
Set<String> referencedFiles = collectReferencedFileStorageKeys(export); writeFileBinaries(zip, collectReferencedFileStorageKeys(export));
Set<String> filesWritten = new LinkedHashSet<>();
for (String key : referencedFiles) {
if (key == null || key.isBlank() || !filesWritten.add(key)) {
continue;
}
try (InputStream data = fileStorage.download(key)) {
if (data == null) {
continue; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("files/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du zip d'export", e); throw new UncheckedIOException("Echec de la generation du zip d'export", e);
} }
} }
/** Ecrit un binaire d'image par cle REFERENCEE (deduplique, cles orphelines/vides ignorees). */
private void writeImageBinaries(ZipOutputStream zip, Set<String> referenced) throws IOException {
Set<String> written = new LinkedHashSet<>();
for (String key : referenced) {
if (isWritable(key, written)) {
writeImageEntry(zip, key);
}
}
}
private void writeImageEntry(ZipOutputStream zip, String key) throws IOException {
try (InputStream data = imageStorage.download(key)) {
if (data == null) {
return; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("images/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
/** Ecrit un binaire de fichier (battlemap) par cle REFERENCEE (deduplique, cles orphelines/vides ignorees). */
private void writeFileBinaries(ZipOutputStream zip, Set<String> referenced) throws IOException {
Set<String> written = new LinkedHashSet<>();
for (String key : referenced) {
if (isWritable(key, written)) {
writeFileEntry(zip, key);
}
}
}
private void writeFileEntry(ZipOutputStream zip, String key) throws IOException {
try (InputStream data = fileStorage.download(key)) {
if (data == null) {
return; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("files/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
/** Vrai si la cle est ecrivable : non nulle/vide, et pas deja ecrite (marque au passage). */
private static boolean isWritable(String key, Set<String> written) {
return key != null && !key.isBlank() && written.add(key);
}
/** /**
* Collecte tous les storageKeys references par le contenu exporte : * Collecte tous les storageKeys references par le contenu exporte :
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy * Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
@@ -392,35 +455,58 @@ public class ExportService {
// Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload), // Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload),
// PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images // PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images
// exportées — même logique que collectReferencedFileStorageKeys pour les fichiers. // exportées — même logique que collectReferencedFileStorageKeys pour les fichiers.
java.util.Map<String, String> keyByImageId = indexImagesById(export);
Set<String> refs = collectImageIdRefs(export);
return resolveStorageKeys(refs, keyByImageId);
}
private static java.util.Map<String, String> indexImagesById(ContentExport export) {
java.util.Map<String, String> keyByImageId = new java.util.HashMap<>(); java.util.Map<String, String> keyByImageId = new java.util.HashMap<>();
for (ContentExport.ImageDto img : export.images()) { for (ContentExport.ImageDto img : export.images()) {
if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey()); if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey());
} }
return keyByImageId;
}
/** Collecte les IDs d'images référencés par toutes les entités exportées (avant résolution en storageKey). */
private Set<String> collectImageIdRefs(ContentExport export) {
Set<String> refs = new LinkedHashSet<>(); Set<String> refs = new LinkedHashSet<>();
for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds()); for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds()); for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
if (export.quests() != null) { addQuestImageRefs(refs, export.quests());
for (ContentExport.QuestDto q : export.quests()) addAll(refs, q.illustrationImageIds());
}
for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds()); for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds());
for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms()); for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms());
for (ContentExport.CharacterDto c : export.characters()) { for (ContentExport.CharacterDto c : export.characters()) addCharacterImageRefs(refs, c);
add(refs, c.portraitImageId()); for (ContentExport.NpcDto n : export.npcs()) addNpcImageRefs(refs, n);
add(refs, c.headerImageId()); for (ContentExport.EnemyDto e : export.enemies()) addEnemyImageRefs(refs, e);
addImageValues(refs, c.imageValues());
}
for (ContentExport.NpcDto n : export.npcs()) {
add(refs, n.portraitImageId());
add(refs, n.headerImageId());
addImageValues(refs, n.imageValues());
}
for (ContentExport.EnemyDto e : export.enemies()) {
add(refs, e.portraitImageId());
add(refs, e.headerImageId());
addImageValues(refs, e.imageValues());
}
for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues()); for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues());
return refs;
}
private void addQuestImageRefs(Set<String> refs, List<ContentExport.QuestDto> quests) {
if (quests == null) return;
for (ContentExport.QuestDto q : quests) addAll(refs, q.illustrationImageIds());
}
private void addCharacterImageRefs(Set<String> refs, ContentExport.CharacterDto c) {
add(refs, c.portraitImageId());
add(refs, c.headerImageId());
addImageValues(refs, c.imageValues());
}
private void addNpcImageRefs(Set<String> refs, ContentExport.NpcDto n) {
add(refs, n.portraitImageId());
add(refs, n.headerImageId());
addImageValues(refs, n.imageValues());
}
private void addEnemyImageRefs(Set<String> refs, ContentExport.EnemyDto e) {
add(refs, e.portraitImageId());
add(refs, e.headerImageId());
addImageValues(refs, e.imageValues());
}
private static Set<String> resolveStorageKeys(Set<String> refs, java.util.Map<String, String> keyByImageId) {
Set<String> keys = new LinkedHashSet<>(); Set<String> keys = new LinkedHashSet<>();
for (String ref : refs) { for (String ref : refs) {
String key = keyByImageId.get(ref); String key = keyByImageId.get(ref);

View File

@@ -46,7 +46,7 @@ final class IdRemapper {
} }
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) { static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
if (ids == null) return null; if (ids == null) return List.of();
List<String> out = new ArrayList<>(ids.size()); List<String> out = new ArrayList<>(ids.size());
for (String id : ids) out.add(remapStringId(map, id)); for (String id : ids) out.add(remapStringId(map, id));
return out; return out;
@@ -58,7 +58,7 @@ final class IdRemapper {
*/ */
static Map<String, List<String>> remapImageValues(Map<Long, Long> imageMap, static Map<String, List<String>> remapImageValues(Map<Long, Long> imageMap,
Map<String, List<String>> imageValues) { Map<String, List<String>> imageValues) {
if (imageValues == null) return null; if (imageValues == null) return Map.of();
Map<String, List<String>> out = new LinkedHashMap<>(); Map<String, List<String>> out = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> e : imageValues.entrySet()) { for (Map.Entry<String, List<String>> e : imageValues.entrySet()) {
out.put(e.getKey(), remapStringList(imageMap, e.getValue())); out.put(e.getKey(), remapStringList(imageMap, e.getValue()));
@@ -75,7 +75,7 @@ final class IdRemapper {
*/ */
static <V> Map<String, Map<String, V>> remapImageFraming(Map<Long, Long> imageMap, static <V> Map<String, Map<String, V>> remapImageFraming(Map<Long, Long> imageMap,
Map<String, Map<String, V>> framing) { Map<String, Map<String, V>> framing) {
if (framing == null) return null; if (framing == null) return Map.of();
Map<String, Map<String, V>> out = new LinkedHashMap<>(); Map<String, Map<String, V>> out = new LinkedHashMap<>();
for (Map.Entry<String, Map<String, V>> outer : framing.entrySet()) { for (Map.Entry<String, Map<String, V>> outer : framing.entrySet()) {
Map<String, V> inner = outer.getValue(); Map<String, V> inner = outer.getValue();
@@ -98,7 +98,7 @@ final class IdRemapper {
* mutable (Lombok), on réécrit EN PLACE — la liste vient d'une désérialisation jetable. * mutable (Lombok), on réécrit EN PLACE — la liste vient d'une désérialisation jetable.
*/ */
static List<Room> remapRoomImages(Map<Long, Long> imageMap, List<Room> rooms) { static List<Room> remapRoomImages(Map<Long, Long> imageMap, List<Room> rooms) {
if (rooms == null) return null; if (rooms == null) return List.of();
for (Room r : rooms) { for (Room r : rooms) {
if (r == null) continue; if (r == null) continue;
r.setIllustrationImageIds(remapStringList(imageMap, r.getIllustrationImageIds())); r.setIllustrationImageIds(remapStringList(imageMap, r.getIllustrationImageIds()));
@@ -113,7 +113,7 @@ final class IdRemapper {
* on reconstruit chaque entrée. * on reconstruit chaque entrée.
*/ */
static List<SceneBattlemap> remapBattlemaps(Map<Long, Long> fileMap, List<SceneBattlemap> battlemaps) { static List<SceneBattlemap> remapBattlemaps(Map<Long, Long> fileMap, List<SceneBattlemap> battlemaps) {
if (battlemaps == null) return null; if (battlemaps == null) return List.of();
List<SceneBattlemap> out = new ArrayList<>(battlemaps.size()); List<SceneBattlemap> out = new ArrayList<>(battlemaps.size());
for (SceneBattlemap bm : battlemaps) { for (SceneBattlemap bm : battlemaps) {
out.add(new SceneBattlemap(bm.label(), out.add(new SceneBattlemap(bm.label(),
@@ -129,7 +129,7 @@ final class IdRemapper {
* Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1). * Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1).
*/ */
static List<Prerequisite> remapPrerequisites(Map<Long, Long> idMap, List<Prerequisite> prereqs) { static List<Prerequisite> remapPrerequisites(Map<Long, Long> idMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null; if (prereqs == null) return List.of();
List<Prerequisite> out = new ArrayList<>(prereqs.size()); List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) { for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) { if (p instanceof Prerequisite.QuestCompleted qc) {
@@ -144,7 +144,7 @@ final class IdRemapper {
/** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */ /** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */
static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap, static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap,
List<QuestNodeRef> nodes) { List<QuestNodeRef> nodes) {
if (nodes == null) return null; if (nodes == null) return List.of();
List<QuestNodeRef> out = new ArrayList<>(nodes.size()); List<QuestNodeRef> out = new ArrayList<>(nodes.size());
for (QuestNodeRef n : nodes) { for (QuestNodeRef n : nodes) {
Map<Long, Long> map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap; Map<Long, Long> map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap;
@@ -154,7 +154,7 @@ final class IdRemapper {
} }
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) { static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null; if (branches == null) return List.of();
List<SceneBranch> out = new ArrayList<>(branches.size()); List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) { for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind())); out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind()));

View File

@@ -52,41 +52,47 @@ class ImageImporter {
ImportIdMaps maps, ImportIdMaps maps,
ImportResult.Builder result) { ImportResult.Builder result) {
for (ContentExport.ImageDto meta : nullSafe(export.images())) { for (ContentExport.ImageDto meta : nullSafe(export.images())) {
String storageKey = meta.storageKey(); importImage(meta, imageBinaries, maps, result);
if (storageKey == null || storageKey.isBlank()) continue;
var existing = imageRepo.findByStorageKey(storageKey);
if (existing.isPresent()) {
// Image déjà présente : réutilisée (pas de réupload). L'ancien id pointe
// désormais la ligne existante.
mapId(maps, meta.id(), existing.get().getId());
result.imageReused();
continue;
}
byte[] data = imageBinaries.get(storageKey);
if (data == null) {
// Métadonnée sans binaire (ex. image orpheline non embarquée) : rien à
// matérialiser, pas de cible de remap.
continue;
}
String contentType = meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
mapId(maps, meta.id(), imageRepo.save(e).getId());
result.imageUploaded();
} }
} }
/** Traite une métadonnée d'image : réutilise la ligne existante, sinon matérialise le binaire. */
private void importImage(ContentExport.ImageDto meta, Map<String, byte[]> imageBinaries,
ImportIdMaps maps, ImportResult.Builder result) {
String storageKey = meta.storageKey();
if (storageKey == null || storageKey.isBlank()) return;
var existing = imageRepo.findByStorageKey(storageKey);
if (existing.isPresent()) {
// Image déjà présente : réutilisée (pas de réupload). L'ancien id pointe
// désormais la ligne existante.
mapId(maps, meta.id(), existing.get().getId());
result.imageReused();
return;
}
byte[] data = imageBinaries.get(storageKey);
if (data == null) {
// Métadonnée sans binaire (ex. image orpheline non embarquée) : rien à
// matérialiser, pas de cible de remap.
return;
}
String contentType = meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
mapId(maps, meta.id(), imageRepo.save(e).getId());
result.imageUploaded();
}
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) { private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
if (oldId != null && newId != null) maps.imageMap.put(oldId, newId); if (oldId != null && newId != null) maps.imageMap.put(oldId, newId);
} }

View File

@@ -58,7 +58,20 @@ class ImportReferenceRemapper {
} }
void remap(ImportIdMaps maps) { void remap(ImportIdMaps maps) {
// LoreNode.parentId remapLoreNodeParents(maps);
remapTemplateDefaultNodes(maps);
remapCampaignWeakRefs(maps);
remapPageRelatedPages(maps);
remapArcRelatedPages(maps);
remapChapterRelatedPages(maps);
remapNpcRelatedPages(maps);
remapSceneRefs(maps);
remapQuestRefs(maps);
remapClockTriggerRefs(maps);
}
/** LoreNode.parentId. */
private void remapLoreNodeParents(ImportIdMaps maps) {
for (LoreNodeJpaEntity e : maps.loreNodesToFix) { for (LoreNodeJpaEntity e : maps.loreNodesToFix) {
Long newParent = maps.loreNodeMap.get(e.getParentId()); Long newParent = maps.loreNodeMap.get(e.getParentId());
if (newParent != null) { if (newParent != null) {
@@ -66,8 +79,10 @@ class ImportReferenceRemapper {
loreNodeRepo.save(e); loreNodeRepo.save(e);
} }
} }
}
// Template.defaultNodeId /** Template.defaultNodeId. */
private void remapTemplateDefaultNodes(ImportIdMaps maps) {
for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) { for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) {
Long newTemplateId = maps.templateMap.get(d.id()); Long newTemplateId = maps.templateMap.get(d.id());
Long newNode = maps.loreNodeMap.get(d.defaultNodeId()); Long newNode = maps.loreNodeMap.get(d.defaultNodeId());
@@ -78,8 +93,10 @@ class ImportReferenceRemapper {
}); });
} }
} }
}
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). /** Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). */
private void remapCampaignWeakRefs(ImportIdMaps maps) {
for (Long newCampaignId : maps.campaignMap.values()) { for (Long newCampaignId : maps.campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> { campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId()); String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId());
@@ -89,40 +106,50 @@ class ImportReferenceRemapper {
campaignRepo.save(c); campaignRepo.save(c);
}); });
} }
}
// Page.relatedPageIds /** Page.relatedPageIds. */
private void remapPageRelatedPages(ImportIdMaps maps) {
for (Long newPageId : maps.pageMap.values()) { for (Long newPageId : maps.pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> { pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds())); p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds()));
pageRepo.save(p); pageRepo.save(p);
}); });
} }
}
// Arc.relatedPageIds /** Arc.relatedPageIds. */
private void remapArcRelatedPages(ImportIdMaps maps) {
for (Long newArcId : maps.arcMap.values()) { for (Long newArcId : maps.arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> { arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds())); a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds()));
arcRepo.save(a); arcRepo.save(a);
}); });
} }
}
// Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1) /** Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1). */
private void remapChapterRelatedPages(ImportIdMaps maps) {
for (Long newChapterId : maps.chapterMap.values()) { for (Long newChapterId : maps.chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> { chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds())); c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds()));
chapterRepo.save(c); chapterRepo.save(c);
}); });
} }
}
// Npc.relatedPageIds /** Npc.relatedPageIds. */
private void remapNpcRelatedPages(ImportIdMaps maps) {
for (Long newNpcId : maps.npcMap.values()) { for (Long newNpcId : maps.npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> { npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds())); n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds()));
npcRepo.save(n); npcRepo.save(n);
}); });
} }
}
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene) /** Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene). */
private void remapSceneRefs(ImportIdMaps maps) {
for (Long newSceneId : maps.sceneMap.values()) { for (Long newSceneId : maps.sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> { sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds())); s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds()));
@@ -131,9 +158,13 @@ class ImportReferenceRemapper {
sceneRepo.save(s); sceneRepo.save(s);
}); });
} }
}
// Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap), /**
// relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici. * Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
* relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
*/
private void remapQuestRefs(ImportIdMaps maps) {
for (Long newQuestId : maps.questMap.values()) { for (Long newQuestId : maps.questMap.values()) {
questRepo.findById(newQuestId).ifPresent(q -> { questRepo.findById(newQuestId).ifPresent(q -> {
q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites())); q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites()));
@@ -142,9 +173,13 @@ class ImportReferenceRemapper {
questRepo.save(q); questRepo.save(q);
}); });
} }
}
// Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée /**
// (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper). * Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
* (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
*/
private void remapClockTriggerRefs(ImportIdMaps maps) {
for (Long newClockId : maps.clockMap.values()) { for (Long newClockId : maps.clockMap.values()) {
clockRepo.findById(newClockId).ifPresent(c -> { clockRepo.findById(newClockId).ifPresent(c -> {
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) { if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {

View File

@@ -40,6 +40,8 @@ class LegacyQuestConverter {
* quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme * quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme
* pour les quêtes v2. * pour les quêtes v2.
*/ */
// S125 : faux positif — commentaires explicatifs (prose), pas de code mort.
@SuppressWarnings("java:S125")
void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) { void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) {
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>(); Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a); for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);

View File

@@ -46,38 +46,55 @@ class StoredFileImporter {
ImportResult.Builder result) { ImportResult.Builder result) {
int imported = 0; int imported = 0;
for (ContentExport.StoredFileDto meta : nullSafe(export.storedFiles())) { for (ContentExport.StoredFileDto meta : nullSafe(export.storedFiles())) {
String storageKey = meta.storageKey(); if (importOne(meta, fileBinaries, maps)) {
if (storageKey == null || storageKey.isBlank()) continue; imported++;
var existing = fileRepo.findByStorageKey(storageKey);
if (existing.isPresent()) {
// Deja present : reutilise (pas de reupload). L'ancien id pointe la ligne existante.
mapId(maps, meta.id(), existing.get().getId());
continue;
} }
byte[] data = fileBinaries.get(storageKey);
if (data == null) {
continue; // metadonnee sans binaire : rien a materialiser
}
String contentType = meta.contentType() != null
? meta.contentType() : "application/octet-stream";
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
fileStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
StoredFileJpaEntity e = new StoredFileJpaEntity();
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
mapId(maps, meta.id(), fileRepo.save(e).getId());
imported++;
} }
result.count("storedFiles", imported); result.count("storedFiles", imported);
} }
/**
* Traite une metadonnee de fichier. Reutilise la ligne existante si la cle est deja
* connue, sinon materialise le binaire et cree la ligne.
*
* @return {@code true} si un nouveau binaire a ete stocke + une ligne creee (a compter
* dans les imports), {@code false} sinon (cle vide, reutilisation, binaire absent)
*/
private boolean importOne(ContentExport.StoredFileDto meta,
Map<String, byte[]> fileBinaries,
ImportIdMaps maps) {
String storageKey = meta.storageKey();
if (storageKey == null || storageKey.isBlank()) {
return false;
}
var existing = fileRepo.findByStorageKey(storageKey);
if (existing.isPresent()) {
// Deja present : reutilise (pas de reupload). L'ancien id pointe la ligne existante.
mapId(maps, meta.id(), existing.get().getId());
return false;
}
byte[] data = fileBinaries.get(storageKey);
if (data == null) {
return false; // metadonnee sans binaire : rien a materialiser
}
String contentType = meta.contentType() != null
? meta.contentType() : "application/octet-stream";
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
fileStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
StoredFileJpaEntity e = new StoredFileJpaEntity();
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
mapId(maps, meta.id(), fileRepo.save(e).getId());
return true;
}
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) { private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
if (oldId != null && newId != null) maps.storedFileMap.put(oldId, newId); if (oldId != null && newId != null) maps.storedFileMap.put(oldId, newId);
} }

View File

@@ -124,13 +124,37 @@ public class FoundryExportService {
AssetRegistry assets = new AssetRegistry(); AssetRegistry assets = new AssetRegistry();
// Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order). ArcsQuestsScenes structure = buildArcsQuestsScenes(campaign, opts, assets);
List<FoundryBundle.Persona> npcs = buildNpcs(campaign, opts, npcTemplate, assets);
List<FoundryBundle.Persona> enemies = buildEnemies(campaign, opts, enemyTemplate, foundryActorType, assets);
List<FoundryBundle.RandomTable> randomTables = buildRandomTables(campaign, opts);
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign(
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
FoundryBundle.Data data = new FoundryBundle.Data(
FORMAT_VERSION, campaignNode,
new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()),
structure.arcs(), structure.quests(), structure.scenes(),
npcs, enemies, randomTables, assets.assets());
FoundryBundle.Manifest manifest =
buildManifest(campaign, exportedAt, structure, npcs, enemies, randomTables, assets);
return new BuiltBundle(manifest, data, assets.binaries());
}
/** Arcs -> Quetes -> Scenes, a plat + refs parent (regroupes : construits par la meme triple boucle). */
private record ArcsQuestsScenes(
List<FoundryBundle.Arc> arcs, List<FoundryBundle.Quest> quests, List<FoundryBundle.Scene> scenes) {}
/** Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order). */
private ArcsQuestsScenes buildArcsQuestsScenes(CampaignJpaEntity campaign, ExportOptions opts, AssetRegistry assets) {
List<FoundryBundle.Arc> arcs = new ArrayList<>(); List<FoundryBundle.Arc> arcs = new ArrayList<>();
List<FoundryBundle.Quest> quests = new ArrayList<>(); List<FoundryBundle.Quest> quests = new ArrayList<>();
List<FoundryBundle.Scene> scenes = new ArrayList<>(); List<FoundryBundle.Scene> scenes = new ArrayList<>();
List<ArcJpaEntity> arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
for (ArcJpaEntity arc : arcEntities) {
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) : // Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
// leurs illustrations ne sont pas embarquees. // leurs illustrations ne sont pas embarquees.
arcs.add(new FoundryBundle.Arc( arcs.add(new FoundryBundle.Arc(
@@ -150,40 +174,51 @@ public class FoundryExportService {
} }
} }
} }
return new ArcsQuestsScenes(arcs, quests, scenes);
}
// PNJ : purement journal — hors perimetre sans les journaux. /** PNJ : purement journal — hors perimetre sans les journaux. */
private List<FoundryBundle.Persona> buildNpcs(CampaignJpaEntity campaign, ExportOptions opts,
List<TemplateField> npcTemplate, AssetRegistry assets) {
List<FoundryBundle.Persona> npcs = new ArrayList<>(); List<FoundryBundle.Persona> npcs = new ArrayList<>();
if (opts.journals()) { if (!opts.journals()) return npcs;
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
npcs.add(new FoundryBundle.Persona( npcs.add(new FoundryBundle.Persona(
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null, assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets))); fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
}
} }
return npcs;
}
// Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token) /**
// ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que * Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token)
// les journaux -> non embarquees en mode cartes seules. * ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que
* les journaux -> non embarquees en mode cartes seules.
*/
private List<FoundryBundle.Persona> buildEnemies(CampaignJpaEntity campaign, ExportOptions opts,
List<TemplateField> enemyTemplate, String foundryActorType,
AssetRegistry assets) {
List<FoundryBundle.Persona> enemies = new ArrayList<>(); List<FoundryBundle.Persona> enemies = new ArrayList<>();
if (opts.maps() || opts.journals()) { if (!opts.maps() && !opts.journals()) return enemies;
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
enemies.add(new FoundryBundle.Persona( enemies.add(new FoundryBundle.Persona(
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(), str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
assets.image(e.getPortraitImageId()), assets.image(e.getPortraitImageId()),
opts.journals() ? assets.image(e.getHeaderImageId()) : null, opts.journals() ? assets.image(e.getHeaderImageId()) : null,
e.getLevel(), e.getLevel(),
e.getFoundryRef(), e.getFoundryRef(),
buildFoundryActor(e, enemyTemplate, foundryActorType), buildFoundryActor(e, enemyTemplate, foundryActorType),
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
opts.journals() ? e.getImageValues() : null, assets))); opts.journals() ? e.getImageValues() : null, assets)));
}
} }
return enemies;
}
private List<FoundryBundle.RandomTable> buildRandomTables(CampaignJpaEntity campaign, ExportOptions opts) {
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>(); List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
for (RandomTableJpaEntity t : opts.tables() if (!opts.tables()) return randomTables;
? randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId()) for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
: List.<RandomTableJpaEntity>of()) {
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>(); List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
if (t.getEntries() != null) { if (t.getEntries() != null) {
for (RandomTableEntryJpaEntity en : t.getEntries()) { for (RandomTableEntryJpaEntity en : t.getEntries()) {
@@ -194,29 +229,25 @@ public class FoundryExportService {
randomTables.add(new FoundryBundle.RandomTable( randomTables.add(new FoundryBundle.RandomTable(
str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries)); str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries));
} }
return randomTables;
}
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign( private FoundryBundle.Manifest buildManifest(CampaignJpaEntity campaign, String exportedAt,
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId()); ArcsQuestsScenes structure, List<FoundryBundle.Persona> npcs,
List<FoundryBundle.Persona> enemies,
FoundryBundle.Data data = new FoundryBundle.Data( List<FoundryBundle.RandomTable> randomTables, AssetRegistry assets) {
FORMAT_VERSION, campaignNode,
new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()),
arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
Map<String, Integer> counts = new LinkedHashMap<>(); Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("arcs", arcs.size()); counts.put("arcs", structure.arcs().size());
counts.put("quests", quests.size()); counts.put("quests", structure.quests().size());
counts.put("scenes", scenes.size()); counts.put("scenes", structure.scenes().size());
counts.put("npcs", npcs.size()); counts.put("npcs", npcs.size());
counts.put("enemies", enemies.size()); counts.put("enemies", enemies.size());
counts.put("randomTables", randomTables.size()); counts.put("randomTables", randomTables.size());
counts.put("assets", assets.assets().size()); counts.put("assets", assets.assets().size());
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest( return new FoundryBundle.Manifest(
FORMAT_VERSION, "loremind", appVersion, exportedAt, FORMAT_VERSION, "loremind", appVersion, exportedAt,
str(campaign.getId()), campaign.getName(), CONTENT_FORMAT, counts); str(campaign.getId()), campaign.getName(), CONTENT_FORMAT, counts);
return new BuiltBundle(manifest, data, assets.binaries());
} }
/** Serialise le bundle dans le flux au format .zip (binaires streames a la volee). */ /** Serialise le bundle dans le flux au format .zip (binaires streames a la volee). */
@@ -233,24 +264,36 @@ public class FoundryExportService {
zip.write(writer.writeValueAsBytes(bundle.data())); zip.write(writer.writeValueAsBytes(bundle.data()));
zip.closeEntry(); zip.closeEntry();
Set<String> written = new HashSet<>(); writeBinaries(zip, bundle.binaries());
for (BinaryRef ref : bundle.binaries()) {
if (!written.add(ref.path())) continue; // dedup defensif
InputStream data = ref.image()
? imageStorage.download(ref.storageKey())
: fileStorage.download(ref.storageKey());
if (data == null) continue; // cle orpheline : on ignore
try (data) {
zip.putNextEntry(new ZipEntry(ref.path()));
data.transferTo(zip);
zip.closeEntry();
}
}
} catch (IOException e) { } catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du bundle Foundry", e); throw new UncheckedIOException("Echec de la generation du bundle Foundry", e);
} }
} }
/** Ecrit chaque binaire reference, dedup par chemin cible (cle orpheline ignoree). */
private void writeBinaries(ZipOutputStream zip, List<BinaryRef> binaries) throws IOException {
Set<String> written = new HashSet<>();
for (BinaryRef ref : binaries) {
if (written.add(ref.path())) {
writeBinaryEntry(zip, ref);
}
}
}
private void writeBinaryEntry(ZipOutputStream zip, BinaryRef ref) throws IOException {
InputStream data = ref.image()
? imageStorage.download(ref.storageKey())
: fileStorage.download(ref.storageKey());
if (data == null) {
return; // cle orpheline : on ignore
}
try (data) {
zip.putNextEntry(new ZipEntry(ref.path()));
data.transferTo(zip);
zip.closeEntry();
}
}
// ----- Mapping Scene ----- // ----- Mapping Scene -----
private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) { private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) {
@@ -326,21 +369,33 @@ public class FoundryExportService {
private FoundryBundle.FoundryActor buildFoundryActor(EnemyJpaEntity e, List<TemplateField> template, String actorType) { private FoundryBundle.FoundryActor buildFoundryActor(EnemyJpaEntity e, List<TemplateField> template, String actorType) {
if (actorType == null || actorType.isBlank() || template == null) return null; if (actorType == null || actorType.isBlank() || template == null) return null;
if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null; if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null;
Map<String, Object> system = new LinkedHashMap<>(); Map<String, Object> system = new LinkedHashMap<>();
Map<String, String> values = e.getValues(); Map<String, String> values = e.getValues();
boolean any = false; boolean any = false;
for (TemplateField f : template) { for (TemplateField f : template) {
if (f == null || f.getName() == null) continue; FieldContribution c = foundryFieldContribution(f, values);
String path = f.getFoundryPath(); if (c != null) {
if (path == null || path.isBlank()) continue; setPath(system, c.path(), c.value());
String raw = values != null ? values.get(f.getName()) : null; any = true;
if (raw == null || raw.isBlank()) continue; }
setPath(system, path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
any = true;
} }
return any ? new FoundryBundle.FoundryActor(actorType, system) : null; return any ? new FoundryBundle.FoundryActor(actorType, system) : null;
} }
/** Chemin Foundry ("a.b.c") + valeur d'un champ template pour {@link #buildFoundryActor}. */
private record FieldContribution(String path, Object value) {}
/** Contribution d'un champ, ou null si non mappe/sans valeur (nom, chemin Foundry ou valeur absent). */
private static FieldContribution foundryFieldContribution(TemplateField f, Map<String, String> values) {
if (f == null || f.getName() == null) return null;
String path = f.getFoundryPath();
if (path == null || path.isBlank()) return null;
String raw = values != null ? values.get(f.getName()) : null;
if (raw == null || raw.isBlank()) return null;
return new FieldContribution(path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
}
/** Pose une valeur dans un objet imbriqué selon un chemin pointé ("a.b.c"). */ /** Pose une valeur dans un objet imbriqué selon un chemin pointé ("a.b.c"). */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static void setPath(Map<String, Object> root, String path, Object value) { private static void setPath(Map<String, Object> root, String path, Object value) {
@@ -370,52 +425,72 @@ public class FoundryExportService {
Map<String, Map<String, String>> keyValueValues, Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues, Map<String, List<String>> imageValues,
AssetRegistry assets) { AssetRegistry assets) {
List<FoundryBundle.Field> out = new ArrayList<>();
// Repli : pas de template -> paires brutes label=cle.
if (template == null || template.isEmpty()) { if (template == null || template.isEmpty()) {
if (values != null) { return rawFields(values);
for (Map.Entry<String, String> e : values.entrySet()) {
if (e.getValue() != null && !e.getValue().isBlank()) {
out.add(new FoundryBundle.Field("text", e.getKey(), e.getValue(), null, null));
}
}
}
return out;
} }
List<FoundryBundle.Field> out = new ArrayList<>();
for (TemplateField f : template) { for (TemplateField f : template) {
if (f == null || f.getName() == null || f.getType() == null) continue; FoundryBundle.Field field = toField(f, values, keyValueValues, imageValues, assets);
FieldType type = f.getType(); if (field != null) {
if (type == FieldType.TEXT || type == FieldType.NUMBER) { out.add(field);
String v = values != null ? values.get(f.getName()) : null;
if (v != null && !v.isBlank()) {
out.add(new FoundryBundle.Field(type == FieldType.NUMBER ? "number" : "text",
f.getName(), v, null, null));
}
} else if (type == FieldType.KEY_VALUE_LIST) {
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
List<String> labels = f.getLabels();
if (inner != null && labels != null) {
List<FoundryBundle.Entry> entries = new ArrayList<>();
for (String label : labels) {
String v = inner.get(label);
if (v != null && !v.isBlank()) entries.add(new FoundryBundle.Entry(label, v));
}
if (!entries.isEmpty()) {
out.add(new FoundryBundle.Field("keyValueList", f.getName(), null, entries, null));
}
}
} else if (type == FieldType.IMAGE) {
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
List<String> assetIds = assets.images(ids);
if (!assetIds.isEmpty()) {
out.add(new FoundryBundle.Field("image", f.getName(), null, null, assetIds));
}
} }
// TABLE : pas de stockage cote PNJ/Ennemi -> ignore.
} }
return out; return out;
} }
/** Repli sans template : paires brutes label=cle (valeurs non vides uniquement). */
private static List<FoundryBundle.Field> rawFields(Map<String, String> values) {
List<FoundryBundle.Field> out = new ArrayList<>();
if (values != null) {
for (Map.Entry<String, String> e : values.entrySet()) {
if (e.getValue() != null && !e.getValue().isBlank()) {
out.add(new FoundryBundle.Field("text", e.getKey(), e.getValue(), null, null));
}
}
}
return out;
}
/** Un champ Foundry pour ce TemplateField (dispatch par type), ou null si non mappe/non applicable/vide. */
private static FoundryBundle.Field toField(TemplateField f,
Map<String, String> values,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues,
AssetRegistry assets) {
if (f == null || f.getName() == null || f.getType() == null) return null;
return switch (f.getType()) {
case TEXT, NUMBER -> textOrNumberField(f, values);
case KEY_VALUE_LIST -> keyValueField(f, keyValueValues);
case IMAGE -> imageField(f, imageValues, assets);
case TABLE -> null; // pas de stockage cote PNJ/Ennemi -> ignore.
};
}
private static FoundryBundle.Field textOrNumberField(TemplateField f, Map<String, String> values) {
String v = values != null ? values.get(f.getName()) : null;
if (v == null || v.isBlank()) return null;
return new FoundryBundle.Field(f.getType() == FieldType.NUMBER ? "number" : "text", f.getName(), v, null, null);
}
private static FoundryBundle.Field keyValueField(TemplateField f, Map<String, Map<String, String>> keyValueValues) {
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
List<String> labels = f.getLabels();
if (inner == null || labels == null) return null;
List<FoundryBundle.Entry> entries = new ArrayList<>();
for (String label : labels) {
String v = inner.get(label);
if (v != null && !v.isBlank()) entries.add(new FoundryBundle.Entry(label, v));
}
return entries.isEmpty() ? null : new FoundryBundle.Field("keyValueList", f.getName(), null, entries, null);
}
private static FoundryBundle.Field imageField(TemplateField f, Map<String, List<String>> imageValues,
AssetRegistry assets) {
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
List<String> assetIds = assets.images(ids);
return assetIds.isEmpty() ? null : new FoundryBundle.Field("image", f.getName(), null, null, assetIds);
}
// ----- Registre des assets (images + battlemaps), dedup + index ----- // ----- Registre des assets (images + battlemaps), dedup + index -----
private final class AssetRegistry { private final class AssetRegistry {

View File

@@ -0,0 +1,178 @@
package com.loremind.infrastructure.transfer.pdf;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/**
* Primitives de rendu HTML/XHTML du livret PDF, sans etat : echappement XML sur mesure,
* fragments repetes, formatage, et le rendu « libelle + texte riche » (paragraphes et
* listes a puces) partage par toutes les sections. Importe en statique par PdfExportService.
*/
final class PdfHtml {
private PdfHtml() {}
// ----- Fragments HTML repetes (S1192) -----
static final String DIV_CLOSE = "</div>";
static final String SPAN_CLOSE = "</span>";
static final String TBODY_TABLE_CLOSE = "</tbody></table>";
static final String TD_TR_CLOSE = "</td></tr>";
static final String H3_FOLDER_OPEN = "<h3 class=\"folder\" id=\"";
static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
/** Balise d'ouverture de ligne avec alternance de fond (zebra striping) : appeler en {@code trTag(row++)}. */
static String trTag(int row) {
return row % 2 == 1 ? "<tr class=\"alt\">" : "<tr>";
}
/** Un signet PDF (childrenHtml : signets enfants deja rendus, ou chaine vide). */
static String bookmark(String name, String anchor, String childrenHtml) {
return "<bookmark name=\"" + esc(name == null ? "" : name) + "\" href=\"#" + anchor + "\">"
+ childrenHtml + "</bookmark>";
}
static String cm(double v) {
return String.format(Locale.ROOT, "%.1fcm", v);
}
/** Un lieu/moment assez court pour la ligne de contexte de scene (sinon champ normal). */
static boolean isMetaShort(String s) {
return s == null || (!s.contains("\n") && !s.contains("\r") && s.trim().length() <= 90);
}
/**
* Echappe le texte pour XHTML et retire les caracteres interdits en XML 1.0.
* Les glyphes exotiques (fleches, ≈, cyrillique...) sont couverts par les polices
* DejaVu embarquees ; seuls les emojis (hors plan de base, absents de DejaVu)
* sont retires pour ne pas sortir en "#".
*/
static String esc(String s) {
if (s == null) return "";
StringBuilder b = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 && c != '\n' && c != '\t') continue; // \r normalise en amont
switch (c) {
case '&' -> b.append("&amp;");
case '<' -> b.append("&lt;");
case '>' -> b.append("&gt;");
case '"' -> b.append("&quot;");
case '\u202F', '\u2007' -> b.append('\u00A0'); // espaces fines (U+202F, U+2007) -> insecable
default -> {
if (!Character.isSurrogate(c)) b.append(c); // emojis : sans glyphe, on retire
}
}
}
return b.toString();
}
/** Comme esc, mais les sauts de ligne deviennent des &lt;br/&gt;. */
static String multiline(String s) {
if (s == null) return "";
return esc(s.replace("\r\n", "\n").replace('\r', '\n')).replace("\n", "<br/>");
}
// ----- Bloc "libelle + texte" -----
/** Un paragraphe (lignes jointes par &lt;br/&gt;) ou une liste a puces. */
record TextBlock(boolean list, List<String> lines) {}
/**
* ENCADRE special (codes visuels des livres de JdR) : "readaloud" = texte a lire aux
* joueurs (parchemin, filets or), "secret" = reserve au MJ (violet tirete), "combat" =
* rencontre (accent rouge). Libelle sur sa propre ligne, contenu en blocs.
*/
static void box(StringBuilder b, String cssClass, String label, String value) {
if (!notBlank(value)) return;
b.append("<div class=\"box ").append(cssClass).append("\">");
b.append("<div class=\"box-label\">").append(esc(label)).append(DIV_CLOSE);
List<TextBlock> blocks = parseBlocks(value);
for (int i = 0; i < blocks.size(); i++) {
TextBlock t = blocks.get(i);
if (t.list()) {
b.append("<ul>");
for (String line : t.lines()) b.append("<li>").append(esc(line)).append("</li>");
b.append("</ul>");
} else if (i == 0) {
b.append(paragraphHtml(t));
} else {
b.append("<div class=\"para\">").append(paragraphHtml(t)).append(DIV_CLOSE);
}
}
b.append(DIV_CLOSE);
}
/**
* Bloc "libelle en tete de ligne + valeur" si la valeur est non vide. Le libelle est
* rendu EN LIGNE devant le premier paragraphe (style stat-block : compact et balayable),
* les paragraphes suivants et les listes a puces (lignes "- ...") en dessous.
*/
static void block(StringBuilder b, String label, String value) {
block(b, label, value, null);
}
/** Variante avec classe CSS additionnelle sur le champ (ex : "ambiance" -> italique). */
static void block(StringBuilder b, String label, String value, String extraClass) {
if (!notBlank(value)) return;
List<TextBlock> blocks = parseBlocks(value);
b.append("<div class=\"field").append(extraClass != null ? " " + extraClass : "")
.append("\"><span class=\"field-label\">").append(esc(label)).append(SPAN_CLOSE);
int i = 0;
if (!blocks.isEmpty() && !blocks.get(0).list()) {
b.append(paragraphHtml(blocks.get(0)));
i = 1;
}
for (; i < blocks.size(); i++) {
TextBlock t = blocks.get(i);
if (t.list()) {
b.append("<ul>");
for (String line : t.lines()) b.append("<li>").append(esc(line)).append("</li>");
b.append("</ul>");
} else {
b.append("<div class=\"para\">").append(paragraphHtml(t)).append(DIV_CLOSE);
}
}
b.append(DIV_CLOSE);
}
private static String paragraphHtml(TextBlock t) {
StringBuilder p = new StringBuilder();
for (int i = 0; i < t.lines().size(); i++) {
if (i > 0) p.append("<br/>");
p.append(esc(t.lines().get(i)));
}
return p.toString();
}
/**
* Decoupe un texte brut en blocs : les lignes vides separent les paragraphes, les
* lignes commencant par "- ", "• " ou "* " deviennent de vraies listes a puces.
*/
private static List<TextBlock> parseBlocks(String value) {
String norm = value.replace("\r\n", "\n").replace('\r', '\n');
List<TextBlock> blocks = new ArrayList<>();
List<String> cur = new ArrayList<>();
boolean curList = false;
for (String line : norm.split("\n", -1)) {
String t = line.trim();
if (t.isEmpty()) {
if (!cur.isEmpty()) blocks.add(new TextBlock(curList, cur));
cur = new ArrayList<>();
continue;
}
boolean bullet = t.startsWith("- ") || t.startsWith("") || t.startsWith("* ");
if (!cur.isEmpty() && bullet != curList) {
blocks.add(new TextBlock(curList, cur));
cur = new ArrayList<>();
}
curList = bullet;
cur.add(bullet ? t.substring(2).trim() : t);
}
if (!cur.isEmpty()) blocks.add(new TextBlock(curList, cur));
return blocks;
}
}

View File

@@ -0,0 +1,4 @@
package com.loremind.infrastructure.transfer.pdf;
/** Image re-encodee prete a inliner : data-URI + dimensions reelles (pour l'aspect). */
record PdfImage(String uri, int w, int h) {}

View File

@@ -0,0 +1,111 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import com.loremind.infrastructure.persistence.entity.StoredFileJpaEntity;
import com.loremind.infrastructure.persistence.jpa.ImageJpaRepository;
import com.loremind.infrastructure.persistence.jpa.StoredFileJpaRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
/**
* Charge les images LoreMind (portraits, illustrations) et les battlemaps (fichiers
* stockes) et les prepare pour l'inlining PDF : lecture du binaire via le stockage,
* redimensionnement (max PORTRAIT_MAX / ILLUSTRATION_MAX px), re-encodage JPEG sur fond
* blanc (aplatit la transparence) et sortie en data-URI. Le decodeur WebP TwelveMonkeys
* (present au classpath) permet de decoder aussi les WebP. Tout echec de decodage renvoie
* null : l'image est simplement omise du livret plutot que de casser l'export.
*/
@Component
class PdfImageEncoder {
private static final Logger log = LoggerFactory.getLogger(PdfImageEncoder.class);
/** Cotes max (px) avant re-encodage : portraits compacts, battlemaps/illustrations larges. */
static final int PORTRAIT_MAX = 700;
static final int ILLUSTRATION_MAX = 1500;
private final ImageJpaRepository imageRepo;
private final StoredFileJpaRepository storedFileRepo;
private final ImageStorage imageStorage;
private final FileStorage fileStorage;
PdfImageEncoder(ImageJpaRepository imageRepo, StoredFileJpaRepository storedFileRepo,
ImageStorage imageStorage, FileStorage fileStorage) {
this.imageRepo = imageRepo;
this.storedFileRepo = storedFileRepo;
this.imageStorage = imageStorage;
this.fileStorage = fileStorage;
}
/** Image LoreMind re-encodee (data-URI JPEG redimensionne), ou null. */
PdfImage image(String imageId, int maxDim) {
if (imageId == null || imageId.isBlank()) return null;
ImageJpaEntity e;
try {
e = imageRepo.findById(Long.parseLong(imageId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
return encode(imageStorage.download(e.getStorageKey()), maxDim, imageId);
}
/** Battlemap (fichier stocke) re-encodee, seulement si c'est une image (pas une video). */
PdfImage fileImage(String fileId) {
if (fileId == null || fileId.isBlank()) return null;
StoredFileJpaEntity e;
try {
e = storedFileRepo.findById(Long.parseLong(fileId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
String ct = e.getContentType();
if (ct == null || !ct.startsWith("image/")) return null; // mp4/webm -> ignore
return encode(fileStorage.download(e.getStorageKey()), ILLUSTRATION_MAX, fileId);
}
/** Lit un flux image, le redimensionne (max maxDim) et le re-encode en data-URI JPEG. */
private PdfImage encode(InputStream in, int maxDim, String ref) {
if (in == null) return null;
try (in) {
BufferedImage src = ImageIO.read(in);
if (src == null) {
log.debug("Image PDF ignoree (format non decode) : {}", ref);
return null;
}
int w = src.getWidth();
int h = src.getHeight();
double scale = Math.min(1.0, (double) maxDim / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setColor(Color.WHITE); // fond blanc : aplatit la transparence (JPEG sans alpha)
g.fillRect(0, 0, nw, nh);
g.drawImage(src, 0, 0, nw, nh, null);
g.dispose();
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(dst, "jpeg", out);
return new PdfImage("data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray()),
nw, nh);
} catch (IOException | RuntimeException ex) {
log.warn("Image PDF ignoree ({}) : {}", ref, ex.getMessage());
return null;
}
}
}

Some files were not shown because too many files have changed in this diff Show More