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
This commit is contained in:
77
.gitea/workflows/quality.yml
Normal file
77
.gitea/workflows/quality.yml
Normal 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
37
.mega-linter.yml
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import java.util.List;
|
||||
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
||||
|
||||
/**
|
||||
* {@code existingId} (nullable) : 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).
|
||||
* <p>
|
||||
* {@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(
|
||||
String name, String description, String type,
|
||||
List<ChapterProposal> chapters, String existingId) {
|
||||
|
||||
@@ -2,11 +2,9 @@ package com.loremind.domain.campaigncontext.quest;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
||||
* {@link PrerequisiteEvaluator}.
|
||||
*
|
||||
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
||||
* (pas de OR pour le moment).
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ import java.util.Set;
|
||||
/**
|
||||
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
||||
* et en dérive le {@link QuestStatus} effectif.
|
||||
*
|
||||
* 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é
|
||||
* 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,
|
||||
EvaluationContext ctx
|
||||
) {
|
||||
switch (progression) {
|
||||
case COMPLETED: return QuestStatus.COMPLETED;
|
||||
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
|
||||
case NOT_STARTED:
|
||||
return areAllSatisfied(prerequisites, ctx)
|
||||
? QuestStatus.AVAILABLE
|
||||
: QuestStatus.LOCKED;
|
||||
default:
|
||||
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
|
||||
}
|
||||
return switch (progression) {
|
||||
case COMPLETED -> QuestStatus.COMPLETED;
|
||||
case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
|
||||
case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
|
||||
? QuestStatus.AVAILABLE
|
||||
: QuestStatus.LOCKED;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
||||
* selon les prérequis — voir {@link QuestStatus}.
|
||||
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
||||
* COMPLETED : marquée terminée par le MJ.
|
||||
*
|
||||
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
||||
*/
|
||||
public enum ProgressionStatus {
|
||||
|
||||
@@ -4,7 +4,6 @@ package com.loremind.domain.campaigncontext.quest;
|
||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
||||
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
||||
*
|
||||
* Table de vérité :
|
||||
* NOT_STARTED + prérequis non remplis -> LOCKED
|
||||
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
||||
|
||||
@@ -158,7 +158,7 @@ public class GameSystem {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,22 +26,25 @@ import java.util.function.Consumer;
|
||||
@Component
|
||||
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
||||
|
||||
private static final String ADAPT_PATH = "/adapt/campaign/stream";
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final long timeoutSeconds;
|
||||
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
|
||||
private final String adaptPath;
|
||||
|
||||
public BrainCampaignAdaptClient(
|
||||
WebClient.Builder webClientBuilder,
|
||||
ObjectMapper objectMapper,
|
||||
@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.objectMapper = objectMapper;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
this.adaptPath = adaptPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -65,7 +68,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
||||
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(ADAPT_PATH)
|
||||
.uri(adaptPath)
|
||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
|
||||
@@ -36,24 +36,37 @@ import java.util.function.Consumer;
|
||||
@Component
|
||||
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 =
|
||||
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 BrainSseImportSupport sse;
|
||||
private final long importTimeoutSeconds;
|
||||
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
|
||||
private final String importCampaignStreamPath;
|
||||
|
||||
public BrainCampaignImportClient(
|
||||
WebClient.Builder webClientBuilder,
|
||||
ObjectMapper objectMapper,
|
||||
@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.sse = new BrainSseImportSupport(objectMapper);
|
||||
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
|
||||
public void importCampaignStreaming(
|
||||
byte[] pdfBytes,
|
||||
@@ -69,7 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
|
||||
.uri(importCampaignStreamPath)
|
||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
@@ -80,12 +93,11 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
int[] pageCount = {0};
|
||||
int[] ocrPageCount = {0};
|
||||
boolean[] terminated = {false};
|
||||
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
|
||||
sse.runStream(
|
||||
flux, importTimeoutSeconds, terminated,
|
||||
event -> handleEvent(
|
||||
event, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
||||
event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
|
||||
onError, CampaignImportException::new);
|
||||
}
|
||||
|
||||
@@ -94,11 +106,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
int[] pageCount,
|
||||
int[] ocrPageCount,
|
||||
boolean[] terminated,
|
||||
Consumer<CampaignImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<CampaignImportProposal> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
ImportCallbacks callbacks) {
|
||||
|
||||
String event = ssEvent.event();
|
||||
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
||||
@@ -106,27 +114,27 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
if ("heartbeat".equals(event)) {
|
||||
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
|
||||
onHeartbeat.run();
|
||||
callbacks.onHeartbeat().run();
|
||||
return;
|
||||
}
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(sse.readMessage(data));
|
||||
callbacks.onStatus().accept(sse.readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
onStatus.accept(sse.chunkFailedStatus(data));
|
||||
callbacks.onStatus().accept(sse.chunkFailedStatus(data));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new CampaignImportException(
|
||||
callbacks.onError().accept(new CampaignImportException(
|
||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -136,10 +144,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
if ("start".equals(event)) {
|
||||
pageCount[0] = node.path("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));
|
||||
} else if ("progress".equals(event)) {
|
||||
onProgress.accept(new CampaignImportProgress(
|
||||
callbacks.onProgress().accept(new CampaignImportProgress(
|
||||
node.path("current").asInt(),
|
||||
node.path("total").asInt(),
|
||||
pageCount[0],
|
||||
@@ -150,7 +158,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
node.path("npc_count").asInt()));
|
||||
} else if ("done".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onDone.accept(new CampaignImportProposal(
|
||||
callbacks.onDone().accept(new CampaignImportProposal(
|
||||
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
|
||||
}
|
||||
}
|
||||
@@ -163,7 +171,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
for (JsonNode arc : arcsNode) {
|
||||
arcs.add(new ArcProposal(
|
||||
text(arc, "name"),
|
||||
text(arc, "description"),
|
||||
text(arc, FIELD_DESCRIPTION),
|
||||
text(arc, "type"),
|
||||
toChapters(arc.path("chapters")),
|
||||
null));
|
||||
@@ -178,7 +186,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
for (JsonNode ch : chaptersNode) {
|
||||
chapters.add(new ChapterProposal(
|
||||
text(ch, "name"),
|
||||
text(ch, "description"),
|
||||
text(ch, FIELD_DESCRIPTION),
|
||||
toScenes(ch.path("scenes")),
|
||||
null));
|
||||
}
|
||||
@@ -191,7 +199,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
if (scenesNode != null && scenesNode.isArray()) {
|
||||
for (JsonNode sc : scenesNode) {
|
||||
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"),
|
||||
toRooms(sc.path("rooms")), null));
|
||||
}
|
||||
@@ -204,7 +212,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
if (roomsNode != null && roomsNode.isArray()) {
|
||||
for (JsonNode rm : roomsNode) {
|
||||
rooms.add(new RoomProposal(
|
||||
text(rm, "name"), text(rm, "description"),
|
||||
text(rm, "name"), text(rm, FIELD_DESCRIPTION),
|
||||
text(rm, "enemies"), text(rm, "loot")));
|
||||
}
|
||||
}
|
||||
@@ -215,7 +223,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||
List<NpcProposal> npcs = new ArrayList<>();
|
||||
if (npcsNode != null && npcsNode.isArray()) {
|
||||
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;
|
||||
|
||||
@@ -24,7 +24,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
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
|
||||
@@ -40,11 +40,14 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
public class BrainChatPayloadBuilder {
|
||||
|
||||
private static final String KEY_DESCRIPTION = "description";
|
||||
private static final String KEY_TITLE = "title";
|
||||
|
||||
public Map<String, Object> build(ChatRequest request) {
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
root.put("messages", request.messages().stream()
|
||||
.map(this::messageToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
|
||||
if (request.loreContext() != null) {
|
||||
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||
@@ -75,25 +78,25 @@ public class BrainChatPayloadBuilder {
|
||||
map.put("started_at", sc.startedAt().toString());
|
||||
}
|
||||
map.put("entries", sc.entries() != null
|
||||
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
|
||||
? sc.entries().stream().map(this::journalEntryToMap).toList()
|
||||
: List.of());
|
||||
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
|
||||
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
|
||||
map.put("previous_events", sc.previousEvents().stream()
|
||||
.map(this::journalEntryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
|
||||
// saturer le prompt sur les campagnes sans Hub.
|
||||
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
|
||||
map.put("available_quests", sc.availableQuests().stream()
|
||||
.map(this::questSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
|
||||
map.put("in_progress_quests", sc.inProgressQuests().stream()
|
||||
.map(this::questSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
|
||||
map.put("locked_quest_titles", sc.lockedQuestTitles());
|
||||
@@ -108,7 +111,7 @@ public class BrainChatPayloadBuilder {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", q.name());
|
||||
map.put("arc_name", q.arcName());
|
||||
putIfText(map, "description", q.description());
|
||||
putIfText(map, KEY_DESCRIPTION, q.description());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -147,7 +150,7 @@ public class BrainChatPayloadBuilder {
|
||||
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||
.map(this::pageSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
map.put("folders", foldersMap);
|
||||
map.put("tags", ctx.tags());
|
||||
@@ -156,7 +159,7 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("title", ps.title());
|
||||
map.put(KEY_TITLE, ps.title());
|
||||
map.put("template_name", ps.templateName());
|
||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||
@@ -173,7 +176,7 @@ public class BrainChatPayloadBuilder {
|
||||
|
||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||
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_fields", pc.templateFields());
|
||||
map.put("values", pc.values());
|
||||
@@ -186,18 +189,18 @@ public class BrainChatPayloadBuilder {
|
||||
map.put("campaign_description", ctx.campaignDescription());
|
||||
map.put("arcs", ctx.arcs().stream()
|
||||
.map(this::arcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||
map.put("characters", ctx.characters().stream()
|
||||
.map(this::characterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
||||
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
||||
map.put("npcs", ctx.npcs().stream()
|
||||
.map(this::npcSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -224,13 +227,14 @@ public class BrainChatPayloadBuilder {
|
||||
T entity,
|
||||
Function<T, String> nameExtractor,
|
||||
Function<T, String> descriptionExtractor,
|
||||
Function<T, Integer> illustrationCountExtractor,
|
||||
ToIntFunction<T> illustrationCountExtractor,
|
||||
BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", nameExtractor.apply(entity));
|
||||
map.put("description", descriptionExtractor.apply(entity));
|
||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
||||
map.put(KEY_DESCRIPTION, descriptionExtractor.apply(entity));
|
||||
int illustrationCount = illustrationCountExtractor.applyAsInt(entity);
|
||||
if (illustrationCount > 0) {
|
||||
map.put("illustration_count", illustrationCount);
|
||||
}
|
||||
childSerializer.accept(map, entity);
|
||||
return map;
|
||||
@@ -249,7 +253,7 @@ public class BrainChatPayloadBuilder {
|
||||
}
|
||||
map.put("chapters", arc.chapters().stream()
|
||||
.map(this::chapterSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,7 +265,7 @@ public class BrainChatPayloadBuilder {
|
||||
ChapterSummary::illustrationCount,
|
||||
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||
.map(this::sceneSummaryToMap)
|
||||
.collect(Collectors.toList())));
|
||||
.toList()));
|
||||
}
|
||||
|
||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||
@@ -275,13 +279,13 @@ public class BrainChatPayloadBuilder {
|
||||
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||
map.put("branches", s.branches().stream()
|
||||
.map(this::branchHintToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
// Pièces du lieu explorable : omises si scène classique.
|
||||
if (s.rooms() != null && !s.rooms().isEmpty()) {
|
||||
map.put("rooms", s.rooms().stream()
|
||||
.map(this::roomSummaryToMap)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -298,7 +302,7 @@ public class BrainChatPayloadBuilder {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("name", r.name());
|
||||
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());
|
||||
if (r.branches() != null && !r.branches().isEmpty()) {
|
||||
map.put("branches", r.branches().stream().map(b -> {
|
||||
@@ -307,7 +311,7 @@ public class BrainChatPayloadBuilder {
|
||||
bm.put("target_room_name", b.targetRoomName());
|
||||
putIfText(bm, "condition", b.condition());
|
||||
return bm;
|
||||
}).collect(Collectors.toList()));
|
||||
}).toList());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
@@ -315,7 +319,7 @@ public class BrainChatPayloadBuilder {
|
||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("entity_type", ne.entityType());
|
||||
map.put("title", ne.title());
|
||||
map.put(KEY_TITLE, ne.title());
|
||||
map.put("fields", ne.fields());
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
|
||||
@@ -45,7 +44,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
|
||||
.map(m -> Map.<String, Object>of(
|
||||
"role", m.getRole(),
|
||||
"content", m.getContent() == null ? "" : m.getContent()))
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.Map;
|
||||
public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
||||
|
||||
private static final String GENERATE_PATH = "/generate/item-catalog";
|
||||
private static final String KEY_DESCRIPTION = "description";
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String baseUrl;
|
||||
@@ -36,10 +37,25 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
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<>();
|
||||
req.put("description", description == null ? "" : description);
|
||||
req.put(KEY_DESCRIPTION, description == null ? "" : description);
|
||||
req.put("context", context == null ? "" : context);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -60,30 +76,38 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
||||
if (resp == null) {
|
||||
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<>();
|
||||
Object rawItems = resp.get("items");
|
||||
if (rawItems instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (!(item instanceof Map<?, ?> m)) continue;
|
||||
String name = asString(m.get("name"));
|
||||
if (name == null || name.isBlank()) continue;
|
||||
items.add(CatalogItem.builder()
|
||||
.name(name)
|
||||
.price(asString(m.get("price")))
|
||||
.category(asString(m.get("category")))
|
||||
.description(asString(m.get("description")))
|
||||
.build());
|
||||
for (Object raw : list) {
|
||||
CatalogItem item = toItem(raw);
|
||||
if (item != null) {
|
||||
items.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.isEmpty()) {
|
||||
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 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"));
|
||||
return new GeneratedCatalog(
|
||||
name != null && !name.isBlank() ? name : description,
|
||||
asString(resp.get("description")),
|
||||
items);
|
||||
String name = asString(m.get("name"));
|
||||
if (name == null || name.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -39,20 +39,9 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
|
||||
List<Map<String, String>> fieldPayload = new ArrayList<>();
|
||||
Set<String> allowed = new HashSet<>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
List<Map<String, String>> fieldPayload = buildFieldPayload(fields);
|
||||
Set<String> allowed = allowedKeys(fields);
|
||||
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
req.put("entity_type", entityType == null ? "" : entityType);
|
||||
@@ -60,6 +49,38 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
||||
req.put("instruction", instruction == null ? "" : instruction);
|
||||
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();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
|
||||
@@ -78,18 +99,37 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
||||
if (resp == null) {
|
||||
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<>();
|
||||
Object rawFields = resp.get("fields");
|
||||
if (rawFields instanceof Map<?, ?> m) {
|
||||
for (Map.Entry<?, ?> e : m.entrySet()) {
|
||||
String key = e.getKey() == null ? null : e.getKey().toString();
|
||||
if (key == null || !allowed.contains(key)) continue; // whitelist stricte
|
||||
String value = e.getValue() == null ? null : e.getValue().toString();
|
||||
if (value == null || value.isBlank()) continue; // pas de remplissage vide
|
||||
out.add(new ProposedField(key, value.trim()));
|
||||
ProposedField field = toProposedField(e, allowed);
|
||||
if (field != null) {
|
||||
out.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
|
||||
@@ -26,23 +25,28 @@ import java.util.stream.Collectors;
|
||||
@Component
|
||||
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 =
|
||||
new ParameterizedTypeReference<>() {};
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
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(
|
||||
WebClient.Builder webClientBuilder,
|
||||
ObjectMapper objectMapper,
|
||||
@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.objectMapper = objectMapper;
|
||||
this.timeoutSeconds = timeoutSeconds;
|
||||
this.chatPath = chatPath;
|
||||
this.deepChatPath = deepChatPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -57,11 +61,11 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
|
||||
payload.put("source_ids", sourceIds);
|
||||
payload.put("messages", messages.stream()
|
||||
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
payload.put("context", context == null ? "" : context);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(deep ? DEEP_PATH : PATH)
|
||||
.uri(deep ? deepChatPath : chatPath)
|
||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
|
||||
@@ -55,7 +55,7 @@ public class BrainNotebookIndexClient implements NotebookIndexer {
|
||||
if (resp == null) {
|
||||
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) {
|
||||
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", 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 {
|
||||
public int chunks;
|
||||
@JsonProperty("chunks")
|
||||
private int chunks;
|
||||
@JsonProperty("page_count")
|
||||
public int pageCount;
|
||||
private int pageCount;
|
||||
@JsonProperty("ocr_page_count")
|
||||
public int ocrPageCount;
|
||||
private int ocrPageCount;
|
||||
|
||||
int getChunks() { return chunks; }
|
||||
int getPageCount() { return pageCount; }
|
||||
int getOcrPageCount() { return ocrPageCount; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,23 +49,7 @@ public class BrainRandomTableClient implements RandomTableGenerator {
|
||||
if (resp == null) {
|
||||
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
|
||||
}
|
||||
List<RandomTableEntry> entries = new ArrayList<>();
|
||||
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());
|
||||
}
|
||||
}
|
||||
List<RandomTableEntry> entries = parseEntries(resp.get("entries"));
|
||||
if (entries.isEmpty()) {
|
||||
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
|
||||
}
|
||||
@@ -76,6 +60,39 @@ public class BrainRandomTableClient implements RandomTableGenerator {
|
||||
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
|
||||
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
|
||||
Map<String, Object> req = new LinkedHashMap<>();
|
||||
|
||||
@@ -47,30 +47,45 @@ import java.util.function.Consumer;
|
||||
@Component
|
||||
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 =
|
||||
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 WebClient webClient;
|
||||
private final BrainSseImportSupport sse;
|
||||
private final String baseUrl;
|
||||
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(
|
||||
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
|
||||
WebClient.Builder webClientBuilder,
|
||||
ObjectMapper objectMapper,
|
||||
@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.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||
this.sse = new BrainSseImportSupport(objectMapper);
|
||||
this.baseUrl = baseUrl;
|
||||
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) -------------------------------------------------
|
||||
|
||||
@Override
|
||||
@@ -79,12 +94,12 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
BrainRulesImportResponse response = restTemplate.postForObject(
|
||||
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class);
|
||||
baseUrl + importRulesPath, entity, BrainRulesImportResponse.class);
|
||||
if (response == null || response.getSections() == null) {
|
||||
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
|
||||
}
|
||||
@@ -119,11 +134,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
Consumer<Throwable> onError) {
|
||||
|
||||
MultipartBodyBuilder parts = new MultipartBodyBuilder();
|
||||
parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf"))
|
||||
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
|
||||
parts.part("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME))
|
||||
.filename(filename == null || filename.isBlank() ? DEFAULT_FILENAME : filename);
|
||||
|
||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||
.uri(IMPORT_RULES_STREAM_PATH)
|
||||
.uri(importRulesStreamPath)
|
||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
@@ -136,12 +151,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
int[] pageCount = {0};
|
||||
int[] ocrPageCount = {0};
|
||||
boolean[] terminated = {false};
|
||||
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||
|
||||
sse.runStream(
|
||||
flux, importTimeoutSeconds, terminated,
|
||||
event -> handleEvent(
|
||||
event, pageCount, ocrPageCount, terminated,
|
||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
||||
event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
|
||||
onError, RulesImportException::new);
|
||||
}
|
||||
|
||||
@@ -150,11 +164,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
int[] pageCount,
|
||||
int[] ocrPageCount,
|
||||
boolean[] terminated,
|
||||
Consumer<RulesImportProgress> onProgress,
|
||||
Runnable onHeartbeat,
|
||||
Consumer<String> onStatus,
|
||||
Consumer<RulesImportResult> onDone,
|
||||
Consumer<Throwable> onError) {
|
||||
ImportCallbacks callbacks) {
|
||||
|
||||
String event = ssEvent.event();
|
||||
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
|
||||
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
|
||||
// resté silencieux pendant tout le traitement du morceau.
|
||||
onHeartbeat.run();
|
||||
callbacks.onHeartbeat().run();
|
||||
return;
|
||||
}
|
||||
if ("status".equals(event)) {
|
||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||
onStatus.accept(sse.readMessage(data));
|
||||
callbacks.onStatus().accept(sse.readMessage(data));
|
||||
return;
|
||||
}
|
||||
if ("chunk_failed".equals(event)) {
|
||||
onStatus.accept(sse.chunkFailedStatus(data));
|
||||
callbacks.onStatus().accept(sse.chunkFailedStatus(data));
|
||||
return;
|
||||
}
|
||||
if ("error".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onError.accept(new RulesImportException(
|
||||
callbacks.onError().accept(new RulesImportException(
|
||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||
return;
|
||||
}
|
||||
if ("extracting".equals(event)) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -194,10 +204,10 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
if ("start".equals(event)) {
|
||||
pageCount[0] = node.path("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()));
|
||||
} else if ("progress".equals(event)) {
|
||||
onProgress.accept(new RulesImportProgress(
|
||||
callbacks.onProgress().accept(new RulesImportProgress(
|
||||
node.path("current").asInt(),
|
||||
node.path("total").asInt(),
|
||||
pageCount[0],
|
||||
@@ -205,7 +215,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
||||
toStringList(node.path("new_sections"))));
|
||||
} else if ("done".equals(event)) {
|
||||
terminated[0] = true;
|
||||
onDone.accept(new RulesImportResult(
|
||||
callbacks.onDone().accept(new RulesImportResult(
|
||||
toStringMap(node.path("sections")),
|
||||
node.path("page_count").asInt(),
|
||||
node.path("ocr_page_count").asInt()));
|
||||
|
||||
@@ -62,23 +62,38 @@ public class BrainSceneDraftClient implements SceneDraftAssistant {
|
||||
if (resp == null) {
|
||||
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<>();
|
||||
Object rawScenes = resp.get("scenes");
|
||||
if (rawScenes instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (!(item instanceof Map<?, ?> m)) continue;
|
||||
String name = asString(m.get("name"));
|
||||
if (name == null || name.isBlank()) continue; // un titre est obligatoire
|
||||
out.add(new SceneDraft(
|
||||
name.trim(),
|
||||
trimOrNull(asString(m.get("description"))),
|
||||
trimOrNull(asString(m.get("playerNarration")))));
|
||||
for (Object raw : list) {
|
||||
SceneDraft draft = toSceneDraft(raw);
|
||||
if (draft != null) {
|
||||
out.add(draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
return o != null ? o.toString() : null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -41,7 +42,7 @@ public class BrainSidecar {
|
||||
private final BrainSidecarProperties props;
|
||||
private final String internalSecret;
|
||||
|
||||
private volatile Process process;
|
||||
private final AtomicReference<Process> process = new AtomicReference<>();
|
||||
|
||||
public BrainSidecar(BrainSidecarProperties props,
|
||||
@Value("${brain.internal-secret:}") String internalSecret) {
|
||||
@@ -75,9 +76,10 @@ public class BrainSidecar {
|
||||
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
|
||||
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={}).",
|
||||
process.pid(), workingDir != null ? workingDir : "<heritee>");
|
||||
started.pid(), workingDir != null ? workingDir : "<heritee>");
|
||||
} catch (IOException e) {
|
||||
log.error("[Brain] Echec du lancement du sidecar (commande={}). "
|
||||
+ "Les fonctions IA seront indisponibles. Cause : {}",
|
||||
@@ -87,7 +89,7 @@ public class BrainSidecar {
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
Process p = this.process;
|
||||
Process p = this.process.get();
|
||||
if (p == null || !p.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ import java.util.function.Consumer;
|
||||
*/
|
||||
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;
|
||||
|
||||
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. */
|
||||
String readMessage(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
if (node != null && node.hasNonNull("message")) {
|
||||
return node.get("message").asText();
|
||||
if (node != null && node.hasNonNull(MESSAGE_FIELD)) {
|
||||
return node.get(MESSAGE_FIELD).asText();
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -59,8 +62,8 @@ final class BrainSseImportSupport {
|
||||
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
|
||||
String chunkFailedStatus(String data) {
|
||||
JsonNode node = readJson(data);
|
||||
String msg = node != null && node.hasNonNull("message")
|
||||
? node.get("message").asText() : "";
|
||||
String msg = node != null && node.hasNonNull(MESSAGE_FIELD)
|
||||
? node.get(MESSAGE_FIELD).asText() : "";
|
||||
int current = node != null ? node.path("current").asInt() : 0;
|
||||
int total = node != null ? node.path("total").asInt() : 0;
|
||||
return "Morceau " + current + "/" + total + " ignoré"
|
||||
|
||||
@@ -26,6 +26,8 @@ public class DesktopBrowserOpener {
|
||||
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
// S125 : faux positif — commentaire explicatif (prose), pas de code mort.
|
||||
@SuppressWarnings("java:S125")
|
||||
public void onReady() {
|
||||
// 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.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.loremind.infrastructure.desktop;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
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.
|
||||
* 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 {
|
||||
|
||||
/** 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")
|
||||
private static FileChannel lockChannel;
|
||||
private static FileLock lock;
|
||||
|
||||
private DesktopSingleInstance() {}
|
||||
|
||||
@@ -71,11 +88,11 @@ public final class DesktopSingleInstance {
|
||||
Path lockFile = dir.resolve(".instance.lock");
|
||||
lockChannel = FileChannel.open(lockFile,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||
lock = lockChannel.tryLock();
|
||||
FileLock lock = lockChannel.tryLock();
|
||||
return lock != null; // null = deja verrouille par une autre instance
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage()
|
||||
+ ") — on tente de demarrer quand meme.");
|
||||
log.warn("[Desktop] Verrou d'instance indisponible ({}) — on tente de demarrer quand meme.",
|
||||
e.getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +105,7 @@ public final class DesktopSingleInstance {
|
||||
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
|
||||
public static void openUrl(String url) {
|
||||
try {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||
ProcessBuilder pb;
|
||||
if (os.contains("win")) {
|
||||
// rundll32 : ouverture d'URL fiable sans dependance graphique Java.
|
||||
@@ -96,12 +113,12 @@ public final class DesktopSingleInstance {
|
||||
} else if (os.contains("mac")) {
|
||||
pb = new ProcessBuilder("open", url);
|
||||
} else {
|
||||
pb = new ProcessBuilder("xdg-open", url);
|
||||
pb = new ProcessBuilder(XDG_OPEN, url);
|
||||
}
|
||||
pb.start();
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url
|
||||
+ " : " + e.getMessage() + ". Ouvrez-le manuellement.");
|
||||
log.warn("[Desktop] Impossible d'ouvrir le navigateur sur {} : {}. Ouvrez-le manuellement.",
|
||||
url, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,25 +126,25 @@ public final class DesktopSingleInstance {
|
||||
public static void openFolder(Path dir) {
|
||||
try {
|
||||
Files.createDirectories(dir);
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||
ProcessBuilder pb;
|
||||
if (os.contains("win")) {
|
||||
pb = new ProcessBuilder("explorer.exe", dir.toString());
|
||||
} else if (os.contains("mac")) {
|
||||
pb = new ProcessBuilder("open", dir.toString());
|
||||
} else {
|
||||
pb = new ProcessBuilder("xdg-open", dir.toString());
|
||||
pb = new ProcessBuilder(XDG_OPEN, dir.toString());
|
||||
}
|
||||
pb.start();
|
||||
} 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). */
|
||||
public static void openInEditor(Path file) {
|
||||
try {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||
ProcessBuilder pb;
|
||||
if (os.contains("win")) {
|
||||
// notepad : toujours present, ouvre proprement un .properties
|
||||
@@ -136,11 +153,11 @@ public final class DesktopSingleInstance {
|
||||
} else if (os.contains("mac")) {
|
||||
pb = new ProcessBuilder("open", "-t", file.toString());
|
||||
} else {
|
||||
pb = new ProcessBuilder("xdg-open", file.toString());
|
||||
pb = new ProcessBuilder(XDG_OPEN, file.toString());
|
||||
}
|
||||
pb.start();
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage());
|
||||
log.warn("[Desktop] Ouverture du fichier impossible : {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.loremind.infrastructure.desktop;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
@@ -30,6 +33,14 @@ import java.util.Properties;
|
||||
*/
|
||||
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 static final String DEFAULT_TEMPLATE = """
|
||||
@@ -69,10 +80,10 @@ public final class DesktopUserConfig {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, DEFAULT_TEMPLATE);
|
||||
System.out.println("[Desktop] Config utilisateur creee : " + file);
|
||||
log.info("[Desktop] Config utilisateur creee : {}", file);
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage()
|
||||
+ " — defauts utilises (port 8080, admin/admin).");
|
||||
log.warn("[Desktop] Impossible de creer {} : {} — defauts utilises (port 8080, admin/admin).",
|
||||
file, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +98,13 @@ public final class DesktopUserConfig {
|
||||
int wanted = configuredPort();
|
||||
int chosen = isPortFree(wanted) ? wanted : findFreePort(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 {
|
||||
Files.writeString(portFile(), String.valueOf(chosen));
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ").");
|
||||
log.warn("[Desktop] Ecriture du port impossible ({}).", e.getMessage());
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
@@ -104,7 +115,7 @@ public final class DesktopUserConfig {
|
||||
* → port configuré → 8080.
|
||||
*/
|
||||
public static int runningPort() {
|
||||
String sys = System.getProperty("server.port");
|
||||
String sys = System.getProperty(SERVER_PORT_PROPERTY);
|
||||
if (sys != null && !sys.isBlank()) {
|
||||
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
|
||||
}
|
||||
@@ -124,7 +135,7 @@ public final class DesktopUserConfig {
|
||||
Properties props = new Properties();
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
props.load(in);
|
||||
String v = props.getProperty("server.port");
|
||||
String v = props.getProperty(SERVER_PORT_PROPERTY);
|
||||
if (v != null && !v.isBlank()) {
|
||||
return Integer.parseInt(v.trim());
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
|
||||
|
||||
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 ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@@ -62,9 +65,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
|
||||
root = mapper.createObjectNode();
|
||||
}
|
||||
|
||||
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
|
||||
? (ObjectNode) root.get("auths")
|
||||
: root.putObject("auths");
|
||||
ObjectNode auths = root.has(AUTHS_KEY) && root.get(AUTHS_KEY).isObject()
|
||||
? (ObjectNode) root.get(AUTHS_KEY)
|
||||
: root.putObject(AUTHS_KEY);
|
||||
|
||||
String b64 = Base64.getEncoder().encodeToString(
|
||||
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.nimbusds.jose.crypto.Ed25519Verifier;
|
||||
import com.nimbusds.jose.jwk.OctetKeyPair;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import org.bouncycastle.asn1.ASN1Sequence;
|
||||
import org.bouncycastle.asn1.ASN1Primitive;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -21,8 +21,8 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.ParseException;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
|
||||
@@ -88,25 +88,48 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
||||
|
||||
@Override
|
||||
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) {
|
||||
throw new JwtVerificationException("JWT verifier not configured");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureNotBlank(String rawJwt) throws JwtVerificationException {
|
||||
if (rawJwt == null || rawJwt.isBlank()) {
|
||||
throw new JwtVerificationException("JWT is empty");
|
||||
}
|
||||
}
|
||||
|
||||
SignedJWT signed;
|
||||
private static SignedJWT parseJwt(String rawJwt) throws JwtVerificationException {
|
||||
try {
|
||||
signed = SignedJWT.parse(rawJwt);
|
||||
return SignedJWT.parse(rawJwt);
|
||||
} catch (ParseException e) {
|
||||
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureExpectedAlgorithm(SignedJWT signed) throws JwtVerificationException {
|
||||
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
|
||||
if (!JWSAlgorithm.EdDSA.equals(alg)) {
|
||||
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifySignature(SignedJWT signed) throws JwtVerificationException {
|
||||
try {
|
||||
JWSVerifier verifier = new Ed25519Verifier(publicKey);
|
||||
if (!signed.verify(verifier)) {
|
||||
@@ -115,31 +138,43 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
||||
} catch (Exception e) {
|
||||
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
JWTClaimsSet claims;
|
||||
private static JWTClaimsSet parseClaims(SignedJWT signed) throws JwtVerificationException {
|
||||
try {
|
||||
claims = signed.getJWTClaimsSet();
|
||||
return signed.getJWTClaimsSet();
|
||||
} catch (ParseException e) {
|
||||
throw new JwtVerificationException("JWT claims parse error", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureExpectedIssuer(JWTClaimsSet claims) throws JwtVerificationException {
|
||||
if (!expectedIssuer.equals(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)) {
|
||||
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();
|
||||
if (exp == null || iat == null || sub == null) {
|
||||
if (claims.getExpirationTime() == null || claims.getIssueTime() == null || sub == null) {
|
||||
throw new JwtVerificationException("JWT missing required claims");
|
||||
}
|
||||
|
||||
// Note : on ne refuse pas un JWT expire 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.
|
||||
Instant exp = claims.getExpirationTime().toInstant();
|
||||
Instant iat = claims.getIssueTime().toInstant();
|
||||
|
||||
String tierId;
|
||||
String instanceId;
|
||||
@@ -153,13 +188,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
||||
throw new JwtVerificationException("JWT missing tier_id or instance_id");
|
||||
}
|
||||
|
||||
return new LicenseClaims(
|
||||
sub,
|
||||
tierId,
|
||||
instanceId,
|
||||
iat.toInstant(),
|
||||
exp.toInstant()
|
||||
);
|
||||
return new LicenseClaims(sub, tierId, instanceId, iat, exp);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,7 +203,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
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();
|
||||
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
|
||||
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
|
||||
|
||||
@@ -31,6 +31,24 @@ public class CharacterNpcMarkdownBackfill {
|
||||
|
||||
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 ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@@ -44,8 +62,8 @@ public class CharacterNpcMarkdownBackfill {
|
||||
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
|
||||
return;
|
||||
}
|
||||
int chars = backfillTable("characters");
|
||||
int npcs = backfillTable("npcs");
|
||||
int chars = backfillTable("characters", SELECT_CHARACTERS, UPDATE_CHARACTERS);
|
||||
int npcs = backfillTable("npcs", SELECT_NPCS, UPDATE_NPCS);
|
||||
if (chars + npcs > 0) {
|
||||
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) {
|
||||
// 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 = '{}')";
|
||||
|
||||
private int backfillTable(String label, String selectSql, String updateSql) {
|
||||
var rows = jdbc.queryForList(selectSql);
|
||||
int migrated = 0;
|
||||
for (var row : rows) {
|
||||
@@ -82,10 +93,10 @@ public class CharacterNpcMarkdownBackfill {
|
||||
try {
|
||||
json = mapper.writeValueAsString(Map.of("Notes", markdown));
|
||||
} 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;
|
||||
}
|
||||
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
|
||||
jdbc.update(updateSql, json, id);
|
||||
migrated++;
|
||||
}
|
||||
return migrated;
|
||||
|
||||
@@ -35,6 +35,10 @@ public class GameSystemSeeder {
|
||||
|
||||
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;
|
||||
|
||||
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
|
||||
@@ -81,7 +85,7 @@ public class GameSystemSeeder {
|
||||
GameSystem.builder()
|
||||
.name("Nimble (extrait)")
|
||||
.description("Système léger et narratif, résolution rapide des combats.")
|
||||
.author("LoreMind seed")
|
||||
.author(AUTHOR_SEED)
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(NIMBLE_RULES)
|
||||
.characterTemplate(nimbleCharacterTemplate())
|
||||
@@ -90,7 +94,7 @@ public class GameSystemSeeder {
|
||||
GameSystem.builder()
|
||||
.name("D&D 5e SRD (extrait)")
|
||||
.description("Extrait libre des bases du System Reference Document 5.1.")
|
||||
.author("LoreMind seed")
|
||||
.author(AUTHOR_SEED)
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(DND_SRD_RULES)
|
||||
.characterTemplate(dndCharacterTemplate())
|
||||
@@ -99,7 +103,7 @@ public class GameSystemSeeder {
|
||||
GameSystem.builder()
|
||||
.name("Homebrew Exemple")
|
||||
.description("Template minimaliste à dupliquer pour créer votre propre système.")
|
||||
.author("LoreMind seed")
|
||||
.author(AUTHOR_SEED)
|
||||
.isPublic(false)
|
||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||
.characterTemplate(genericCharacterTemplate())
|
||||
@@ -113,10 +117,10 @@ public class GameSystemSeeder {
|
||||
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
||||
private static List<TemplateField> genericCharacterTemplate() {
|
||||
return List.of(
|
||||
TemplateField.text("Histoire"),
|
||||
TemplateField.text(FIELD_HISTOIRE),
|
||||
TemplateField.text("Personnalite"),
|
||||
TemplateField.text("Apparence"),
|
||||
TemplateField.image("Galerie", ImageLayout.GALLERY),
|
||||
TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY),
|
||||
TemplateField.text("Notes")
|
||||
);
|
||||
}
|
||||
@@ -137,9 +141,9 @@ public class GameSystemSeeder {
|
||||
TemplateField.number("Blessures graves max"),
|
||||
TemplateField.text("Capacites de classe"),
|
||||
TemplateField.text("Equipement"),
|
||||
TemplateField.text("Histoire"),
|
||||
TemplateField.text(FIELD_HISTOIRE),
|
||||
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("Equipement"),
|
||||
TemplateField.text("Sorts"),
|
||||
TemplateField.text("Histoire"),
|
||||
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||
TemplateField.text(FIELD_HISTOIRE),
|
||||
TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -32,8 +33,11 @@ public class MapJsonConverter implements AttributeConverter<Map<String, Object>,
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null) {
|
||||
return null;
|
||||
// Map vide (mutable) plutôt que null : les consommateurs itèrent/écrivent
|
||||
// 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 {
|
||||
return objectMapper.readValue(dbData, Map.class);
|
||||
|
||||
@@ -60,71 +60,8 @@ public class TemplateFieldListJsonConverter
|
||||
}
|
||||
List<TemplateField> result = new ArrayList<>();
|
||||
for (JsonNode item : root) {
|
||||
if (item.isTextual()) {
|
||||
// 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).
|
||||
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.
|
||||
TemplateField field = parseField(item);
|
||||
if (field != null) result.add(field);
|
||||
}
|
||||
return result;
|
||||
} 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
|
||||
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
|
||||
|
||||
@@ -9,6 +9,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -94,12 +95,12 @@ public class ArcJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
|
||||
@@ -64,12 +65,12 @@ public class CampaignJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -68,12 +69,12 @@ public class ChapterJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -81,8 +82,8 @@ public class CharacterJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
@@ -90,6 +91,6 @@ public class CharacterJpaEntity {
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
|
||||
@@ -64,13 +65,13 @@ public class ClockJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -77,13 +78,13 @@ public class ConversationJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
|
||||
@@ -54,6 +55,6 @@ public class ConversationMessageJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -83,8 +84,8 @@ public class EnemyJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = createdAt;
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
@@ -92,6 +93,6 @@ public class EnemyJpaEntity {
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Fronts en PostgreSQL.
|
||||
@@ -44,13 +45,13 @@ public class FrontJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -69,8 +70,8 @@ public class GameSystemJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = createdAt;
|
||||
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
|
||||
@@ -78,6 +79,6 @@ public class GameSystemJpaEntity {
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entite JPA pour les metadonnees d'images en PostgreSQL.
|
||||
@@ -43,7 +44,7 @@ public class ImageJpaEntity {
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (uploadedAt == null) {
|
||||
uploadedAt = LocalDateTime.now();
|
||||
uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -60,12 +61,12 @@ public class ItemCatalogJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Lores en base de données PostgreSQL.
|
||||
@@ -43,12 +44,12 @@ public class LoreJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
|
||||
@@ -48,12 +49,12 @@ public class LoreNodeJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@Entity
|
||||
@Table(name = "notebooks", indexes = {
|
||||
@@ -36,12 +37,12 @@ public class NotebookJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@Entity
|
||||
@Table(name = "notebook_messages", indexes = {
|
||||
@@ -44,6 +45,6 @@ public class NotebookMessageJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
@Entity
|
||||
@Table(name = "notebook_sources", indexes = {
|
||||
@@ -42,6 +43,6 @@ public class NotebookSourceJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
||||
if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -74,8 +75,8 @@ public class NpcJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
if (values == null) values = new HashMap<>();
|
||||
if (imageValues == null) imageValues = new HashMap<>();
|
||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||
@@ -83,6 +84,6 @@ public class NpcJpaEntity {
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -94,12 +95,12 @@ public class PageJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne.
|
||||
@@ -43,12 +44,12 @@ public class PlaythroughJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -90,12 +91,12 @@ public class QuestJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -62,12 +63,12 @@ public class RandomTableJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -140,12 +141,12 @@ public class SceneJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des entrées de journal de session.
|
||||
@@ -48,13 +49,13 @@ public class SessionEntryJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entité JPA pour la persistance des Sessions en PostgreSQL.
|
||||
@@ -61,13 +62,13 @@ public class SessionJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/**
|
||||
* Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL.
|
||||
@@ -44,7 +45,7 @@ public class StoredFileJpaEntity {
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (uploadedAt == null) {
|
||||
uploadedAt = LocalDateTime.now();
|
||||
uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -54,12 +55,12 @@ public class TemplateJpaEntity {
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
createdAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now();
|
||||
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
protected void onUpdate() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -35,7 +36,11 @@ public class PlaythroughMigrationRunner {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
|
||||
|
||||
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
|
||||
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
|
||||
return args -> migrate(jdbc);
|
||||
@@ -73,7 +78,7 @@ public class PlaythroughMigrationRunner {
|
||||
);
|
||||
if (campaignsWithoutPlaythrough.isEmpty()) return 0;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||
for (Long campaignId : campaignsWithoutPlaythrough) {
|
||||
jdbc.update(
|
||||
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
|
||||
@@ -85,7 +90,7 @@ public class PlaythroughMigrationRunner {
|
||||
}
|
||||
|
||||
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(
|
||||
"UPDATE sessions s " +
|
||||
"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) {
|
||||
if (!columnExists(jdbc, "characters", "campaign_id")) return 0;
|
||||
if (!columnExists(jdbc, "characters", COLUMN_CAMPAIGN_ID)) return 0;
|
||||
return jdbc.update(
|
||||
"UPDATE characters c " +
|
||||
"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.
|
||||
*/
|
||||
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
|
||||
relaxNotNull(jdbc, "sessions", "campaign_id");
|
||||
relaxNotNull(jdbc, "characters", "campaign_id");
|
||||
relaxNotNull(jdbc, "sessions", COLUMN_CAMPAIGN_ID);
|
||||
relaxNotNull(jdbc, "characters", COLUMN_CAMPAIGN_ID);
|
||||
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) {
|
||||
if (!columnExists(jdbc, table, column)) return;
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port ArcRepository.
|
||||
@@ -42,14 +41,14 @@ public class PostgresArcRepository implements ArcRepository {
|
||||
Long longCampaignId = Long.parseLong(campaignId);
|
||||
return jpaRepository.findByCampaignId(longCampaignId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Arc> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
|
||||
@@ -43,7 +42,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
||||
public List<Campaign> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,7 +61,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
||||
public List<Campaign> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port ChapterRepository.
|
||||
@@ -42,14 +41,14 @@ public class PostgresChapterRepository implements ChapterRepository {
|
||||
Long longArcId = Long.parseLong(arcId);
|
||||
return jpaRepository.findByArcId(longArcId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Chapter> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresCharacterRepository implements CharacterRepository {
|
||||
@@ -36,7 +35,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
||||
public List<Character> findByPlaythroughId(String playthroughId) {
|
||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -53,7 +52,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
||||
public List<Character> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Character toDomainEntity(CharacterJpaEntity e) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,13 +7,13 @@ import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
|
||||
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import org.hibernate.Hibernate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur Postgres pour ConversationRepository.
|
||||
@@ -46,7 +46,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
||||
return jpa.findById(Long.parseLong(id))
|
||||
.map(e -> {
|
||||
// Force l'initialisation LAZY avant de sortir de la transaction.
|
||||
e.getMessages().size();
|
||||
Hibernate.initialize(e.getMessages());
|
||||
return toDomain(e, true);
|
||||
});
|
||||
}
|
||||
@@ -66,7 +66,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList());
|
||||
return rows.stream().map(e -> toDomain(e, false)).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -88,7 +88,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
||||
.build();
|
||||
conv.getMessages().add(msg);
|
||||
// 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);
|
||||
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) {
|
||||
List<ConversationMessage> msgs = withMessages
|
||||
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList())
|
||||
? e.getMessages().stream().map(this::toDomainMessage).toList()
|
||||
: new java.util.ArrayList<>();
|
||||
return Conversation.builder()
|
||||
.id(e.getId().toString())
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresEnemyRepository implements EnemyRepository {
|
||||
@@ -34,7 +33,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
||||
public List<Enemy> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -46,7 +45,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
||||
public List<Enemy> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
@@ -35,7 +34,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
public List<GameSystem> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -52,7 +51,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||
public List<GameSystem> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
|
||||
|
||||
@@ -64,7 +64,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
||||
public List<ItemCatalog> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -82,7 +82,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
||||
public List<ItemCatalog> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository.
|
||||
@@ -41,7 +40,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
Long longLoreId = Long.parseLong(loreId);
|
||||
return jpaRepository.findByLoreId(longLoreId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -49,19 +48,19 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
if (parentId == null || parentId.isEmpty()) {
|
||||
return jpaRepository.findByParentId(null).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
Long longParentId = Long.parseLong(parentId);
|
||||
return jpaRepository.findByParentId(longParentId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<LoreNode> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -85,7 +84,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
||||
public List<LoreNode> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port LoreRepository.
|
||||
@@ -42,7 +41,7 @@ public class PostgresLoreRepository implements LoreRepository {
|
||||
public List<Lore> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -61,7 +60,7 @@ public class PostgresLoreRepository implements LoreRepository {
|
||||
public List<Lore> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Méthodes de conversion
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresNotebookRepository implements NotebookRepository {
|
||||
@@ -53,7 +52,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
@Override
|
||||
public List<Notebook> findByCampaignId(String campaignId) {
|
||||
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toNotebook).collect(Collectors.toList());
|
||||
.map(this::toNotebook).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -93,7 +92,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
@Override
|
||||
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
|
||||
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
.map(this::toSource).collect(Collectors.toList());
|
||||
.map(this::toSource).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -116,19 +115,20 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
||||
@Override
|
||||
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
|
||||
return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
.map(this::toMessage).collect(Collectors.toList());
|
||||
.map(this::toMessage).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
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
|
||||
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
|
||||
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||
.map(this::toMessage).collect(Collectors.toList());
|
||||
.map(this::toMessage).toList();
|
||||
}
|
||||
|
||||
// --- Mapping ---
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresNpcRepository implements NpcRepository {
|
||||
@@ -37,7 +36,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
||||
public List<Npc> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -54,7 +53,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
||||
public List<Npc> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port PageRepository.
|
||||
@@ -42,7 +41,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
public List<Page> findByLoreId(String loreId) {
|
||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -50,14 +49,14 @@ public class PostgresPageRepository implements PageRepository {
|
||||
Long longNodeId = Long.parseLong(nodeId);
|
||||
return jpaRepository.findByNodeId(longNodeId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Page> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -81,7 +80,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
public List<Page> searchByTitle(String query) {
|
||||
return jpaRepository.findByTitleContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Page toDomainEntity(PageJpaEntity e) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresPlaythroughRepository implements PlaythroughRepository {
|
||||
@@ -34,12 +33,12 @@ public class PostgresPlaythroughRepository implements PlaythroughRepository {
|
||||
public List<Playthrough> findByCampaignId(String campaignId) {
|
||||
return jpa.findByCampaignId(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Playthrough> findAll() {
|
||||
return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList());
|
||||
return jpa.findAll().stream().map(this::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Repository
|
||||
public class PostgresQuestProgressionRepository implements QuestProgressionRepository {
|
||||
@@ -25,7 +24,7 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos
|
||||
public List<QuestProgression> findByPlaythroughId(String playthroughId) {
|
||||
return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port QuestRepository.
|
||||
@@ -40,7 +39,7 @@ public class PostgresQuestRepository implements QuestRepository {
|
||||
public List<Quest> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@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
|
||||
return jpaRepository.findByArcId(Long.parseLong(arcId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Quest> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -68,7 +68,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
||||
public List<RandomTable> findByCampaignId(String campaignId) {
|
||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -86,7 +86,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
||||
public List<RandomTable> searchByName(String query) {
|
||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port SceneRepository.
|
||||
@@ -43,14 +42,14 @@ public class PostgresSceneRepository implements SceneRepository {
|
||||
Long longChapterId = Long.parseLong(chapterId);
|
||||
return jpaRepository.findByChapterId(longChapterId).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Scene> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
|
||||
@@ -38,7 +37,7 @@ public class PostgresSessionEntryRepository implements SessionEntryRepository {
|
||||
public List<SessionEntry> findBySessionId(String sessionId) {
|
||||
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant
|
||||
@@ -38,14 +37,14 @@ public class PostgresSessionRepository implements SessionRepository {
|
||||
public List<Session> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Session> findByPlaythroughId(String playthroughId) {
|
||||
return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream()
|
||||
.map(this::toDomain)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Adaptateur d'infrastructure qui implémente le Port TemplateRepository.
|
||||
@@ -40,14 +39,14 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
||||
public List<Template> findAll() {
|
||||
return jpaRepository.findAll().stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Template> findByLoreId(String loreId) {
|
||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,7 +63,7 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
||||
public List<Template> searchByName(String query) {
|
||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||
.map(this::toDomainEntity)
|
||||
.collect(Collectors.toList());
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- Mapping ----------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.loremind.infrastructure.storage;
|
||||
|
||||
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.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -26,6 +28,8 @@ import java.util.UUID;
|
||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
||||
public class FilesystemFileStorageAdapter implements FileStorage {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FilesystemFileStorageAdapter.class);
|
||||
|
||||
private final Path root;
|
||||
|
||||
public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
||||
@@ -36,7 +40,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
|
||||
void ensureRootExists() {
|
||||
try {
|
||||
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) {
|
||||
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
|
||||
}
|
||||
@@ -84,7 +88,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
|
||||
try {
|
||||
Files.deleteIfExists(resolveKey(storageKey));
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Storage] Erreur suppression fichier (non bloquante) : " + e.getMessage());
|
||||
log.warn("[Storage] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.loremind.infrastructure.storage;
|
||||
|
||||
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.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -31,6 +33,8 @@ import java.util.UUID;
|
||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
||||
public class FilesystemImageStorageAdapter implements ImageStorage {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(FilesystemImageStorageAdapter.class);
|
||||
|
||||
private final Path root;
|
||||
|
||||
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
||||
@@ -41,7 +45,7 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
|
||||
void ensureRootExists() {
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
System.out.println("[Storage] Backend filesystem actif — racine : " + root);
|
||||
log.info("[Storage] Backend filesystem actif — racine : {}", root);
|
||||
} catch (IOException 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));
|
||||
} catch (IOException e) {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.errors.ErrorResponseException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -24,6 +26,8 @@ import java.util.UUID;
|
||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
||||
public class MinioFileStorageAdapter implements FileStorage {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MinioFileStorageAdapter.class);
|
||||
|
||||
private final MinioClient minioClient;
|
||||
private final String bucket;
|
||||
|
||||
@@ -90,7 +94,7 @@ public class MinioFileStorageAdapter implements FileStorage {
|
||||
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||
);
|
||||
} catch (Exception e) {
|
||||
System.err.println("[MinIO] Erreur suppression fichier (non bloquante) : " + e.getMessage());
|
||||
log.warn("[MinIO] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import io.minio.MinioClient;
|
||||
import io.minio.PutObjectArgs;
|
||||
import io.minio.RemoveObjectArgs;
|
||||
import io.minio.errors.ErrorResponseException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -27,6 +29,8 @@ import java.util.UUID;
|
||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
||||
public class MinioImageStorageAdapter implements ImageStorage {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MinioImageStorageAdapter.class);
|
||||
|
||||
private final MinioClient minioClient;
|
||||
private final String bucket;
|
||||
|
||||
@@ -95,7 +99,7 @@ public class MinioImageStorageAdapter implements ImageStorage {
|
||||
);
|
||||
} catch (Exception e) {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,19 @@ class CampaignContentInserter {
|
||||
}
|
||||
|
||||
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())) {
|
||||
CampaignJpaEntity e = new CampaignJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -90,8 +102,10 @@ class CampaignContentInserter {
|
||||
maps.campaignMap.put(d.id(), campaignRepo.save(e).getId());
|
||||
}
|
||||
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())) {
|
||||
ArcJpaEntity e = new ArcJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -110,9 +124,12 @@ class CampaignContentInserter {
|
||||
maps.arcMap.put(d.id(), arcRepo.save(e).getId());
|
||||
}
|
||||
result.count("arcs", maps.arcMap.size());
|
||||
}
|
||||
|
||||
// -- ItemCatalog (+ items en cascade)
|
||||
int catalogCount = 0, itemCount = 0;
|
||||
/** ItemCatalog (+ items en cascade). */
|
||||
private void insertItemCatalogs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||
int catalogCount = 0;
|
||||
int itemCount = 0;
|
||||
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
|
||||
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -138,9 +155,12 @@ class CampaignContentInserter {
|
||||
}
|
||||
result.count("itemCatalogs", catalogCount);
|
||||
result.count("catalogItems", itemCount);
|
||||
}
|
||||
|
||||
// -- RandomTable (+ entries en cascade)
|
||||
int tableCount = 0, entryCount = 0;
|
||||
/** RandomTable (+ entries en cascade). */
|
||||
private void insertRandomTables(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||
int tableCount = 0;
|
||||
int entryCount = 0;
|
||||
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
|
||||
RandomTableJpaEntity e = new RandomTableJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -167,9 +187,13 @@ class CampaignContentInserter {
|
||||
}
|
||||
result.count("randomTables", tableCount);
|
||||
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())) {
|
||||
ChapterJpaEntity e = new ChapterJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -185,9 +209,16 @@ class CampaignContentInserter {
|
||||
maps.chapterMap.put(d.id(), chapterRepo.save(e).getId());
|
||||
}
|
||||
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())) {
|
||||
QuestJpaEntity e = new QuestJpaEntity();
|
||||
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
|
||||
@@ -206,15 +237,14 @@ class CampaignContentInserter {
|
||||
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) {
|
||||
legacyQuestConverter.convertLegacyChaptersToQuests(export, maps);
|
||||
}
|
||||
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())) {
|
||||
NpcJpaEntity e = new NpcJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -230,8 +260,10 @@ class CampaignContentInserter {
|
||||
maps.npcMap.put(d.id(), npcRepo.save(e).getId());
|
||||
}
|
||||
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())) {
|
||||
EnemyJpaEntity e = new EnemyJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -249,8 +281,10 @@ class CampaignContentInserter {
|
||||
maps.enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
||||
}
|
||||
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())) {
|
||||
SceneJpaEntity e = new SceneJpaEntity();
|
||||
e.setName(d.name());
|
||||
@@ -270,15 +304,7 @@ class CampaignContentInserter {
|
||||
e.setEnemyIds(d.enemyIds()); // remappe en 2e passe
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
|
||||
e.setIllustrationImageIds(IdRemapper.remapStringList(maps.imageMap, d.illustrationImageIds()));
|
||||
// Battlemaps : ids StoredFile remappes via storedFileMap. Les exports anterieurs
|
||||
// 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.setBattlemaps(resolveBattlemaps(d, maps));
|
||||
e.setGraphX(d.graphX());
|
||||
e.setGraphY(d.graphY());
|
||||
e.setBranches(d.branches()); // remappe en 2e passe
|
||||
@@ -290,6 +316,20 @@ class CampaignContentInserter {
|
||||
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) {
|
||||
return list != null ? list : List.of();
|
||||
}
|
||||
|
||||
@@ -191,30 +191,96 @@ public class ExportService {
|
||||
CampaignJpaEntity campaign = campaignRepo.findById(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<ChapterJpaEntity> chapterEntities = arcEntities.stream()
|
||||
.flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
|
||||
List<SceneJpaEntity> sceneEntities = chapterEntities.stream()
|
||||
.flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
|
||||
List<NpcJpaEntity> npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid);
|
||||
List<EnemyJpaEntity> enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
|
||||
List<ItemCatalogJpaEntity> catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid);
|
||||
List<RandomTableJpaEntity> tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid);
|
||||
return new StructuralClosure(arcEntities, chapterEntities, sceneEntities,
|
||||
npcRepo.findByCampaignIdOrderByOrderAsc(cid), enemyRepo.findByCampaignIdOrderByOrderAsc(cid),
|
||||
itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid), randomTableRepo.findByCampaignIdOrderByOrderAsc(cid));
|
||||
}
|
||||
|
||||
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
|
||||
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
|
||||
/** Univers (lore) lié à la campagne : optionnel selon {@code req.includeLore()}. */
|
||||
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;
|
||||
List<LoreJpaEntity> loreEntities = lid != null ? singleton(loreRepo, lid) : List.of();
|
||||
List<LoreNodeJpaEntity> loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of();
|
||||
List<TemplateJpaEntity> templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of();
|
||||
List<PageJpaEntity> pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of();
|
||||
if (lid == null) return LoreClosure.EMPTY;
|
||||
return new LoreClosure(singleton(loreRepo, lid), loreNodeRepo.findByLoreId(lid),
|
||||
templateRepo.findByLoreId(lid), pageRepo.findByLoreId(lid));
|
||||
}
|
||||
|
||||
// Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie,
|
||||
// donc « sans jeu » = sans feuilles de perso.
|
||||
List<PlaythroughJpaEntity> ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of();
|
||||
/**
|
||||
* Espace de jeu (parties -> séances/journal/flags/quêtes + feuilles de perso) : optionnel
|
||||
* 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()
|
||||
.flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
|
||||
List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream()
|
||||
@@ -229,74 +295,52 @@ public class ExportService {
|
||||
.flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
|
||||
List<CharacterJpaEntity> characterEntities = ptEntities.stream()
|
||||
.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.
|
||||
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid);
|
||||
/** Binaires (images/fichiers) référencés par la clôture exportée. */
|
||||
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).
|
||||
List<ImageJpaEntity> imageEntities = List.of();
|
||||
List<StoredFileJpaEntity> fileEntities = List.of();
|
||||
if (req.includeImages()) {
|
||||
Set<String> imageRefs = new LinkedHashSet<>();
|
||||
arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
|
||||
chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
|
||||
campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds()));
|
||||
sceneEntities.forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
|
||||
sceneEntities.forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
|
||||
npcEntities.forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
|
||||
enemyEntities.forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
|
||||
characterEntities.forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
|
||||
pageEntities.forEach(p -> addImageValues(imageRefs, p.getImageValues()));
|
||||
imageEntities = imageRefs.stream()
|
||||
.map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
|
||||
.map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
|
||||
.distinct().toList();
|
||||
private BinaryClosure loadBinaryClosure(StructuralClosure structure, LoreClosure lore, PlayClosure play,
|
||||
List<QuestJpaEntity> campaignQuests) {
|
||||
Set<String> imageRefs = new LinkedHashSet<>();
|
||||
structure.arcs().forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
|
||||
structure.chapters().forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
|
||||
campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds()));
|
||||
structure.scenes().forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
|
||||
structure.scenes().forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
|
||||
structure.npcs().forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
|
||||
structure.enemies().forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
|
||||
play.characters().forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
|
||||
lore.pages().forEach(p -> addImageValues(imageRefs, p.getImageValues()));
|
||||
List<ImageJpaEntity> imageEntities = imageRefs.stream()
|
||||
.map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
|
||||
.map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
|
||||
.distinct().toList();
|
||||
|
||||
Set<Long> fileRefs = new LinkedHashSet<>();
|
||||
sceneEntities.forEach(s -> {
|
||||
if (s.getBattlemaps() == null) return;
|
||||
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
|
||||
});
|
||||
fileEntities = fileRefs.stream()
|
||||
.map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
|
||||
}
|
||||
Set<Long> fileRefs = new LinkedHashSet<>();
|
||||
structure.scenes().forEach(s -> {
|
||||
if (s.getBattlemaps() == null) return;
|
||||
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
|
||||
});
|
||||
List<StoredFileJpaEntity> fileEntities = fileRefs.stream()
|
||||
.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
|
||||
// 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());
|
||||
}
|
||||
return new BinaryClosure(imageEntities, fileEntities);
|
||||
}
|
||||
|
||||
ContentExport.Manifest manifest =
|
||||
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName());
|
||||
return new ContentExport(manifest,
|
||||
map(gsEntities, this::toGameSystemDto),
|
||||
map(loreEntities, this::toLoreDto),
|
||||
map(loreNodeEntities, this::toLoreNodeDto),
|
||||
map(templateEntities, this::toTemplateDto),
|
||||
map(pageEntities, this::toPageDto),
|
||||
List.of(campaignDto),
|
||||
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));
|
||||
/**
|
||||
* DTO Campaign : si le lore n'est pas exporté, on neutralise loreId (évite une
|
||||
* référence pendante vers un univers absent à l'import).
|
||||
*/
|
||||
private ContentExport.CampaignDto campaignDto(CampaignJpaEntity campaign, boolean includeLore) {
|
||||
ContentExport.CampaignDto dto = toCampaignDto(campaign);
|
||||
if (includeLore) return dto;
|
||||
return new ContentExport.CampaignDto(dto.id(), dto.name(), dto.description(),
|
||||
dto.arcsCount(), dto.playerCount(), null, dto.gameSystemId());
|
||||
}
|
||||
|
||||
// ----- Helpers de chargement -----
|
||||
@@ -345,44 +389,63 @@ public class ExportService {
|
||||
zip.closeEntry();
|
||||
|
||||
// Binaires images : uniquement ceux reellement references.
|
||||
Set<String> referenced = 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();
|
||||
}
|
||||
}
|
||||
writeImageBinaries(zip, collectReferencedStorageKeys(export));
|
||||
|
||||
// Binaires fichiers (battlemaps : media + sidecar) : ceux references par
|
||||
// les scenes. Stockes a part sous "files/<storageKey>".
|
||||
Set<String> referencedFiles = 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();
|
||||
}
|
||||
}
|
||||
writeFileBinaries(zip, collectReferencedFileStorageKeys(export));
|
||||
} catch (IOException 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 :
|
||||
* 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),
|
||||
// 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.
|
||||
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<>();
|
||||
for (ContentExport.ImageDto img : export.images()) {
|
||||
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<>();
|
||||
for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
|
||||
for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
|
||||
if (export.quests() != null) {
|
||||
for (ContentExport.QuestDto q : export.quests()) addAll(refs, q.illustrationImageIds());
|
||||
}
|
||||
addQuestImageRefs(refs, export.quests());
|
||||
for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds());
|
||||
for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms());
|
||||
for (ContentExport.CharacterDto c : export.characters()) {
|
||||
add(refs, c.portraitImageId());
|
||||
add(refs, c.headerImageId());
|
||||
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.CharacterDto c : export.characters()) addCharacterImageRefs(refs, c);
|
||||
for (ContentExport.NpcDto n : export.npcs()) addNpcImageRefs(refs, n);
|
||||
for (ContentExport.EnemyDto e : export.enemies()) addEnemyImageRefs(refs, e);
|
||||
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<>();
|
||||
for (String ref : refs) {
|
||||
String key = keyByImageId.get(ref);
|
||||
|
||||
@@ -46,7 +46,7 @@ final class IdRemapper {
|
||||
}
|
||||
|
||||
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());
|
||||
for (String id : ids) out.add(remapStringId(map, id));
|
||||
return out;
|
||||
@@ -58,7 +58,7 @@ final class IdRemapper {
|
||||
*/
|
||||
static Map<String, List<String>> remapImageValues(Map<Long, Long> imageMap,
|
||||
Map<String, List<String>> imageValues) {
|
||||
if (imageValues == null) return null;
|
||||
if (imageValues == null) return Map.of();
|
||||
Map<String, List<String>> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> e : imageValues.entrySet()) {
|
||||
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,
|
||||
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<>();
|
||||
for (Map.Entry<String, Map<String, V>> outer : framing.entrySet()) {
|
||||
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.
|
||||
*/
|
||||
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) {
|
||||
if (r == null) continue;
|
||||
r.setIllustrationImageIds(remapStringList(imageMap, r.getIllustrationImageIds()));
|
||||
@@ -113,7 +113,7 @@ final class IdRemapper {
|
||||
* on reconstruit chaque entrée.
|
||||
*/
|
||||
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());
|
||||
for (SceneBattlemap bm : battlemaps) {
|
||||
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).
|
||||
*/
|
||||
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());
|
||||
for (Prerequisite p : prereqs) {
|
||||
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). */
|
||||
static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap,
|
||||
List<QuestNodeRef> nodes) {
|
||||
if (nodes == null) return null;
|
||||
if (nodes == null) return List.of();
|
||||
List<QuestNodeRef> out = new ArrayList<>(nodes.size());
|
||||
for (QuestNodeRef n : nodes) {
|
||||
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) {
|
||||
if (branches == null) return null;
|
||||
if (branches == null) return List.of();
|
||||
List<SceneBranch> out = new ArrayList<>(branches.size());
|
||||
for (SceneBranch b : branches) {
|
||||
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind()));
|
||||
|
||||
@@ -52,41 +52,47 @@ class ImageImporter {
|
||||
ImportIdMaps maps,
|
||||
ImportResult.Builder result) {
|
||||
for (ContentExport.ImageDto meta : nullSafe(export.images())) {
|
||||
String storageKey = meta.storageKey();
|
||||
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();
|
||||
importImage(meta, imageBinaries, maps, result);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (oldId != null && newId != null) maps.imageMap.put(oldId, newId);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,20 @@ class ImportReferenceRemapper {
|
||||
}
|
||||
|
||||
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) {
|
||||
Long newParent = maps.loreNodeMap.get(e.getParentId());
|
||||
if (newParent != null) {
|
||||
@@ -66,8 +79,10 @@ class ImportReferenceRemapper {
|
||||
loreNodeRepo.save(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template.defaultNodeId
|
||||
/** Template.defaultNodeId. */
|
||||
private void remapTemplateDefaultNodes(ImportIdMaps maps) {
|
||||
for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) {
|
||||
Long newTemplateId = maps.templateMap.get(d.id());
|
||||
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()) {
|
||||
campaignRepo.findById(newCampaignId).ifPresent(c -> {
|
||||
String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId());
|
||||
@@ -89,40 +106,50 @@ class ImportReferenceRemapper {
|
||||
campaignRepo.save(c);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Page.relatedPageIds
|
||||
/** Page.relatedPageIds. */
|
||||
private void remapPageRelatedPages(ImportIdMaps maps) {
|
||||
for (Long newPageId : maps.pageMap.values()) {
|
||||
pageRepo.findById(newPageId).ifPresent(p -> {
|
||||
p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds()));
|
||||
pageRepo.save(p);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Arc.relatedPageIds
|
||||
/** Arc.relatedPageIds. */
|
||||
private void remapArcRelatedPages(ImportIdMaps maps) {
|
||||
for (Long newArcId : maps.arcMap.values()) {
|
||||
arcRepo.findById(newArcId).ifPresent(a -> {
|
||||
a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds()));
|
||||
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()) {
|
||||
chapterRepo.findById(newChapterId).ifPresent(c -> {
|
||||
c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds()));
|
||||
chapterRepo.save(c);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Npc.relatedPageIds
|
||||
/** Npc.relatedPageIds. */
|
||||
private void remapNpcRelatedPages(ImportIdMaps maps) {
|
||||
for (Long newNpcId : maps.npcMap.values()) {
|
||||
npcRepo.findById(newNpcId).ifPresent(n -> {
|
||||
n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds()));
|
||||
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()) {
|
||||
sceneRepo.findById(newSceneId).ifPresent(s -> {
|
||||
s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds()));
|
||||
@@ -131,9 +158,13 @@ class ImportReferenceRemapper {
|
||||
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()) {
|
||||
questRepo.findById(newQuestId).ifPresent(q -> {
|
||||
q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites()));
|
||||
@@ -142,9 +173,13 @@ class ImportReferenceRemapper {
|
||||
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()) {
|
||||
clockRepo.findById(newClockId).ifPresent(c -> {
|
||||
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
|
||||
|
||||
@@ -40,6 +40,8 @@ class LegacyQuestConverter {
|
||||
* quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme
|
||||
* pour les quêtes v2.
|
||||
*/
|
||||
// S125 : faux positif — commentaires explicatifs (prose), pas de code mort.
|
||||
@SuppressWarnings("java:S125")
|
||||
void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) {
|
||||
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
|
||||
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
|
||||
|
||||
@@ -46,38 +46,55 @@ class StoredFileImporter {
|
||||
ImportResult.Builder result) {
|
||||
int imported = 0;
|
||||
for (ContentExport.StoredFileDto meta : nullSafe(export.storedFiles())) {
|
||||
String storageKey = meta.storageKey();
|
||||
if (storageKey == null || storageKey.isBlank()) continue;
|
||||
|
||||
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;
|
||||
if (importOne(meta, fileBinaries, maps)) {
|
||||
imported++;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (oldId != null && newId != null) maps.storedFileMap.put(oldId, newId);
|
||||
}
|
||||
|
||||
@@ -124,13 +124,37 @@ public class FoundryExportService {
|
||||
|
||||
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.Quest> quests = new ArrayList<>();
|
||||
List<FoundryBundle.Scene> scenes = new ArrayList<>();
|
||||
|
||||
List<ArcJpaEntity> arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
|
||||
for (ArcJpaEntity arc : arcEntities) {
|
||||
for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
|
||||
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
|
||||
// leurs illustrations ne sont pas embarquees.
|
||||
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<>();
|
||||
if (opts.journals()) {
|
||||
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
npcs.add(new FoundryBundle.Persona(
|
||||
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
|
||||
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
|
||||
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
|
||||
}
|
||||
if (!opts.journals()) return npcs;
|
||||
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
npcs.add(new FoundryBundle.Persona(
|
||||
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
|
||||
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
|
||||
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
|
||||
// les journaux -> non embarquees en mode cartes seules.
|
||||
/**
|
||||
* Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token)
|
||||
* 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<>();
|
||||
if (opts.maps() || opts.journals()) {
|
||||
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
enemies.add(new FoundryBundle.Persona(
|
||||
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
||||
assets.image(e.getPortraitImageId()),
|
||||
opts.journals() ? assets.image(e.getHeaderImageId()) : null,
|
||||
e.getLevel(),
|
||||
e.getFoundryRef(),
|
||||
buildFoundryActor(e, enemyTemplate, foundryActorType),
|
||||
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
|
||||
opts.journals() ? e.getImageValues() : null, assets)));
|
||||
}
|
||||
if (!opts.maps() && !opts.journals()) return enemies;
|
||||
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
enemies.add(new FoundryBundle.Persona(
|
||||
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
||||
assets.image(e.getPortraitImageId()),
|
||||
opts.journals() ? assets.image(e.getHeaderImageId()) : null,
|
||||
e.getLevel(),
|
||||
e.getFoundryRef(),
|
||||
buildFoundryActor(e, enemyTemplate, foundryActorType),
|
||||
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
|
||||
opts.journals() ? e.getImageValues() : null, assets)));
|
||||
}
|
||||
return enemies;
|
||||
}
|
||||
|
||||
private List<FoundryBundle.RandomTable> buildRandomTables(CampaignJpaEntity campaign, ExportOptions opts) {
|
||||
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
|
||||
for (RandomTableJpaEntity t : opts.tables()
|
||||
? randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())
|
||||
: List.<RandomTableJpaEntity>of()) {
|
||||
if (!opts.tables()) return randomTables;
|
||||
for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
|
||||
if (t.getEntries() != null) {
|
||||
for (RandomTableEntryJpaEntity en : t.getEntries()) {
|
||||
@@ -194,29 +229,25 @@ public class FoundryExportService {
|
||||
randomTables.add(new FoundryBundle.RandomTable(
|
||||
str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries));
|
||||
}
|
||||
return randomTables;
|
||||
}
|
||||
|
||||
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()),
|
||||
arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
|
||||
|
||||
private FoundryBundle.Manifest buildManifest(CampaignJpaEntity campaign, String exportedAt,
|
||||
ArcsQuestsScenes structure, List<FoundryBundle.Persona> npcs,
|
||||
List<FoundryBundle.Persona> enemies,
|
||||
List<FoundryBundle.RandomTable> randomTables, AssetRegistry assets) {
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
counts.put("arcs", arcs.size());
|
||||
counts.put("quests", quests.size());
|
||||
counts.put("scenes", scenes.size());
|
||||
counts.put("arcs", structure.arcs().size());
|
||||
counts.put("quests", structure.quests().size());
|
||||
counts.put("scenes", structure.scenes().size());
|
||||
counts.put("npcs", npcs.size());
|
||||
counts.put("enemies", enemies.size());
|
||||
counts.put("randomTables", randomTables.size());
|
||||
counts.put("assets", assets.assets().size());
|
||||
|
||||
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest(
|
||||
return new FoundryBundle.Manifest(
|
||||
FORMAT_VERSION, "loremind", appVersion, exportedAt,
|
||||
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). */
|
||||
@@ -233,24 +264,36 @@ public class FoundryExportService {
|
||||
zip.write(writer.writeValueAsBytes(bundle.data()));
|
||||
zip.closeEntry();
|
||||
|
||||
Set<String> written = new HashSet<>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
writeBinaries(zip, bundle.binaries());
|
||||
} catch (IOException 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 -----
|
||||
|
||||
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) {
|
||||
if (actorType == null || actorType.isBlank() || template == null) return null;
|
||||
if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null;
|
||||
|
||||
Map<String, Object> system = new LinkedHashMap<>();
|
||||
Map<String, String> values = e.getValues();
|
||||
boolean any = false;
|
||||
for (TemplateField f : template) {
|
||||
if (f == null || f.getName() == null) continue;
|
||||
String path = f.getFoundryPath();
|
||||
if (path == null || path.isBlank()) continue;
|
||||
String raw = values != null ? values.get(f.getName()) : null;
|
||||
if (raw == null || raw.isBlank()) continue;
|
||||
setPath(system, path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
|
||||
any = true;
|
||||
FieldContribution c = foundryFieldContribution(f, values);
|
||||
if (c != null) {
|
||||
setPath(system, c.path(), c.value());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
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"). */
|
||||
@SuppressWarnings("unchecked")
|
||||
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, List<String>> imageValues,
|
||||
AssetRegistry assets) {
|
||||
List<FoundryBundle.Field> out = new ArrayList<>();
|
||||
// Repli : pas de template -> paires brutes label=cle.
|
||||
if (template == null || template.isEmpty()) {
|
||||
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;
|
||||
return rawFields(values);
|
||||
}
|
||||
List<FoundryBundle.Field> out = new ArrayList<>();
|
||||
for (TemplateField f : template) {
|
||||
if (f == null || f.getName() == null || f.getType() == null) continue;
|
||||
FieldType type = f.getType();
|
||||
if (type == FieldType.TEXT || type == FieldType.NUMBER) {
|
||||
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));
|
||||
}
|
||||
FoundryBundle.Field field = toField(f, values, keyValueValues, imageValues, assets);
|
||||
if (field != null) {
|
||||
out.add(field);
|
||||
}
|
||||
// TABLE : pas de stockage cote PNJ/Ennemi -> ignore.
|
||||
}
|
||||
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 -----
|
||||
|
||||
private final class AssetRegistry {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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("&");
|
||||
case '<' -> b.append("<");
|
||||
case '>' -> b.append(">");
|
||||
case '"' -> b.append(""");
|
||||
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 <br/>. */
|
||||
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 <br/>) 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.loremind.infrastructure.transfer.pdf;
|
||||
|
||||
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
import com.loremind.infrastructure.persistence.jpa.*;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.ToIntFunction;
|
||||
|
||||
/**
|
||||
* Charge la {@link Structure} narrative d'une campagne en UNE passe : arcs/chapitres/scenes
|
||||
* et quetes tries par ordre manuel, index de noms, et resolution de la FUSION quete/conteneur
|
||||
* de l'arbre (cf. QuestService) — l'arc SYSTEM « Quetes libres » et les chapitres-conteneurs
|
||||
* sont identifies ici pour que le rendu ne duplique pas les quetes.
|
||||
*/
|
||||
@Component
|
||||
class PdfStructureLoader {
|
||||
|
||||
private final ArcJpaRepository arcRepo;
|
||||
private final ChapterJpaRepository chapterRepo;
|
||||
private final SceneJpaRepository sceneRepo;
|
||||
private final QuestJpaRepository questRepo;
|
||||
private final EnemyJpaRepository enemyRepo;
|
||||
|
||||
PdfStructureLoader(ArcJpaRepository arcRepo, ChapterJpaRepository chapterRepo,
|
||||
SceneJpaRepository sceneRepo, QuestJpaRepository questRepo,
|
||||
EnemyJpaRepository enemyRepo) {
|
||||
this.arcRepo = arcRepo;
|
||||
this.chapterRepo = chapterRepo;
|
||||
this.sceneRepo = sceneRepo;
|
||||
this.questRepo = questRepo;
|
||||
this.enemyRepo = enemyRepo;
|
||||
}
|
||||
|
||||
Structure load(CampaignJpaEntity campaign) {
|
||||
Structure st = new Structure();
|
||||
Set<Long> systemArcIds = loadArcs(st, campaign);
|
||||
Map<Long, Long> arcOfChapter = loadChaptersAndScenes(st);
|
||||
loadEnemyNames(st, campaign);
|
||||
loadQuests(st, campaign);
|
||||
linkQuestsToContainers(st, arcOfChapter, systemArcIds);
|
||||
return st;
|
||||
}
|
||||
|
||||
/** Arcs tries + separation visible/systeme ; alimente st.arcs et st.visibleArcs. */
|
||||
private Set<Long> loadArcs(Structure st, CampaignJpaEntity campaign) {
|
||||
st.arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
|
||||
List<ArcJpaEntity> visible = new ArrayList<>();
|
||||
Set<Long> systemArcIds = new HashSet<>();
|
||||
for (ArcJpaEntity arc : st.arcs) {
|
||||
if (arc.getType() == ArcType.SYSTEM) systemArcIds.add(arc.getId());
|
||||
else visible.add(arc);
|
||||
}
|
||||
st.visibleArcs = visible;
|
||||
return systemArcIds;
|
||||
}
|
||||
|
||||
/** Chapitres (par arc) + scenes (par chapitre), tries + index de noms. @return arc de chaque chapitre. */
|
||||
private Map<Long, Long> loadChaptersAndScenes(Structure st) {
|
||||
Map<Long, Long> arcOfChapter = new HashMap<>();
|
||||
for (ArcJpaEntity arc : st.arcs) {
|
||||
List<ChapterJpaEntity> chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder);
|
||||
st.chaptersByArc.put(arc.getId(), chapters);
|
||||
for (ChapterJpaEntity ch : chapters) {
|
||||
arcOfChapter.put(ch.getId(), arc.getId());
|
||||
st.chapterNames.put(String.valueOf(ch.getId()), ch.getName());
|
||||
List<SceneJpaEntity> scenes = sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder);
|
||||
st.scenesByChapter.put(ch.getId(), scenes);
|
||||
for (SceneJpaEntity sc : scenes) st.sceneNames.put(String.valueOf(sc.getId()), sc.getName());
|
||||
}
|
||||
}
|
||||
return arcOfChapter;
|
||||
}
|
||||
|
||||
private void loadEnemyNames(Structure st, CampaignJpaEntity campaign) {
|
||||
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||
st.enemyNames.put(String.valueOf(e.getId()), e.getName());
|
||||
}
|
||||
}
|
||||
|
||||
private void loadQuests(Structure st, CampaignJpaEntity campaign) {
|
||||
st.quests = sortByOrder(questRepo.findByCampaignId(campaign.getId()), QuestJpaEntity::getOrder);
|
||||
for (QuestJpaEntity q : st.quests) st.questNames.put(String.valueOf(q.getId()), q.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Meme regle que QuestService.isContainerOf : un chapitre reference est le CONTENEUR
|
||||
* de la quete s'il vit dans l'arc de la quete (jumeau hub) ou dans l'arc SYSTEM.
|
||||
*/
|
||||
private void linkQuestsToContainers(Structure st, Map<Long, Long> arcOfChapter, Set<Long> systemArcIds) {
|
||||
for (QuestJpaEntity q : st.quests) {
|
||||
if (!isFusedInNarrative(st, q, arcOfChapter, systemArcIds)) {
|
||||
st.standaloneQuests.add(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFusedInNarrative(Structure st, QuestJpaEntity q,
|
||||
Map<Long, Long> arcOfChapter, Set<Long> systemArcIds) {
|
||||
if (q.getNodes() == null) return false;
|
||||
boolean fused = false;
|
||||
for (QuestNodeRef n : q.getNodes()) {
|
||||
Long cid = containerChapterId(n, q, arcOfChapter, systemArcIds);
|
||||
if (cid == null) continue;
|
||||
st.questByContainerChapter.putIfAbsent(cid, q);
|
||||
if (st.questByContainerChapter.get(cid) == q && !systemArcIds.contains(arcOfChapter.get(cid))) {
|
||||
fused = true;
|
||||
}
|
||||
}
|
||||
return fused;
|
||||
}
|
||||
|
||||
/** Id du chapitre-conteneur si ce noeud EN est un pour cette quete, sinon null. */
|
||||
private static Long containerChapterId(QuestNodeRef n, QuestJpaEntity q,
|
||||
Map<Long, Long> arcOfChapter, Set<Long> systemArcIds) {
|
||||
if (n.nodeType() != NodeType.CHAPTER) return null;
|
||||
Long cid;
|
||||
try { cid = Long.parseLong(n.nodeId()); } catch (NumberFormatException ex) { return null; }
|
||||
Long arcId = arcOfChapter.get(cid);
|
||||
if (arcId == null) return null;
|
||||
boolean container = (q.getArcId() != null && q.getArcId().equals(arcId)) || systemArcIds.contains(arcId);
|
||||
return container ? cid : null;
|
||||
}
|
||||
|
||||
/** Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes. */
|
||||
private static <T> List<T> sortByOrder(List<T> list, ToIntFunction<T> order) {
|
||||
List<T> copy = new ArrayList<>(list);
|
||||
copy.sort(Comparator.comparingInt(order));
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.loremind.infrastructure.transfer.pdf;
|
||||
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Structure narrative de la campagne chargee UNE fois : arcs/chapitres/scenes/quetes
|
||||
* tries, index de noms, et liens quete -> chapitre-conteneur (fusion de l'arbre).
|
||||
* Peuplee par {@link PdfStructureLoader}, lue par PdfExportService lors du rendu.
|
||||
*/
|
||||
final class Structure {
|
||||
List<ArcJpaEntity> arcs = List.of();
|
||||
/** Arcs affiches en narration (l'arc technique SYSTEM est de la plomberie). */
|
||||
List<ArcJpaEntity> visibleArcs = List.of();
|
||||
final Map<Long, List<ChapterJpaEntity>> chaptersByArc = new LinkedHashMap<>();
|
||||
final Map<Long, List<SceneJpaEntity>> scenesByChapter = new LinkedHashMap<>();
|
||||
List<QuestJpaEntity> quests = List.of();
|
||||
/** Chapitre-conteneur -> quete fusionnee dessus (jumeau hub ou conteneur SYSTEM). */
|
||||
final Map<Long, QuestJpaEntity> questByContainerChapter = new LinkedHashMap<>();
|
||||
/** Quetes SANS conteneur dans la narration visible : rendues dans la partie « Quetes ». */
|
||||
final List<QuestJpaEntity> standaloneQuests = new ArrayList<>();
|
||||
final Map<String, String> chapterNames = new HashMap<>();
|
||||
final Map<String, String> sceneNames = new HashMap<>();
|
||||
final Map<String, String> questNames = new HashMap<>();
|
||||
/** Noms des fiches du bestiaire — resout les {@code enemyIds} des scenes/pieces. */
|
||||
final Map<String, String> enemyNames = new HashMap<>();
|
||||
|
||||
/** Ids (String) des chapitres-conteneurs de cette quete. */
|
||||
Set<String> containerChapterIds(QuestJpaEntity q) {
|
||||
Set<String> out = new HashSet<>();
|
||||
for (Map.Entry<Long, QuestJpaEntity> e : questByContainerChapter.entrySet()) {
|
||||
if (Objects.equals(e.getValue().getId(), q.getId())) out.add(String.valueOf(e.getKey()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user