Compare commits
8 Commits
v1.0.1-bet
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
| ad3ea0e9e6 | |||
| 0c44e42a22 | |||
| 1c8b24c5dd | |||
| 177967af55 | |||
| 42c4b53ced | |||
| 3e9e828225 | |||
| 51eb907078 | |||
| 9f0359d8c0 |
@@ -82,9 +82,11 @@ jobs:
|
|||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||||
|
|
||||||
|
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
|
||||||
|
# (GHESNotSupportedError — constaté sur le job MegaLinter).
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: web/playwright-report/
|
path: web/playwright-report/
|
||||||
|
|||||||
104
.gitea/workflows/quality.yml
Normal file
104
.gitea/workflows/quality.yml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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:
|
||||||
|
# beta inclus : c'est la branche de dev, le feedback qualité doit y vivre
|
||||||
|
# (ci.yml/e2e.yml, eux, restent volontairement sur main + PR).
|
||||||
|
branches: [main, beta]
|
||||||
|
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:
|
||||||
|
# main → scan complet du dépôt ; beta et PR → seulement les fichiers
|
||||||
|
# modifiés par rapport à main (rapide, feedback ciblé).
|
||||||
|
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
|
||||||
|
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
|
||||||
|
- name: Publier les rapports
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
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
|
||||||
|
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
|
||||||
|
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
|
||||||
|
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
|
||||||
|
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
|
||||||
|
# rend l'étape quasi gratuite d'un run à l'autre.
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '17'
|
||||||
|
cache: maven
|
||||||
|
- name: Précharger le cache Maven (~/.m2)
|
||||||
|
working-directory: core
|
||||||
|
run: |
|
||||||
|
chmod +x ./mvnw
|
||||||
|
./mvnw -B -q dependency:go-offline
|
||||||
|
- 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é.
|
||||||
|
# --offline-scan : AUCUNE requête vers Maven Central pendant la
|
||||||
|
# résolution — Trivy allait y chercher les POMs parents même avec le
|
||||||
|
# cache rempli, et l'IP du runner finissait rate-limitée (429, 30 min).
|
||||||
|
# Tout est déjà dans ~/.m2 grâce au dependency:go-offline ci-dessus ;
|
||||||
|
# contrepartie : une dépendance absente du cache serait ignorée en
|
||||||
|
# silence (impossible ici, go-offline échouerait d'abord).
|
||||||
|
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
|
||||||
|
run: |
|
||||||
|
trivy fs . \
|
||||||
|
--scanners vuln \
|
||||||
|
--severity HIGH,CRITICAL \
|
||||||
|
--ignore-unfixed \
|
||||||
|
--offline-scan \
|
||||||
|
--skip-dirs node_modules \
|
||||||
|
--skip-dirs docusaurus \
|
||||||
|
--exit-code 1
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -129,3 +129,5 @@ web/coverage/
|
|||||||
brain/htmlcov/
|
brain/htmlcov/
|
||||||
brain/.coverage
|
brain/.coverage
|
||||||
foundry-module/
|
foundry-module/
|
||||||
|
plan-promotion-loremind.md
|
||||||
|
post-reddit-foundryvtt.md
|
||||||
|
|||||||
43
.mega-linter.yml
Normal file
43
.mega-linter.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# PMD : ruleset projet à la racine (java-pmd-ruleset.xml, détecté
|
||||||
|
# automatiquement) — orienté bugs réels, sans style. Non-bloquant le temps
|
||||||
|
# de purger le backlog (~65 findings : PreserveStackTrace, EmptyCatchBlock,
|
||||||
|
# CheckResultSet, RelianceOnDefaultCharset…). Repasser à false ensuite.
|
||||||
|
JAVA_PMD_DISABLE_ERRORS: true
|
||||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="1.0.1-beta",
|
version="1.0.3",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
15
core/pom.xml
15
core/pom.xml
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>1.0.1-beta</version>
|
<version>1.0.3</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -24,6 +24,19 @@
|
|||||||
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
||||||
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
||||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||||
|
<!-- Overrides CVE (detectes par Trivy en CI — job quality.yml) :
|
||||||
|
proprietes reconnues par le BOM Spring Boot, comme ci-dessus. -->
|
||||||
|
<!-- >= 2.21.4 : CVE-2026-54512 / CVE-2026-54513 (execution de code
|
||||||
|
arbitraire via contournement du PolymorphicTypeValidator). -->
|
||||||
|
<jackson-bom.version>2.21.4</jackson-bom.version>
|
||||||
|
<!-- >= 4.1.135 : lot de CVE netty (DoS codec/handler, bypass de
|
||||||
|
verification hostname CVE-2026-50010, DNS CVE-2026-45674/47691). -->
|
||||||
|
<netty.version>4.1.135.Final</netty.version>
|
||||||
|
<!-- >= 10.1.55 : 3 CRITICAL Tomcat (CVE-2026-41293 headers HTTP/2 non
|
||||||
|
valides, CVE-2026-43512 bypass auth digest, CVE-2026-43515). -->
|
||||||
|
<tomcat.version>10.1.55</tomcat.version>
|
||||||
|
<!-- >= 42.7.11 : CVE-2026-42198 (DoS client via SCRAM-SHA-256). -->
|
||||||
|
<postgresql.version>42.7.11</postgresql.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ public class QuestService {
|
|||||||
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
|
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
|
||||||
private boolean isContainerOf(Quest quest, Chapter chapter) {
|
private boolean isContainerOf(Quest quest, Chapter chapter) {
|
||||||
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
|
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
|
||||||
|
return inSystemArc(chapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */
|
||||||
|
private boolean inSystemArc(Chapter chapter) {
|
||||||
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
|
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
|
||||||
.map(a -> a.getType() == ArcType.SYSTEM)
|
.map(a -> a.getType() == ArcType.SYSTEM)
|
||||||
.orElse(false);
|
.orElse(false);
|
||||||
@@ -156,6 +161,18 @@ public class QuestService {
|
|||||||
/**
|
/**
|
||||||
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
|
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
|
||||||
*
|
*
|
||||||
|
* <p>Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
|
||||||
|
* isContainerOf exclut les liens transversaux) :
|
||||||
|
* <ul>
|
||||||
|
* <li>jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
|
||||||
|
* aucune perte de contenu ;</li>
|
||||||
|
* <li>conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
|
||||||
|
* quête partie il est invisible partout dans l'appli et pourrirait en fantôme
|
||||||
|
* (réapparitions dans les exports). L'impact est annoncé au préalable par
|
||||||
|
* {@link #getDeletionImpact} (dialogue de confirmation côté front) ;</li>
|
||||||
|
* <li>conteneur encore référencé par une autre quête : jamais touché.</li>
|
||||||
|
* </ul></p>
|
||||||
|
*
|
||||||
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
|
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
|
||||||
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
|
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
|
||||||
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
|
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
|
||||||
@@ -167,28 +184,47 @@ public class QuestService {
|
|||||||
progressionRepository.deleteByQuestId(id);
|
progressionRepository.deleteByQuestId(id);
|
||||||
questRepository.deleteById(id);
|
questRepository.deleteById(id);
|
||||||
if (quest == null) return;
|
if (quest == null) return;
|
||||||
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
|
|
||||||
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
|
|
||||||
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
|
|
||||||
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
|
|
||||||
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
|
|
||||||
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
|
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
|
||||||
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||||
if (node.nodeType() != NodeType.CHAPTER) continue;
|
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||||
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
|
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
|
||||||
boolean container = isContainerOf(quest, ch);
|
boolean container = isContainerOf(quest, ch);
|
||||||
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
|
|
||||||
boolean referencedElsewhere = remaining.stream()
|
boolean referencedElsewhere = remaining.stream()
|
||||||
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||||
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||||
&& ch.getId().equals(n.nodeId())));
|
&& ch.getId().equals(n.nodeId())));
|
||||||
if (container && empty && !referencedElsewhere) {
|
if (!container || referencedElsewhere) return;
|
||||||
chapterRepository.deleteById(ch.getId());
|
var scenes = sceneRepository.findByChapterId(ch.getId());
|
||||||
}
|
if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible
|
||||||
|
for (var scene : scenes) sceneRepository.deleteById(scene.getId());
|
||||||
|
chapterRepository.deleteById(ch.getId());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */
|
||||||
|
public record DeletionImpact(int scenes) {}
|
||||||
|
|
||||||
|
public DeletionImpact getDeletionImpact(String questId) {
|
||||||
|
Quest quest = questRepository.findById(questId).orElse(null);
|
||||||
|
if (quest == null) return new DeletionImpact(0);
|
||||||
|
List<Quest> others = questRepository.findByCampaignId(quest.getCampaignId()).stream()
|
||||||
|
.filter(q -> !questId.equals(q.getId()))
|
||||||
|
.toList();
|
||||||
|
int scenes = 0;
|
||||||
|
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||||
|
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||||
|
Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null);
|
||||||
|
if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue;
|
||||||
|
boolean referencedElsewhere = others.stream()
|
||||||
|
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||||
|
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||||
|
&& ch.getId().equals(n.nodeId())));
|
||||||
|
if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size();
|
||||||
|
}
|
||||||
|
return new DeletionImpact(scenes);
|
||||||
|
}
|
||||||
|
|
||||||
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
|
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
|
||||||
return nodes != null ? nodes : List.of();
|
return nodes != null ? nodes : List.of();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
public record CampaignImportProposal(List<ArcProposal> arcs, List<NpcProposal> npcs) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@code existingId} (nullable) : si présent, le nœud existe DÉJÀ dans la
|
* {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply).
|
||||||
* campagne (rempli côté UI lors de la revue pré-chargée) → l'apply ne le
|
* <p>
|
||||||
* recrée pas, il l'utilise comme parent des nouveaux enfants. Null = à créer.
|
* {@code existingId} (nullable, porté aussi par Chapter/SceneProposal) : si présent,
|
||||||
|
* le nœud existe DÉJÀ dans la campagne (rempli côté UI lors de la revue pré-chargée)
|
||||||
|
* → l'apply ne le recrée pas, il l'utilise comme parent des nouveaux enfants.
|
||||||
|
* Null = à créer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** {@code type} = "LINEAR" ou "HUB" (mappé sur {@link ArcType} à l'apply). */
|
|
||||||
public record ArcProposal(
|
public record ArcProposal(
|
||||||
String name, String description, String type,
|
String name, String description, String type,
|
||||||
List<ChapterProposal> chapters, String existingId) {
|
List<ChapterProposal> chapters, String existingId) {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package com.loremind.domain.campaigncontext.quest;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
* Condition de déblocage d'une quête (Chapter dans un Arc HUB).
|
||||||
*
|
|
||||||
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
* Sealed : la liste des types est CLOSE et connue à la compilation. Pour ajouter
|
||||||
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
* un nouveau type (ex : NpcMet), il faudra l'ajouter ici ET dans
|
||||||
* {@link PrerequisiteEvaluator}.
|
* {@link PrerequisiteEvaluator}.
|
||||||
*
|
|
||||||
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
* Sémantique MVP : une quête a une LISTE de prérequis, tous combinés en ET logique
|
||||||
* (pas de OR pour le moment).
|
* (pas de OR pour le moment).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import java.util.Set;
|
|||||||
/**
|
/**
|
||||||
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
* Service de domaine (pur, sans effet de bord) : évalue les prérequis d'une quête
|
||||||
* et en dérive le {@link QuestStatus} effectif.
|
* et en dérive le {@link QuestStatus} effectif.
|
||||||
*
|
|
||||||
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
* NB Java 17 : on utilise instanceof pattern matching (finalisé en Java 16) plutôt que
|
||||||
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
* switch pattern matching (preview en 17, final en 21). La perte de l'exhaustivité
|
||||||
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
* compile-time est compensée par le throw final qui fait crasher tout nouvel
|
||||||
@@ -51,15 +50,12 @@ public final class PrerequisiteEvaluator {
|
|||||||
List<Prerequisite> prerequisites,
|
List<Prerequisite> prerequisites,
|
||||||
EvaluationContext ctx
|
EvaluationContext ctx
|
||||||
) {
|
) {
|
||||||
switch (progression) {
|
return switch (progression) {
|
||||||
case COMPLETED: return QuestStatus.COMPLETED;
|
case COMPLETED -> QuestStatus.COMPLETED;
|
||||||
case IN_PROGRESS: return QuestStatus.IN_PROGRESS;
|
case IN_PROGRESS -> QuestStatus.IN_PROGRESS;
|
||||||
case NOT_STARTED:
|
case NOT_STARTED -> areAllSatisfied(prerequisites, ctx)
|
||||||
return areAllSatisfied(prerequisites, ctx)
|
? QuestStatus.AVAILABLE
|
||||||
? QuestStatus.AVAILABLE
|
: QuestStatus.LOCKED;
|
||||||
: QuestStatus.LOCKED;
|
};
|
||||||
default:
|
|
||||||
throw new IllegalStateException("ProgressionStatus non géré : " + progression);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ package com.loremind.domain.campaigncontext.quest;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
* Statut de progression d'une quête (= Chapter dans un Arc HUB), piloté manuellement par le MJ.
|
||||||
*
|
|
||||||
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
* NOT_STARTED : pas encore commencée. Peut être visible (AVAILABLE) ou cachée (LOCKED)
|
||||||
* selon les prérequis — voir {@link QuestStatus}.
|
* selon les prérequis — voir {@link QuestStatus}.
|
||||||
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
* IN_PROGRESS : démarrée par le MJ via le bouton "Démarrer cette quête".
|
||||||
* COMPLETED : marquée terminée par le MJ.
|
* COMPLETED : marquée terminée par le MJ.
|
||||||
*
|
|
||||||
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
* NB : un Chapter d'Arc LINEAR conserve NOT_STARTED par défaut sans impact visible.
|
||||||
*/
|
*/
|
||||||
public enum ProgressionStatus {
|
public enum ProgressionStatus {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package com.loremind.domain.campaigncontext.quest;
|
|||||||
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
* Statut effectif d'une quête tel qu'affiché dans la vue Hub.
|
||||||
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
* DÉRIVÉ — jamais persisté. Calculé par {@link PrerequisiteEvaluator} à partir
|
||||||
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
* de la {@link ProgressionStatus} persistée et de l'évaluation des prérequis.
|
||||||
*
|
|
||||||
* Table de vérité :
|
* Table de vérité :
|
||||||
* NOT_STARTED + prérequis non remplis -> LOCKED
|
* NOT_STARTED + prérequis non remplis -> LOCKED
|
||||||
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
* NOT_STARTED + prérequis remplis -> AVAILABLE
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ public class GameSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean equalsIgnoreCase(String a, String b) {
|
private static boolean equalsIgnoreCase(String a, String b) {
|
||||||
if (a == null || b == null) return a == b;
|
if (a == null || b == null) return a == null && b == null;
|
||||||
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
|
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,22 +26,25 @@ import java.util.function.Consumer;
|
|||||||
@Component
|
@Component
|
||||||
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
||||||
|
|
||||||
private static final String ADAPT_PATH = "/adapt/campaign/stream";
|
|
||||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||||
new ParameterizedTypeReference<>() {};
|
new ParameterizedTypeReference<>() {};
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final long timeoutSeconds;
|
private final long timeoutSeconds;
|
||||||
|
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
|
||||||
|
private final String adaptPath;
|
||||||
|
|
||||||
public BrainCampaignAdaptClient(
|
public BrainCampaignAdaptClient(
|
||||||
WebClient.Builder webClientBuilder,
|
WebClient.Builder webClientBuilder,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${brain.base-url}") String baseUrl,
|
@Value("${brain.base-url}") String baseUrl,
|
||||||
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
|
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds,
|
||||||
|
@Value("${brain.paths.adapt-campaign:/adapt/campaign/stream}") String adaptPath) {
|
||||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.timeoutSeconds = timeoutSeconds;
|
this.timeoutSeconds = timeoutSeconds;
|
||||||
|
this.adaptPath = adaptPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -65,7 +68,7 @@ public class BrainCampaignAdaptClient implements CampaignPdfAdvisor {
|
|||||||
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
|
parts.part("messages", (messagesJson == null || messagesJson.isBlank()) ? "[]" : messagesJson);
|
||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(ADAPT_PATH)
|
.uri(adaptPath)
|
||||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
|
|||||||
@@ -36,24 +36,37 @@ import java.util.function.Consumer;
|
|||||||
@Component
|
@Component
|
||||||
public class BrainCampaignImportClient implements CampaignPdfImporter {
|
public class BrainCampaignImportClient implements CampaignPdfImporter {
|
||||||
|
|
||||||
private static final String IMPORT_CAMPAIGN_STREAM_PATH = "/import/campaign/stream";
|
|
||||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||||
new ParameterizedTypeReference<>() {};
|
new ParameterizedTypeReference<>() {};
|
||||||
|
/** Champ JSON répété du proposal d'arbre (arc/chapitre/scène/salle/PNJ). */
|
||||||
|
private static final String FIELD_DESCRIPTION = "description";
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final BrainSseImportSupport sse;
|
private final BrainSseImportSupport sse;
|
||||||
private final long importTimeoutSeconds;
|
private final long importTimeoutSeconds;
|
||||||
|
// Route du Brain surchargeable par config (défaut = contrat d'API actuel).
|
||||||
|
private final String importCampaignStreamPath;
|
||||||
|
|
||||||
public BrainCampaignImportClient(
|
public BrainCampaignImportClient(
|
||||||
WebClient.Builder webClientBuilder,
|
WebClient.Builder webClientBuilder,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${brain.base-url}") String baseUrl,
|
@Value("${brain.base-url}") String baseUrl,
|
||||||
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
|
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
|
||||||
|
@Value("${brain.paths.import-campaign:/import/campaign/stream}") String importCampaignStreamPath) {
|
||||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||||
this.sse = new BrainSseImportSupport(objectMapper);
|
this.sse = new BrainSseImportSupport(objectMapper);
|
||||||
this.importTimeoutSeconds = importTimeoutSeconds;
|
this.importTimeoutSeconds = importTimeoutSeconds;
|
||||||
|
this.importCampaignStreamPath = importCampaignStreamPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */
|
||||||
|
private record ImportCallbacks(
|
||||||
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
|
Consumer<CampaignImportProposal> onDone,
|
||||||
|
Consumer<Throwable> onError) {}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void importCampaignStreaming(
|
public void importCampaignStreaming(
|
||||||
byte[] pdfBytes,
|
byte[] pdfBytes,
|
||||||
@@ -69,7 +82,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
|
.filename(filename == null || filename.isBlank() ? "campaign.pdf" : filename);
|
||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(IMPORT_CAMPAIGN_STREAM_PATH)
|
.uri(importCampaignStreamPath)
|
||||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
@@ -80,12 +93,11 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
int[] pageCount = {0};
|
int[] pageCount = {0};
|
||||||
int[] ocrPageCount = {0};
|
int[] ocrPageCount = {0};
|
||||||
boolean[] terminated = {false};
|
boolean[] terminated = {false};
|
||||||
|
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
|
|
||||||
sse.runStream(
|
sse.runStream(
|
||||||
flux, importTimeoutSeconds, terminated,
|
flux, importTimeoutSeconds, terminated,
|
||||||
event -> handleEvent(
|
event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
|
||||||
event, pageCount, ocrPageCount, terminated,
|
|
||||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
|
||||||
onError, CampaignImportException::new);
|
onError, CampaignImportException::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +106,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
int[] pageCount,
|
int[] pageCount,
|
||||||
int[] ocrPageCount,
|
int[] ocrPageCount,
|
||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
ImportCallbacks callbacks) {
|
||||||
Runnable onHeartbeat,
|
|
||||||
Consumer<String> onStatus,
|
|
||||||
Consumer<CampaignImportProposal> onDone,
|
|
||||||
Consumer<Throwable> onError) {
|
|
||||||
|
|
||||||
String event = ssEvent.event();
|
String event = ssEvent.event();
|
||||||
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
||||||
@@ -106,27 +114,27 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
if ("heartbeat".equals(event)) {
|
if ("heartbeat".equals(event)) {
|
||||||
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||||
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
|
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front.
|
||||||
onHeartbeat.run();
|
callbacks.onHeartbeat().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("status".equals(event)) {
|
if ("status".equals(event)) {
|
||||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||||
onStatus.accept(sse.readMessage(data));
|
callbacks.onStatus().accept(sse.readMessage(data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("chunk_failed".equals(event)) {
|
if ("chunk_failed".equals(event)) {
|
||||||
onStatus.accept(sse.chunkFailedStatus(data));
|
callbacks.onStatus().accept(sse.chunkFailedStatus(data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("error".equals(event)) {
|
if ("error".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onError.accept(new CampaignImportException(
|
callbacks.onError().accept(new CampaignImportException(
|
||||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("extracting".equals(event)) {
|
if ("extracting".equals(event)) {
|
||||||
onProgress.accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
|
callbacks.onProgress().accept(new CampaignImportProgress(0, 0, 0, 0, 0, 0, 0, 0));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +144,10 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
if ("start".equals(event)) {
|
if ("start".equals(event)) {
|
||||||
pageCount[0] = node.path("page_count").asInt();
|
pageCount[0] = node.path("page_count").asInt();
|
||||||
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
||||||
onProgress.accept(new CampaignImportProgress(
|
callbacks.onProgress().accept(new CampaignImportProgress(
|
||||||
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
|
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], 0, 0, 0, 0));
|
||||||
} else if ("progress".equals(event)) {
|
} else if ("progress".equals(event)) {
|
||||||
onProgress.accept(new CampaignImportProgress(
|
callbacks.onProgress().accept(new CampaignImportProgress(
|
||||||
node.path("current").asInt(),
|
node.path("current").asInt(),
|
||||||
node.path("total").asInt(),
|
node.path("total").asInt(),
|
||||||
pageCount[0],
|
pageCount[0],
|
||||||
@@ -150,7 +158,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
node.path("npc_count").asInt()));
|
node.path("npc_count").asInt()));
|
||||||
} else if ("done".equals(event)) {
|
} else if ("done".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onDone.accept(new CampaignImportProposal(
|
callbacks.onDone().accept(new CampaignImportProposal(
|
||||||
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
|
toArcs(node.path("arcs")), toNpcs(node.path("npcs"))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +171,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
for (JsonNode arc : arcsNode) {
|
for (JsonNode arc : arcsNode) {
|
||||||
arcs.add(new ArcProposal(
|
arcs.add(new ArcProposal(
|
||||||
text(arc, "name"),
|
text(arc, "name"),
|
||||||
text(arc, "description"),
|
text(arc, FIELD_DESCRIPTION),
|
||||||
text(arc, "type"),
|
text(arc, "type"),
|
||||||
toChapters(arc.path("chapters")),
|
toChapters(arc.path("chapters")),
|
||||||
null));
|
null));
|
||||||
@@ -178,7 +186,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
for (JsonNode ch : chaptersNode) {
|
for (JsonNode ch : chaptersNode) {
|
||||||
chapters.add(new ChapterProposal(
|
chapters.add(new ChapterProposal(
|
||||||
text(ch, "name"),
|
text(ch, "name"),
|
||||||
text(ch, "description"),
|
text(ch, FIELD_DESCRIPTION),
|
||||||
toScenes(ch.path("scenes")),
|
toScenes(ch.path("scenes")),
|
||||||
null));
|
null));
|
||||||
}
|
}
|
||||||
@@ -191,7 +199,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
if (scenesNode != null && scenesNode.isArray()) {
|
if (scenesNode != null && scenesNode.isArray()) {
|
||||||
for (JsonNode sc : scenesNode) {
|
for (JsonNode sc : scenesNode) {
|
||||||
scenes.add(new SceneProposal(
|
scenes.add(new SceneProposal(
|
||||||
text(sc, "name"), text(sc, "description"),
|
text(sc, "name"), text(sc, FIELD_DESCRIPTION),
|
||||||
text(sc, "player_narration"), text(sc, "gm_notes"),
|
text(sc, "player_narration"), text(sc, "gm_notes"),
|
||||||
toRooms(sc.path("rooms")), null));
|
toRooms(sc.path("rooms")), null));
|
||||||
}
|
}
|
||||||
@@ -204,7 +212,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
if (roomsNode != null && roomsNode.isArray()) {
|
if (roomsNode != null && roomsNode.isArray()) {
|
||||||
for (JsonNode rm : roomsNode) {
|
for (JsonNode rm : roomsNode) {
|
||||||
rooms.add(new RoomProposal(
|
rooms.add(new RoomProposal(
|
||||||
text(rm, "name"), text(rm, "description"),
|
text(rm, "name"), text(rm, FIELD_DESCRIPTION),
|
||||||
text(rm, "enemies"), text(rm, "loot")));
|
text(rm, "enemies"), text(rm, "loot")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,7 +223,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
List<NpcProposal> npcs = new ArrayList<>();
|
List<NpcProposal> npcs = new ArrayList<>();
|
||||||
if (npcsNode != null && npcsNode.isArray()) {
|
if (npcsNode != null && npcsNode.isArray()) {
|
||||||
for (JsonNode n : npcsNode) {
|
for (JsonNode n : npcsNode) {
|
||||||
npcs.add(new NpcProposal(text(n, "name"), text(n, "description")));
|
npcs.add(new NpcProposal(text(n, "name"), text(n, FIELD_DESCRIPTION)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return npcs;
|
return npcs;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.BiConsumer;
|
import java.util.function.BiConsumer;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.function.ToIntFunction;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
|
* Helper d'infrastructure : traduit un ChatRequest (domaine) vers le dict JSON
|
||||||
@@ -40,11 +40,14 @@ import java.util.stream.Collectors;
|
|||||||
@Component
|
@Component
|
||||||
public class BrainChatPayloadBuilder {
|
public class BrainChatPayloadBuilder {
|
||||||
|
|
||||||
|
private static final String KEY_DESCRIPTION = "description";
|
||||||
|
private static final String KEY_TITLE = "title";
|
||||||
|
|
||||||
public Map<String, Object> build(ChatRequest request) {
|
public Map<String, Object> build(ChatRequest request) {
|
||||||
Map<String, Object> root = new LinkedHashMap<>();
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
root.put("messages", request.messages().stream()
|
root.put("messages", request.messages().stream()
|
||||||
.map(this::messageToMap)
|
.map(this::messageToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
|
|
||||||
if (request.loreContext() != null) {
|
if (request.loreContext() != null) {
|
||||||
root.put("lore_context", loreContextToMap(request.loreContext()));
|
root.put("lore_context", loreContextToMap(request.loreContext()));
|
||||||
@@ -75,25 +78,25 @@ public class BrainChatPayloadBuilder {
|
|||||||
map.put("started_at", sc.startedAt().toString());
|
map.put("started_at", sc.startedAt().toString());
|
||||||
}
|
}
|
||||||
map.put("entries", sc.entries() != null
|
map.put("entries", sc.entries() != null
|
||||||
? sc.entries().stream().map(this::journalEntryToMap).collect(Collectors.toList())
|
? sc.entries().stream().map(this::journalEntryToMap).toList()
|
||||||
: List.of());
|
: List.of());
|
||||||
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
|
// Évènements des sessions précédentes : omis si vide (campagne sur sa 1re session).
|
||||||
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
|
if (sc.previousEvents() != null && !sc.previousEvents().isEmpty()) {
|
||||||
map.put("previous_events", sc.previousEvents().stream()
|
map.put("previous_events", sc.previousEvents().stream()
|
||||||
.map(this::journalEntryToMap)
|
.map(this::journalEntryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
|
// État Hub (quêtes / flags). Toutes les listes sont omises si vides pour ne pas
|
||||||
// saturer le prompt sur les campagnes sans Hub.
|
// saturer le prompt sur les campagnes sans Hub.
|
||||||
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
|
if (sc.availableQuests() != null && !sc.availableQuests().isEmpty()) {
|
||||||
map.put("available_quests", sc.availableQuests().stream()
|
map.put("available_quests", sc.availableQuests().stream()
|
||||||
.map(this::questSummaryToMap)
|
.map(this::questSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
|
if (sc.inProgressQuests() != null && !sc.inProgressQuests().isEmpty()) {
|
||||||
map.put("in_progress_quests", sc.inProgressQuests().stream()
|
map.put("in_progress_quests", sc.inProgressQuests().stream()
|
||||||
.map(this::questSummaryToMap)
|
.map(this::questSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
|
if (sc.lockedQuestTitles() != null && !sc.lockedQuestTitles().isEmpty()) {
|
||||||
map.put("locked_quest_titles", sc.lockedQuestTitles());
|
map.put("locked_quest_titles", sc.lockedQuestTitles());
|
||||||
@@ -108,7 +111,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("name", q.name());
|
map.put("name", q.name());
|
||||||
map.put("arc_name", q.arcName());
|
map.put("arc_name", q.arcName());
|
||||||
putIfText(map, "description", q.description());
|
putIfText(map, KEY_DESCRIPTION, q.description());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
for (Map.Entry<String, List<PageSummary>> e : ctx.folders().entrySet()) {
|
||||||
foldersMap.put(e.getKey(), e.getValue().stream()
|
foldersMap.put(e.getKey(), e.getValue().stream()
|
||||||
.map(this::pageSummaryToMap)
|
.map(this::pageSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
map.put("folders", foldersMap);
|
map.put("folders", foldersMap);
|
||||||
map.put("tags", ctx.tags());
|
map.put("tags", ctx.tags());
|
||||||
@@ -156,7 +159,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
private Map<String, Object> pageSummaryToMap(PageSummary ps) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", ps.title());
|
map.put(KEY_TITLE, ps.title());
|
||||||
map.put("template_name", ps.templateName());
|
map.put("template_name", ps.templateName());
|
||||||
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
// values/tags/related_page_titles : omis si vides pour alléger le payload.
|
||||||
if (ps.values() != null && !ps.values().isEmpty()) {
|
if (ps.values() != null && !ps.values().isEmpty()) {
|
||||||
@@ -173,7 +176,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
|
|
||||||
private Map<String, Object> pageContextToMap(PageContext pc) {
|
private Map<String, Object> pageContextToMap(PageContext pc) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("title", pc.title());
|
map.put(KEY_TITLE, pc.title());
|
||||||
map.put("template_name", pc.templateName());
|
map.put("template_name", pc.templateName());
|
||||||
map.put("template_fields", pc.templateFields());
|
map.put("template_fields", pc.templateFields());
|
||||||
map.put("values", pc.values());
|
map.put("values", pc.values());
|
||||||
@@ -186,18 +189,18 @@ public class BrainChatPayloadBuilder {
|
|||||||
map.put("campaign_description", ctx.campaignDescription());
|
map.put("campaign_description", ctx.campaignDescription());
|
||||||
map.put("arcs", ctx.arcs().stream()
|
map.put("arcs", ctx.arcs().stream()
|
||||||
.map(this::arcSummaryToMap)
|
.map(this::arcSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
// Liste des PJ : omise si aucun pour alléger le prompt des campagnes sans fiches.
|
||||||
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
if (ctx.characters() != null && !ctx.characters().isEmpty()) {
|
||||||
map.put("characters", ctx.characters().stream()
|
map.put("characters", ctx.characters().stream()
|
||||||
.map(this::characterSummaryToMap)
|
.map(this::characterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
// Liste des PNJ : symétrique aux PJ, omise si vide pour alléger le payload.
|
||||||
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
if (ctx.npcs() != null && !ctx.npcs().isEmpty()) {
|
||||||
map.put("npcs", ctx.npcs().stream()
|
map.put("npcs", ctx.npcs().stream()
|
||||||
.map(this::npcSummaryToMap)
|
.map(this::npcSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -224,13 +227,14 @@ public class BrainChatPayloadBuilder {
|
|||||||
T entity,
|
T entity,
|
||||||
Function<T, String> nameExtractor,
|
Function<T, String> nameExtractor,
|
||||||
Function<T, String> descriptionExtractor,
|
Function<T, String> descriptionExtractor,
|
||||||
Function<T, Integer> illustrationCountExtractor,
|
ToIntFunction<T> illustrationCountExtractor,
|
||||||
BiConsumer<Map<String, Object>, T> childSerializer) {
|
BiConsumer<Map<String, Object>, T> childSerializer) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("name", nameExtractor.apply(entity));
|
map.put("name", nameExtractor.apply(entity));
|
||||||
map.put("description", descriptionExtractor.apply(entity));
|
map.put(KEY_DESCRIPTION, descriptionExtractor.apply(entity));
|
||||||
if (illustrationCountExtractor.apply(entity) > 0) {
|
int illustrationCount = illustrationCountExtractor.applyAsInt(entity);
|
||||||
map.put("illustration_count", illustrationCountExtractor.apply(entity));
|
if (illustrationCount > 0) {
|
||||||
|
map.put("illustration_count", illustrationCount);
|
||||||
}
|
}
|
||||||
childSerializer.accept(map, entity);
|
childSerializer.accept(map, entity);
|
||||||
return map;
|
return map;
|
||||||
@@ -249,7 +253,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
}
|
}
|
||||||
map.put("chapters", arc.chapters().stream()
|
map.put("chapters", arc.chapters().stream()
|
||||||
.map(this::chapterSummaryToMap)
|
.map(this::chapterSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
ChapterSummary::illustrationCount,
|
ChapterSummary::illustrationCount,
|
||||||
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
(map, chapter) -> map.put("scenes", chapter.scenes().stream()
|
||||||
.map(this::sceneSummaryToMap)
|
.map(this::sceneSummaryToMap)
|
||||||
.collect(Collectors.toList())));
|
.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
private Map<String, Object> sceneSummaryToMap(SceneSummary s) {
|
||||||
@@ -275,13 +279,13 @@ public class BrainChatPayloadBuilder {
|
|||||||
if (s.branches() != null && !s.branches().isEmpty()) {
|
if (s.branches() != null && !s.branches().isEmpty()) {
|
||||||
map.put("branches", s.branches().stream()
|
map.put("branches", s.branches().stream()
|
||||||
.map(this::branchHintToMap)
|
.map(this::branchHintToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
// Pièces du lieu explorable : omises si scène classique.
|
// Pièces du lieu explorable : omises si scène classique.
|
||||||
if (s.rooms() != null && !s.rooms().isEmpty()) {
|
if (s.rooms() != null && !s.rooms().isEmpty()) {
|
||||||
map.put("rooms", s.rooms().stream()
|
map.put("rooms", s.rooms().stream()
|
||||||
.map(this::roomSummaryToMap)
|
.map(this::roomSummaryToMap)
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -298,7 +302,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("name", r.name());
|
map.put("name", r.name());
|
||||||
if (r.floor() != null) map.put("floor", r.floor());
|
if (r.floor() != null) map.put("floor", r.floor());
|
||||||
putIfText(map, "description", r.description());
|
putIfText(map, KEY_DESCRIPTION, r.description());
|
||||||
putIfText(map, "enemies", r.enemies());
|
putIfText(map, "enemies", r.enemies());
|
||||||
if (r.branches() != null && !r.branches().isEmpty()) {
|
if (r.branches() != null && !r.branches().isEmpty()) {
|
||||||
map.put("branches", r.branches().stream().map(b -> {
|
map.put("branches", r.branches().stream().map(b -> {
|
||||||
@@ -307,7 +311,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
bm.put("target_room_name", b.targetRoomName());
|
bm.put("target_room_name", b.targetRoomName());
|
||||||
putIfText(bm, "condition", b.condition());
|
putIfText(bm, "condition", b.condition());
|
||||||
return bm;
|
return bm;
|
||||||
}).collect(Collectors.toList()));
|
}).toList());
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -315,7 +319,7 @@ public class BrainChatPayloadBuilder {
|
|||||||
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
private Map<String, Object> narrativeEntityToMap(NarrativeEntityContext ne) {
|
||||||
Map<String, Object> map = new LinkedHashMap<>();
|
Map<String, Object> map = new LinkedHashMap<>();
|
||||||
map.put("entity_type", ne.entityType());
|
map.put("entity_type", ne.entityType());
|
||||||
map.put("title", ne.title());
|
map.put(KEY_TITLE, ne.title());
|
||||||
map.put("fields", ne.fields());
|
map.put("fields", ne.fields());
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import java.time.Duration;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
|
* Adaptateur : appelle le Brain POST /summarize/conversation-title pour
|
||||||
@@ -45,7 +44,7 @@ public class BrainConversationTitleClient implements ConversationTitleGenerator
|
|||||||
.map(m -> Map.<String, Object>of(
|
.map(m -> Map.<String, Object>of(
|
||||||
"role", m.getRole(),
|
"role", m.getRole(),
|
||||||
"content", m.getContent() == null ? "" : m.getContent()))
|
"content", m.getContent() == null ? "" : m.getContent()))
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import java.util.Map;
|
|||||||
public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
||||||
|
|
||||||
private static final String GENERATE_PATH = "/generate/item-catalog";
|
private static final String GENERATE_PATH = "/generate/item-catalog";
|
||||||
|
private static final String KEY_DESCRIPTION = "description";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
@@ -36,10 +37,25 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public GeneratedCatalog generate(String description, String context) {
|
public GeneratedCatalog generate(String description, String context) {
|
||||||
|
Map<String, Object> resp = callBrain(description, context);
|
||||||
|
|
||||||
|
List<CatalogItem> items = parseItems(resp.get("items"));
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
|
||||||
|
}
|
||||||
|
String name = asString(resp.get("name"));
|
||||||
|
return new GeneratedCatalog(
|
||||||
|
name != null && !name.isBlank() ? name : description,
|
||||||
|
asString(resp.get(KEY_DESCRIPTION)),
|
||||||
|
items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST one-shot vers le Brain ; toute erreur devient une ItemCatalogGenerationException parlante. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> callBrain(String description, String context) {
|
||||||
Map<String, Object> req = new LinkedHashMap<>();
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
req.put("description", description == null ? "" : description);
|
req.put(KEY_DESCRIPTION, description == null ? "" : description);
|
||||||
req.put("context", context == null ? "" : context);
|
req.put("context", context == null ? "" : context);
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
@@ -60,30 +76,38 @@ public class BrainItemCatalogClient implements ItemCatalogGenerator {
|
|||||||
if (resp == null) {
|
if (resp == null) {
|
||||||
throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide.");
|
throw new ItemCatalogGenerationException("Le Brain a renvoyé une réponse vide.");
|
||||||
}
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items valides de la réponse (entrées malformées ou sans nom ignorées). */
|
||||||
|
private static List<CatalogItem> parseItems(Object rawItems) {
|
||||||
List<CatalogItem> items = new ArrayList<>();
|
List<CatalogItem> items = new ArrayList<>();
|
||||||
Object rawItems = resp.get("items");
|
|
||||||
if (rawItems instanceof List<?> list) {
|
if (rawItems instanceof List<?> list) {
|
||||||
for (Object item : list) {
|
for (Object raw : list) {
|
||||||
if (!(item instanceof Map<?, ?> m)) continue;
|
CatalogItem item = toItem(raw);
|
||||||
String name = asString(m.get("name"));
|
if (item != null) {
|
||||||
if (name == null || name.isBlank()) continue;
|
items.add(item);
|
||||||
items.add(CatalogItem.builder()
|
}
|
||||||
.name(name)
|
|
||||||
.price(asString(m.get("price")))
|
|
||||||
.category(asString(m.get("category")))
|
|
||||||
.description(asString(m.get("description")))
|
|
||||||
.build());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (items.isEmpty()) {
|
return items;
|
||||||
throw new ItemCatalogGenerationException("Aucun objet généré — réessaie ou reformule.");
|
}
|
||||||
|
|
||||||
|
/** Un CatalogItem depuis une entrée brute, ou null si malformée / sans nom. */
|
||||||
|
private static CatalogItem toItem(Object raw) {
|
||||||
|
if (!(raw instanceof Map<?, ?> m)) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
String name = asString(resp.get("name"));
|
String name = asString(m.get("name"));
|
||||||
return new GeneratedCatalog(
|
if (name == null || name.isBlank()) {
|
||||||
name != null && !name.isBlank() ? name : description,
|
return null;
|
||||||
asString(resp.get("description")),
|
}
|
||||||
items);
|
return CatalogItem.builder()
|
||||||
|
.name(name)
|
||||||
|
.price(asString(m.get("price")))
|
||||||
|
.category(asString(m.get("category")))
|
||||||
|
.description(asString(m.get(KEY_DESCRIPTION)))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String asString(Object o) {
|
private static String asString(Object o) {
|
||||||
|
|||||||
@@ -39,20 +39,9 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
|
public List<ProposedField> assist(String entityType, String context, String instruction, List<FieldSpec> fields) {
|
||||||
List<Map<String, String>> fieldPayload = new ArrayList<>();
|
List<Map<String, String>> fieldPayload = buildFieldPayload(fields);
|
||||||
Set<String> allowed = new HashSet<>();
|
Set<String> allowed = allowedKeys(fields);
|
||||||
if (fields != null) {
|
|
||||||
for (FieldSpec f : fields) {
|
|
||||||
if (f == null || f.key() == null) continue;
|
|
||||||
allowed.add(f.key());
|
|
||||||
Map<String, String> fm = new LinkedHashMap<>();
|
|
||||||
fm.put("key", f.key());
|
|
||||||
fm.put("label", f.label() == null ? f.key() : f.label());
|
|
||||||
fieldPayload.add(fm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> req = new LinkedHashMap<>();
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
req.put("entity_type", entityType == null ? "" : entityType);
|
req.put("entity_type", entityType == null ? "" : entityType);
|
||||||
@@ -60,6 +49,38 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
|||||||
req.put("instruction", instruction == null ? "" : instruction);
|
req.put("instruction", instruction == null ? "" : instruction);
|
||||||
req.put("fields", fieldPayload);
|
req.put("fields", fieldPayload);
|
||||||
|
|
||||||
|
Map<String, Object> resp = callBrain(req);
|
||||||
|
return parseProposedFields(resp.get("fields"), allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload des champs à étoffer : {key, label} par champ valide (libellé = clé à défaut). */
|
||||||
|
private static List<Map<String, String>> buildFieldPayload(List<FieldSpec> fields) {
|
||||||
|
List<Map<String, String>> payload = new ArrayList<>();
|
||||||
|
for (FieldSpec f : nullSafe(fields)) {
|
||||||
|
if (f != null && f.key() != null) {
|
||||||
|
Map<String, String> fm = new LinkedHashMap<>();
|
||||||
|
fm.put("key", f.key());
|
||||||
|
fm.put("label", f.label() == null ? f.key() : f.label());
|
||||||
|
payload.add(fm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitelist des clés autorisées en retour (garde-fou contre les clés inventées). */
|
||||||
|
private static Set<String> allowedKeys(List<FieldSpec> fields) {
|
||||||
|
Set<String> allowed = new HashSet<>();
|
||||||
|
for (FieldSpec f : nullSafe(fields)) {
|
||||||
|
if (f != null && f.key() != null) {
|
||||||
|
allowed.add(f.key());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST one-shot vers le Brain ; toute erreur devient une NarrativeAssistException parlante. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> callBrain(Map<String, Object> req) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||||
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
|
HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers);
|
||||||
@@ -78,18 +99,37 @@ public class BrainNarrativeFieldClient implements NarrativeFieldAssistant {
|
|||||||
if (resp == null) {
|
if (resp == null) {
|
||||||
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
|
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
|
||||||
}
|
}
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs proposés retenus : clé dans la whitelist ET valeur non vide (trim). */
|
||||||
|
private static List<ProposedField> parseProposedFields(Object rawFields, Set<String> allowed) {
|
||||||
List<ProposedField> out = new ArrayList<>();
|
List<ProposedField> out = new ArrayList<>();
|
||||||
Object rawFields = resp.get("fields");
|
|
||||||
if (rawFields instanceof Map<?, ?> m) {
|
if (rawFields instanceof Map<?, ?> m) {
|
||||||
for (Map.Entry<?, ?> e : m.entrySet()) {
|
for (Map.Entry<?, ?> e : m.entrySet()) {
|
||||||
String key = e.getKey() == null ? null : e.getKey().toString();
|
ProposedField field = toProposedField(e, allowed);
|
||||||
if (key == null || !allowed.contains(key)) continue; // whitelist stricte
|
if (field != null) {
|
||||||
String value = e.getValue() == null ? null : e.getValue().toString();
|
out.add(field);
|
||||||
if (value == null || value.isBlank()) continue; // pas de remplissage vide
|
}
|
||||||
out.add(new ProposedField(key, value.trim()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Un ProposedField depuis une entrée brute, ou null si clé hors whitelist / valeur vide. */
|
||||||
|
private static ProposedField toProposedField(Map.Entry<?, ?> e, Set<String> allowed) {
|
||||||
|
String key = e.getKey() == null ? null : e.getKey().toString();
|
||||||
|
if (key == null || !allowed.contains(key)) {
|
||||||
|
return null; // whitelist stricte
|
||||||
|
}
|
||||||
|
String value = e.getValue() == null ? null : e.getValue().toString();
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
return null; // pas de remplissage vide
|
||||||
|
}
|
||||||
|
return new ProposedField(key, value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FieldSpec> nullSafe(List<FieldSpec> fields) {
|
||||||
|
return fields != null ? fields : List.of();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import java.time.Duration;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
|
* Adapter de sortie : relaie le chat ANCRÉ (RAG) du Brain via SSE (WebClient).
|
||||||
@@ -26,23 +25,28 @@ import java.util.stream.Collectors;
|
|||||||
@Component
|
@Component
|
||||||
public class BrainNotebookChatClient implements NotebookChatStreamer {
|
public class BrainNotebookChatClient implements NotebookChatStreamer {
|
||||||
|
|
||||||
private static final String PATH = "/chat/notebook/stream";
|
|
||||||
private static final String DEEP_PATH = "/chat/notebook/deep/stream";
|
|
||||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||||
new ParameterizedTypeReference<>() {};
|
new ParameterizedTypeReference<>() {};
|
||||||
|
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final long timeoutSeconds;
|
private final long timeoutSeconds;
|
||||||
|
// Routes du Brain surchargeables par config (défauts = contrat d'API actuel).
|
||||||
|
private final String chatPath;
|
||||||
|
private final String deepChatPath;
|
||||||
|
|
||||||
public BrainNotebookChatClient(
|
public BrainNotebookChatClient(
|
||||||
WebClient.Builder webClientBuilder,
|
WebClient.Builder webClientBuilder,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${brain.base-url}") String baseUrl,
|
@Value("${brain.base-url}") String baseUrl,
|
||||||
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds) {
|
@Value("${brain.import-timeout-seconds:600}") long timeoutSeconds,
|
||||||
|
@Value("${brain.paths.notebook-chat:/chat/notebook/stream}") String chatPath,
|
||||||
|
@Value("${brain.paths.notebook-chat-deep:/chat/notebook/deep/stream}") String deepChatPath) {
|
||||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.timeoutSeconds = timeoutSeconds;
|
this.timeoutSeconds = timeoutSeconds;
|
||||||
|
this.chatPath = chatPath;
|
||||||
|
this.deepChatPath = deepChatPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -57,11 +61,11 @@ public class BrainNotebookChatClient implements NotebookChatStreamer {
|
|||||||
payload.put("source_ids", sourceIds);
|
payload.put("source_ids", sourceIds);
|
||||||
payload.put("messages", messages.stream()
|
payload.put("messages", messages.stream()
|
||||||
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
|
.map(m -> Map.<String, Object>of("role", m.role(), "content", m.content()))
|
||||||
.collect(Collectors.toList()));
|
.toList());
|
||||||
payload.put("context", context == null ? "" : context);
|
payload.put("context", context == null ? "" : context);
|
||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(deep ? DEEP_PATH : PATH)
|
.uri(deep ? deepChatPath : chatPath)
|
||||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class BrainNotebookIndexClient implements NotebookIndexer {
|
|||||||
if (resp == null) {
|
if (resp == null) {
|
||||||
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
|
throw new NotebookException("Le Brain a renvoyé une réponse vide à l'indexation.");
|
||||||
}
|
}
|
||||||
return new IndexResult(resp.chunks, resp.pageCount, resp.ocrPageCount);
|
return new IndexResult(resp.getChunks(), resp.getPageCount(), resp.getOcrPageCount());
|
||||||
} catch (ResourceAccessException e) {
|
} catch (ResourceAccessException e) {
|
||||||
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
|
throw new NotebookException("Le Brain est injoignable (timeout ou arrêté).", e);
|
||||||
} catch (RestClientResponseException e) {
|
} catch (RestClientResponseException e) {
|
||||||
@@ -89,12 +89,22 @@ public class BrainNotebookIndexClient implements NotebookIndexer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Réponse JSON du Brain (snake_case). */
|
/**
|
||||||
|
* Réponse JSON du Brain (snake_case). Champs privés + @JsonProperty explicite
|
||||||
|
* sur CHAQUE champ : Jackson n'auto-détecte que les champs publics par défaut,
|
||||||
|
* l'annotation reste nécessaire pour que la désérialisation continue de fonctionner
|
||||||
|
* une fois les champs rendus privés.
|
||||||
|
*/
|
||||||
private static class IndexResponse {
|
private static class IndexResponse {
|
||||||
public int chunks;
|
@JsonProperty("chunks")
|
||||||
|
private int chunks;
|
||||||
@JsonProperty("page_count")
|
@JsonProperty("page_count")
|
||||||
public int pageCount;
|
private int pageCount;
|
||||||
@JsonProperty("ocr_page_count")
|
@JsonProperty("ocr_page_count")
|
||||||
public int ocrPageCount;
|
private int ocrPageCount;
|
||||||
|
|
||||||
|
int getChunks() { return chunks; }
|
||||||
|
int getPageCount() { return pageCount; }
|
||||||
|
int getOcrPageCount() { return ocrPageCount; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,23 +49,7 @@ public class BrainRandomTableClient implements RandomTableGenerator {
|
|||||||
if (resp == null) {
|
if (resp == null) {
|
||||||
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
|
throw new RandomTableGenerationException("Le Brain a renvoyé une réponse vide.");
|
||||||
}
|
}
|
||||||
List<RandomTableEntry> entries = new ArrayList<>();
|
List<RandomTableEntry> entries = parseEntries(resp.get("entries"));
|
||||||
Object rawEntries = resp.get("entries");
|
|
||||||
if (rawEntries instanceof List<?> list) {
|
|
||||||
for (Object item : list) {
|
|
||||||
if (!(item instanceof Map<?, ?> m)) continue;
|
|
||||||
Integer min = asInt(m.get("min_roll"));
|
|
||||||
Integer max = asInt(m.get("max_roll"));
|
|
||||||
String label = asString(m.get("label"));
|
|
||||||
if (min == null || max == null || label == null || label.isBlank()) continue;
|
|
||||||
entries.add(RandomTableEntry.builder()
|
|
||||||
.minRoll(min)
|
|
||||||
.maxRoll(Math.max(min, max))
|
|
||||||
.label(label)
|
|
||||||
.detail(asString(m.get("detail")))
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
|
throw new RandomTableGenerationException("Aucune entrée générée — réessaie ou reformule.");
|
||||||
}
|
}
|
||||||
@@ -76,6 +60,39 @@ public class BrainRandomTableClient implements RandomTableGenerator {
|
|||||||
entries);
|
entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Entrées valides de la réponse (entrées malformées ou incomplètes ignorées). */
|
||||||
|
private static List<RandomTableEntry> parseEntries(Object rawEntries) {
|
||||||
|
List<RandomTableEntry> entries = new ArrayList<>();
|
||||||
|
if (rawEntries instanceof List<?> list) {
|
||||||
|
for (Object raw : list) {
|
||||||
|
RandomTableEntry entry = toEntry(raw);
|
||||||
|
if (entry != null) {
|
||||||
|
entries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Une RandomTableEntry depuis une entrée brute, ou null si malformée / bornes ou label absents. */
|
||||||
|
private static RandomTableEntry toEntry(Object raw) {
|
||||||
|
if (!(raw instanceof Map<?, ?> m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Integer min = asInt(m.get("min_roll"));
|
||||||
|
Integer max = asInt(m.get("max_roll"));
|
||||||
|
String label = asString(m.get("label"));
|
||||||
|
if (min == null || max == null || label == null || label.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return RandomTableEntry.builder()
|
||||||
|
.minRoll(min)
|
||||||
|
.maxRoll(Math.max(min, max))
|
||||||
|
.label(label)
|
||||||
|
.detail(asString(m.get("detail")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
|
public String improvise(String tableName, String resultLabel, String resultDetail, String context) {
|
||||||
Map<String, Object> req = new LinkedHashMap<>();
|
Map<String, Object> req = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -47,30 +47,45 @@ import java.util.function.Consumer;
|
|||||||
@Component
|
@Component
|
||||||
public class BrainRulesImportClient implements RulesPdfImporter {
|
public class BrainRulesImportClient implements RulesPdfImporter {
|
||||||
|
|
||||||
private static final String IMPORT_RULES_PATH = "/import/rules";
|
|
||||||
private static final String IMPORT_RULES_STREAM_PATH = "/import/rules/stream";
|
|
||||||
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_STRING_TYPE =
|
||||||
new ParameterizedTypeReference<>() {};
|
new ParameterizedTypeReference<>() {};
|
||||||
|
/** Nom de fichier par défaut du PDF de règles (filename absent/vide). */
|
||||||
|
private static final String DEFAULT_FILENAME = "rules.pdf";
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final BrainSseImportSupport sse;
|
private final BrainSseImportSupport sse;
|
||||||
private final String baseUrl;
|
private final String baseUrl;
|
||||||
private final long importTimeoutSeconds;
|
private final long importTimeoutSeconds;
|
||||||
|
// Routes du Brain surchargeables par config (défauts = contrat d'API actuel).
|
||||||
|
private final String importRulesPath;
|
||||||
|
private final String importRulesStreamPath;
|
||||||
|
|
||||||
public BrainRulesImportClient(
|
public BrainRulesImportClient(
|
||||||
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
|
@Qualifier("brainImportRestTemplate") RestTemplate restTemplate,
|
||||||
WebClient.Builder webClientBuilder,
|
WebClient.Builder webClientBuilder,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
@Value("${brain.base-url}") String baseUrl,
|
@Value("${brain.base-url}") String baseUrl,
|
||||||
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds) {
|
@Value("${brain.import-timeout-seconds:600}") long importTimeoutSeconds,
|
||||||
|
@Value("${brain.paths.import-rules:/import/rules}") String importRulesPath,
|
||||||
|
@Value("${brain.paths.import-rules-stream:/import/rules/stream}") String importRulesStreamPath) {
|
||||||
this.restTemplate = restTemplate;
|
this.restTemplate = restTemplate;
|
||||||
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
this.webClient = webClientBuilder.baseUrl(baseUrl).build();
|
||||||
this.sse = new BrainSseImportSupport(objectMapper);
|
this.sse = new BrainSseImportSupport(objectMapper);
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.importTimeoutSeconds = importTimeoutSeconds;
|
this.importTimeoutSeconds = importTimeoutSeconds;
|
||||||
|
this.importRulesPath = importRulesPath;
|
||||||
|
this.importRulesStreamPath = importRulesStreamPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Callbacks de streaming groupés (réduit le nombre de paramètres de handleEvent). */
|
||||||
|
private record ImportCallbacks(
|
||||||
|
Consumer<RulesImportProgress> onProgress,
|
||||||
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
|
Consumer<RulesImportResult> onDone,
|
||||||
|
Consumer<Throwable> onError) {}
|
||||||
|
|
||||||
// --- One-shot (bloquant) -------------------------------------------------
|
// --- One-shot (bloquant) -------------------------------------------------
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -79,12 +94,12 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||||
body.add("file", sse.filePart(pdfBytes, filename, "rules.pdf"));
|
body.add("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME));
|
||||||
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
BrainRulesImportResponse response = restTemplate.postForObject(
|
BrainRulesImportResponse response = restTemplate.postForObject(
|
||||||
baseUrl + IMPORT_RULES_PATH, entity, BrainRulesImportResponse.class);
|
baseUrl + importRulesPath, entity, BrainRulesImportResponse.class);
|
||||||
if (response == null || response.getSections() == null) {
|
if (response == null || response.getSections() == null) {
|
||||||
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
|
throw new RulesImportException("Le Brain a renvoyé une réponse vide.");
|
||||||
}
|
}
|
||||||
@@ -119,11 +134,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
MultipartBodyBuilder parts = new MultipartBodyBuilder();
|
MultipartBodyBuilder parts = new MultipartBodyBuilder();
|
||||||
parts.part("file", sse.filePart(pdfBytes, filename, "rules.pdf"))
|
parts.part("file", sse.filePart(pdfBytes, filename, DEFAULT_FILENAME))
|
||||||
.filename(filename == null || filename.isBlank() ? "rules.pdf" : filename);
|
.filename(filename == null || filename.isBlank() ? DEFAULT_FILENAME : filename);
|
||||||
|
|
||||||
Flux<ServerSentEvent<String>> flux = webClient.post()
|
Flux<ServerSentEvent<String>> flux = webClient.post()
|
||||||
.uri(IMPORT_RULES_STREAM_PATH)
|
.uri(importRulesStreamPath)
|
||||||
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
.header(UserLanguageHolder.HEADER, UserLanguageHolder.get())
|
||||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||||
@@ -136,12 +151,11 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
int[] pageCount = {0};
|
int[] pageCount = {0};
|
||||||
int[] ocrPageCount = {0};
|
int[] ocrPageCount = {0};
|
||||||
boolean[] terminated = {false};
|
boolean[] terminated = {false};
|
||||||
|
ImportCallbacks callbacks = new ImportCallbacks(onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
|
|
||||||
sse.runStream(
|
sse.runStream(
|
||||||
flux, importTimeoutSeconds, terminated,
|
flux, importTimeoutSeconds, terminated,
|
||||||
event -> handleEvent(
|
event -> handleEvent(event, pageCount, ocrPageCount, terminated, callbacks),
|
||||||
event, pageCount, ocrPageCount, terminated,
|
|
||||||
onProgress, onHeartbeat, onStatus, onDone, onError),
|
|
||||||
onError, RulesImportException::new);
|
onError, RulesImportException::new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,11 +164,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
int[] pageCount,
|
int[] pageCount,
|
||||||
int[] ocrPageCount,
|
int[] ocrPageCount,
|
||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
ImportCallbacks callbacks) {
|
||||||
Runnable onHeartbeat,
|
|
||||||
Consumer<String> onStatus,
|
|
||||||
Consumer<RulesImportResult> onDone,
|
|
||||||
Consumer<Throwable> onError) {
|
|
||||||
|
|
||||||
String event = ssEvent.event();
|
String event = ssEvent.event();
|
||||||
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
String data = ssEvent.data() == null ? "" : ssEvent.data();
|
||||||
@@ -163,28 +173,28 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
// Keep-alive du Brain pendant un appel LLM long : à PROPAGER jusqu'au
|
||||||
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
|
// navigateur, sinon nginx (proxy_read_timeout) coupe le SSE Core→front
|
||||||
// resté silencieux pendant tout le traitement du morceau.
|
// resté silencieux pendant tout le traitement du morceau.
|
||||||
onHeartbeat.run();
|
callbacks.onHeartbeat().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("status".equals(event)) {
|
if ("status".equals(event)) {
|
||||||
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||||
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||||
onStatus.accept(sse.readMessage(data));
|
callbacks.onStatus().accept(sse.readMessage(data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("chunk_failed".equals(event)) {
|
if ("chunk_failed".equals(event)) {
|
||||||
onStatus.accept(sse.chunkFailedStatus(data));
|
callbacks.onStatus().accept(sse.chunkFailedStatus(data));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("error".equals(event)) {
|
if ("error".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onError.accept(new RulesImportException(
|
callbacks.onError().accept(new RulesImportException(
|
||||||
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
"Le Brain a signalé une erreur : " + sse.readMessage(data)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ("extracting".equals(event)) {
|
if ("extracting".equals(event)) {
|
||||||
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
|
// Phase d'extraction : total inconnu (0) → l'UI affiche "Extraction…".
|
||||||
onProgress.accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
|
callbacks.onProgress().accept(new RulesImportProgress(0, 0, 0, 0, List.of()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,10 +204,10 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
if ("start".equals(event)) {
|
if ("start".equals(event)) {
|
||||||
pageCount[0] = node.path("page_count").asInt();
|
pageCount[0] = node.path("page_count").asInt();
|
||||||
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
ocrPageCount[0] = node.path("ocr_page_count").asInt();
|
||||||
onProgress.accept(new RulesImportProgress(
|
callbacks.onProgress().accept(new RulesImportProgress(
|
||||||
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
|
0, node.path("total").asInt(), pageCount[0], ocrPageCount[0], List.of()));
|
||||||
} else if ("progress".equals(event)) {
|
} else if ("progress".equals(event)) {
|
||||||
onProgress.accept(new RulesImportProgress(
|
callbacks.onProgress().accept(new RulesImportProgress(
|
||||||
node.path("current").asInt(),
|
node.path("current").asInt(),
|
||||||
node.path("total").asInt(),
|
node.path("total").asInt(),
|
||||||
pageCount[0],
|
pageCount[0],
|
||||||
@@ -205,7 +215,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
toStringList(node.path("new_sections"))));
|
toStringList(node.path("new_sections"))));
|
||||||
} else if ("done".equals(event)) {
|
} else if ("done".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onDone.accept(new RulesImportResult(
|
callbacks.onDone().accept(new RulesImportResult(
|
||||||
toStringMap(node.path("sections")),
|
toStringMap(node.path("sections")),
|
||||||
node.path("page_count").asInt(),
|
node.path("page_count").asInt(),
|
||||||
node.path("ocr_page_count").asInt()));
|
node.path("ocr_page_count").asInt()));
|
||||||
|
|||||||
@@ -62,23 +62,38 @@ public class BrainSceneDraftClient implements SceneDraftAssistant {
|
|||||||
if (resp == null) {
|
if (resp == null) {
|
||||||
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
|
throw new NarrativeAssistException("Le Brain a renvoyé une réponse vide.");
|
||||||
}
|
}
|
||||||
|
return parseScenes(resp.get("scenes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ébauches valides de la réponse (entrées malformées ou sans titre ignorées). */
|
||||||
|
private static List<SceneDraft> parseScenes(Object rawScenes) {
|
||||||
List<SceneDraft> out = new ArrayList<>();
|
List<SceneDraft> out = new ArrayList<>();
|
||||||
Object rawScenes = resp.get("scenes");
|
|
||||||
if (rawScenes instanceof List<?> list) {
|
if (rawScenes instanceof List<?> list) {
|
||||||
for (Object item : list) {
|
for (Object raw : list) {
|
||||||
if (!(item instanceof Map<?, ?> m)) continue;
|
SceneDraft draft = toSceneDraft(raw);
|
||||||
String name = asString(m.get("name"));
|
if (draft != null) {
|
||||||
if (name == null || name.isBlank()) continue; // un titre est obligatoire
|
out.add(draft);
|
||||||
out.add(new SceneDraft(
|
}
|
||||||
name.trim(),
|
|
||||||
trimOrNull(asString(m.get("description"))),
|
|
||||||
trimOrNull(asString(m.get("playerNarration")))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Une SceneDraft depuis une entrée brute, ou null si malformée / sans titre. */
|
||||||
|
private static SceneDraft toSceneDraft(Object raw) {
|
||||||
|
if (!(raw instanceof Map<?, ?> m)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String name = asString(m.get("name"));
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return null; // un titre est obligatoire
|
||||||
|
}
|
||||||
|
return new SceneDraft(
|
||||||
|
name.trim(),
|
||||||
|
trimOrNull(asString(m.get("description"))),
|
||||||
|
trimOrNull(asString(m.get("playerNarration"))));
|
||||||
|
}
|
||||||
|
|
||||||
private static String asString(Object o) {
|
private static String asString(Object o) {
|
||||||
return o != null ? o.toString() : null;
|
return o != null ? o.toString() : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
@@ -41,7 +42,7 @@ public class BrainSidecar {
|
|||||||
private final BrainSidecarProperties props;
|
private final BrainSidecarProperties props;
|
||||||
private final String internalSecret;
|
private final String internalSecret;
|
||||||
|
|
||||||
private volatile Process process;
|
private final AtomicReference<Process> process = new AtomicReference<>();
|
||||||
|
|
||||||
public BrainSidecar(BrainSidecarProperties props,
|
public BrainSidecar(BrainSidecarProperties props,
|
||||||
@Value("${brain.internal-secret:}") String internalSecret) {
|
@Value("${brain.internal-secret:}") String internalSecret) {
|
||||||
@@ -75,9 +76,10 @@ public class BrainSidecar {
|
|||||||
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
|
// (cf. Settings.internal_shared_secret cote Python -> env INTERNAL_SHARED_SECRET)
|
||||||
pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret);
|
pb.environment().put("INTERNAL_SHARED_SECRET", internalSecret);
|
||||||
|
|
||||||
this.process = pb.start();
|
Process started = pb.start();
|
||||||
|
this.process.set(started);
|
||||||
log.info("[Brain] Sidecar demarre (pid={}, cwd={}).",
|
log.info("[Brain] Sidecar demarre (pid={}, cwd={}).",
|
||||||
process.pid(), workingDir != null ? workingDir : "<heritee>");
|
started.pid(), workingDir != null ? workingDir : "<heritee>");
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.error("[Brain] Echec du lancement du sidecar (commande={}). "
|
log.error("[Brain] Echec du lancement du sidecar (commande={}). "
|
||||||
+ "Les fonctions IA seront indisponibles. Cause : {}",
|
+ "Les fonctions IA seront indisponibles. Cause : {}",
|
||||||
@@ -87,7 +89,7 @@ public class BrainSidecar {
|
|||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
public void stop() {
|
public void stop() {
|
||||||
Process p = this.process;
|
Process p = this.process.get();
|
||||||
if (p == null || !p.isAlive()) {
|
if (p == null || !p.isAlive()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import java.util.function.Consumer;
|
|||||||
*/
|
*/
|
||||||
final class BrainSseImportSupport {
|
final class BrainSseImportSupport {
|
||||||
|
|
||||||
|
/** Champ JSON standard des events SSE du Brain portant un message lisible. */
|
||||||
|
private static final String MESSAGE_FIELD = "message";
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
BrainSseImportSupport(ObjectMapper objectMapper) {
|
BrainSseImportSupport(ObjectMapper objectMapper) {
|
||||||
@@ -50,8 +53,8 @@ final class BrainSseImportSupport {
|
|||||||
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
|
/** Champ {@code message} du JSON, ou la {@code data} brute si non-JSON / champ absent. */
|
||||||
String readMessage(String data) {
|
String readMessage(String data) {
|
||||||
JsonNode node = readJson(data);
|
JsonNode node = readJson(data);
|
||||||
if (node != null && node.hasNonNull("message")) {
|
if (node != null && node.hasNonNull(MESSAGE_FIELD)) {
|
||||||
return node.get("message").asText();
|
return node.get(MESSAGE_FIELD).asText();
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -59,8 +62,8 @@ final class BrainSseImportSupport {
|
|||||||
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
|
/** Statut lisible « Morceau x/y ignoré[ : message] » depuis un payload {@code chunk_failed}. */
|
||||||
String chunkFailedStatus(String data) {
|
String chunkFailedStatus(String data) {
|
||||||
JsonNode node = readJson(data);
|
JsonNode node = readJson(data);
|
||||||
String msg = node != null && node.hasNonNull("message")
|
String msg = node != null && node.hasNonNull(MESSAGE_FIELD)
|
||||||
? node.get("message").asText() : "";
|
? node.get(MESSAGE_FIELD).asText() : "";
|
||||||
int current = node != null ? node.path("current").asInt() : 0;
|
int current = node != null ? node.path("current").asInt() : 0;
|
||||||
int total = node != null ? node.path("total").asInt() : 0;
|
int total = node != null ? node.path("total").asInt() : 0;
|
||||||
return "Morceau " + current + "/" + total + " ignoré"
|
return "Morceau " + current + "/" + total + " ignoré"
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public class DesktopBrowserOpener {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
|
private static final Logger log = LoggerFactory.getLogger(DesktopBrowserOpener.class);
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
// S125 : faux positif — commentaire explicatif (prose), pas de code mort.
|
||||||
|
@SuppressWarnings("java:S125")
|
||||||
public void onReady() {
|
public void onReady() {
|
||||||
// Ferme le splash natif (affiche par la JVM via -splash des le double-clic)
|
// Ferme le splash natif (affiche par la JVM via -splash des le double-clic)
|
||||||
// juste avant d'ouvrir le navigateur : le relais visuel est assure.
|
// juste avant d'ouvrir le navigateur : le relais visuel est assure.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.loremind.infrastructure.desktop;
|
package com.loremind.infrastructure.desktop;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.channels.FileChannel;
|
import java.nio.channels.FileChannel;
|
||||||
import java.nio.channels.FileLock;
|
import java.nio.channels.FileLock;
|
||||||
@@ -26,12 +29,26 @@ import java.nio.file.StandardOpenOption;
|
|||||||
* ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink.
|
* ({@code java.desktop}) pourrait etre absent du runtime reduit par jlink.
|
||||||
* On passe donc par la commande systeme d'ouverture d'URL.
|
* On passe donc par la commande systeme d'ouverture d'URL.
|
||||||
*/
|
*/
|
||||||
|
// S4036 : app desktop locale mono-utilisateur — lancement d'utilitaires systeme via PATH
|
||||||
|
// assume, sans contexte d'elevation ni d'input externe.
|
||||||
|
@SuppressWarnings("java:S4036")
|
||||||
public final class DesktopSingleInstance {
|
public final class DesktopSingleInstance {
|
||||||
|
|
||||||
/** Conserve le verrou ouvert pour TOUTE la duree de vie du process (sinon GC = relache). */
|
// Utilisé avant SpringApplication.run() : Logback démarre en config console par
|
||||||
|
// défaut — même destination que l'ancien System.err (cf. DesktopUserConfig).
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DesktopSingleInstance.class);
|
||||||
|
|
||||||
|
private static final String OS_NAME_PROPERTY = "os.name";
|
||||||
|
/** Ouvre URL/fichier/dossier avec l'application par défaut sur Linux. */
|
||||||
|
private static final String XDG_OPEN = "xdg-open";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conserve le CHANNEL ouvert pour toute la duree de vie du process : un FileLock
|
||||||
|
* reste detenu tant que son channel est ouvert (le verrou est libere a la fermeture
|
||||||
|
* du channel ou a la sortie de la JVM, pas au GC de l'objet FileLock).
|
||||||
|
*/
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static FileChannel lockChannel;
|
private static FileChannel lockChannel;
|
||||||
private static FileLock lock;
|
|
||||||
|
|
||||||
private DesktopSingleInstance() {}
|
private DesktopSingleInstance() {}
|
||||||
|
|
||||||
@@ -71,11 +88,11 @@ public final class DesktopSingleInstance {
|
|||||||
Path lockFile = dir.resolve(".instance.lock");
|
Path lockFile = dir.resolve(".instance.lock");
|
||||||
lockChannel = FileChannel.open(lockFile,
|
lockChannel = FileChannel.open(lockFile,
|
||||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
StandardOpenOption.CREATE, StandardOpenOption.WRITE);
|
||||||
lock = lockChannel.tryLock();
|
FileLock lock = lockChannel.tryLock();
|
||||||
return lock != null; // null = deja verrouille par une autre instance
|
return lock != null; // null = deja verrouille par une autre instance
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Verrou d'instance indisponible (" + e.getMessage()
|
log.warn("[Desktop] Verrou d'instance indisponible ({}) — on tente de demarrer quand meme.",
|
||||||
+ ") — on tente de demarrer quand meme.");
|
e.getMessage());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +105,7 @@ public final class DesktopSingleInstance {
|
|||||||
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
|
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
|
||||||
public static void openUrl(String url) {
|
public static void openUrl(String url) {
|
||||||
try {
|
try {
|
||||||
String os = System.getProperty("os.name", "").toLowerCase();
|
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||||
ProcessBuilder pb;
|
ProcessBuilder pb;
|
||||||
if (os.contains("win")) {
|
if (os.contains("win")) {
|
||||||
// rundll32 : ouverture d'URL fiable sans dependance graphique Java.
|
// rundll32 : ouverture d'URL fiable sans dependance graphique Java.
|
||||||
@@ -96,12 +113,12 @@ public final class DesktopSingleInstance {
|
|||||||
} else if (os.contains("mac")) {
|
} else if (os.contains("mac")) {
|
||||||
pb = new ProcessBuilder("open", url);
|
pb = new ProcessBuilder("open", url);
|
||||||
} else {
|
} else {
|
||||||
pb = new ProcessBuilder("xdg-open", url);
|
pb = new ProcessBuilder(XDG_OPEN, url);
|
||||||
}
|
}
|
||||||
pb.start();
|
pb.start();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Impossible d'ouvrir le navigateur sur " + url
|
log.warn("[Desktop] Impossible d'ouvrir le navigateur sur {} : {}. Ouvrez-le manuellement.",
|
||||||
+ " : " + e.getMessage() + ". Ouvrez-le manuellement.");
|
url, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,25 +126,25 @@ public final class DesktopSingleInstance {
|
|||||||
public static void openFolder(Path dir) {
|
public static void openFolder(Path dir) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(dir);
|
Files.createDirectories(dir);
|
||||||
String os = System.getProperty("os.name", "").toLowerCase();
|
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||||
ProcessBuilder pb;
|
ProcessBuilder pb;
|
||||||
if (os.contains("win")) {
|
if (os.contains("win")) {
|
||||||
pb = new ProcessBuilder("explorer.exe", dir.toString());
|
pb = new ProcessBuilder("explorer.exe", dir.toString());
|
||||||
} else if (os.contains("mac")) {
|
} else if (os.contains("mac")) {
|
||||||
pb = new ProcessBuilder("open", dir.toString());
|
pb = new ProcessBuilder("open", dir.toString());
|
||||||
} else {
|
} else {
|
||||||
pb = new ProcessBuilder("xdg-open", dir.toString());
|
pb = new ProcessBuilder(XDG_OPEN, dir.toString());
|
||||||
}
|
}
|
||||||
pb.start();
|
pb.start();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Ouverture du dossier impossible : " + e.getMessage());
|
log.warn("[Desktop] Ouverture du dossier impossible : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */
|
/** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */
|
||||||
public static void openInEditor(Path file) {
|
public static void openInEditor(Path file) {
|
||||||
try {
|
try {
|
||||||
String os = System.getProperty("os.name", "").toLowerCase();
|
String os = System.getProperty(OS_NAME_PROPERTY, "").toLowerCase();
|
||||||
ProcessBuilder pb;
|
ProcessBuilder pb;
|
||||||
if (os.contains("win")) {
|
if (os.contains("win")) {
|
||||||
// notepad : toujours present, ouvre proprement un .properties
|
// notepad : toujours present, ouvre proprement un .properties
|
||||||
@@ -136,11 +153,11 @@ public final class DesktopSingleInstance {
|
|||||||
} else if (os.contains("mac")) {
|
} else if (os.contains("mac")) {
|
||||||
pb = new ProcessBuilder("open", "-t", file.toString());
|
pb = new ProcessBuilder("open", "-t", file.toString());
|
||||||
} else {
|
} else {
|
||||||
pb = new ProcessBuilder("xdg-open", file.toString());
|
pb = new ProcessBuilder(XDG_OPEN, file.toString());
|
||||||
}
|
}
|
||||||
pb.start();
|
pb.start();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage());
|
log.warn("[Desktop] Ouverture du fichier impossible : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.loremind.infrastructure.desktop;
|
package com.loremind.infrastructure.desktop;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.InetAddress;
|
import java.net.InetAddress;
|
||||||
@@ -30,6 +33,14 @@ import java.util.Properties;
|
|||||||
*/
|
*/
|
||||||
public final class DesktopUserConfig {
|
public final class DesktopUserConfig {
|
||||||
|
|
||||||
|
// Utilisé AVANT SpringApplication.run() : Logback s'initialise avec sa config
|
||||||
|
// par défaut (console), puis Spring reprend la main — même destination que
|
||||||
|
// l'ancien System.out, mais horodaté et uniforme avec le reste des logs.
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(DesktopUserConfig.class);
|
||||||
|
|
||||||
|
/** Clé partagée fichier utilisateur / propriété système / Spring. */
|
||||||
|
private static final String SERVER_PORT_PROPERTY = "server.port";
|
||||||
|
|
||||||
private DesktopUserConfig() {}
|
private DesktopUserConfig() {}
|
||||||
|
|
||||||
private static final String DEFAULT_TEMPLATE = """
|
private static final String DEFAULT_TEMPLATE = """
|
||||||
@@ -69,10 +80,10 @@ public final class DesktopUserConfig {
|
|||||||
try {
|
try {
|
||||||
Files.createDirectories(file.getParent());
|
Files.createDirectories(file.getParent());
|
||||||
Files.writeString(file, DEFAULT_TEMPLATE);
|
Files.writeString(file, DEFAULT_TEMPLATE);
|
||||||
System.out.println("[Desktop] Config utilisateur creee : " + file);
|
log.info("[Desktop] Config utilisateur creee : {}", file);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage()
|
log.warn("[Desktop] Impossible de creer {} : {} — defauts utilises (port 8080, admin/admin).",
|
||||||
+ " — defauts utilises (port 8080, admin/admin).");
|
file, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,13 +98,13 @@ public final class DesktopUserConfig {
|
|||||||
int wanted = configuredPort();
|
int wanted = configuredPort();
|
||||||
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
|
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
|
||||||
if (chosen != wanted) {
|
if (chosen != wanted) {
|
||||||
System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + ".");
|
log.info("[Desktop] Port {} occupe — repli sur le port libre {}.", wanted, chosen);
|
||||||
}
|
}
|
||||||
System.setProperty("server.port", String.valueOf(chosen));
|
System.setProperty(SERVER_PORT_PROPERTY, String.valueOf(chosen));
|
||||||
try {
|
try {
|
||||||
Files.writeString(portFile(), String.valueOf(chosen));
|
Files.writeString(portFile(), String.valueOf(chosen));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ").");
|
log.warn("[Desktop] Ecriture du port impossible ({}).", e.getMessage());
|
||||||
}
|
}
|
||||||
return chosen;
|
return chosen;
|
||||||
}
|
}
|
||||||
@@ -104,7 +115,7 @@ public final class DesktopUserConfig {
|
|||||||
* → port configuré → 8080.
|
* → port configuré → 8080.
|
||||||
*/
|
*/
|
||||||
public static int runningPort() {
|
public static int runningPort() {
|
||||||
String sys = System.getProperty("server.port");
|
String sys = System.getProperty(SERVER_PORT_PROPERTY);
|
||||||
if (sys != null && !sys.isBlank()) {
|
if (sys != null && !sys.isBlank()) {
|
||||||
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
|
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
|
||||||
}
|
}
|
||||||
@@ -124,7 +135,7 @@ public final class DesktopUserConfig {
|
|||||||
Properties props = new Properties();
|
Properties props = new Properties();
|
||||||
try (InputStream in = Files.newInputStream(file)) {
|
try (InputStream in = Files.newInputStream(file)) {
|
||||||
props.load(in);
|
props.load(in);
|
||||||
String v = props.getProperty("server.port");
|
String v = props.getProperty(SERVER_PORT_PROPERTY);
|
||||||
if (v != null && !v.isBlank()) {
|
if (v != null && !v.isBlank()) {
|
||||||
return Integer.parseInt(v.trim());
|
return Integer.parseInt(v.trim());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
|
private static final Logger log = LoggerFactory.getLogger(FileDockerConfigWriter.class);
|
||||||
|
|
||||||
|
/** Cle racine du config.json Docker portant les registres authentifies. */
|
||||||
|
private static final String AUTHS_KEY = "auths";
|
||||||
|
|
||||||
private final Path configPath;
|
private final Path configPath;
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
@@ -62,9 +65,9 @@ public class FileDockerConfigWriter implements DockerConfigWriter {
|
|||||||
root = mapper.createObjectNode();
|
root = mapper.createObjectNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectNode auths = root.has("auths") && root.get("auths").isObject()
|
ObjectNode auths = root.has(AUTHS_KEY) && root.get(AUTHS_KEY).isObject()
|
||||||
? (ObjectNode) root.get("auths")
|
? (ObjectNode) root.get(AUTHS_KEY)
|
||||||
: root.putObject("auths");
|
: root.putObject(AUTHS_KEY);
|
||||||
|
|
||||||
String b64 = Base64.getEncoder().encodeToString(
|
String b64 = Base64.getEncoder().encodeToString(
|
||||||
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
|
(credentials.username() + ":" + credentials.password()).getBytes(StandardCharsets.UTF_8));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import com.nimbusds.jose.crypto.Ed25519Verifier;
|
|||||||
import com.nimbusds.jose.jwk.OctetKeyPair;
|
import com.nimbusds.jose.jwk.OctetKeyPair;
|
||||||
import com.nimbusds.jwt.JWTClaimsSet;
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
import com.nimbusds.jwt.SignedJWT;
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
import org.bouncycastle.asn1.ASN1Sequence;
|
import org.bouncycastle.asn1.ASN1Primitive;
|
||||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -21,8 +21,8 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.ParseException;
|
import java.text.ParseException;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
|
* Verifie les JWT EdDSA/Ed25519 emis par le relais Patreon.
|
||||||
@@ -88,25 +88,48 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
|
public LicenseClaims verify(String rawJwt) throws JwtVerificationException {
|
||||||
|
ensureConfigured();
|
||||||
|
ensureNotBlank(rawJwt);
|
||||||
|
|
||||||
|
SignedJWT signed = parseJwt(rawJwt);
|
||||||
|
ensureExpectedAlgorithm(signed);
|
||||||
|
verifySignature(signed);
|
||||||
|
|
||||||
|
JWTClaimsSet claims = parseClaims(signed);
|
||||||
|
ensureExpectedIssuer(claims);
|
||||||
|
ensureExpectedAudience(claims);
|
||||||
|
|
||||||
|
return toLicenseClaims(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureConfigured() throws JwtVerificationException {
|
||||||
if (publicKey == null) {
|
if (publicKey == null) {
|
||||||
throw new JwtVerificationException("JWT verifier not configured");
|
throw new JwtVerificationException("JWT verifier not configured");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureNotBlank(String rawJwt) throws JwtVerificationException {
|
||||||
if (rawJwt == null || rawJwt.isBlank()) {
|
if (rawJwt == null || rawJwt.isBlank()) {
|
||||||
throw new JwtVerificationException("JWT is empty");
|
throw new JwtVerificationException("JWT is empty");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SignedJWT signed;
|
private static SignedJWT parseJwt(String rawJwt) throws JwtVerificationException {
|
||||||
try {
|
try {
|
||||||
signed = SignedJWT.parse(rawJwt);
|
return SignedJWT.parse(rawJwt);
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
|
throw new JwtVerificationException("JWT parse error: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ensureExpectedAlgorithm(SignedJWT signed) throws JwtVerificationException {
|
||||||
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
|
JWSAlgorithm alg = signed.getHeader().getAlgorithm();
|
||||||
if (!JWSAlgorithm.EdDSA.equals(alg)) {
|
if (!JWSAlgorithm.EdDSA.equals(alg)) {
|
||||||
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
|
throw new JwtVerificationException("Unexpected JWT algorithm: " + alg);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void verifySignature(SignedJWT signed) throws JwtVerificationException {
|
||||||
try {
|
try {
|
||||||
JWSVerifier verifier = new Ed25519Verifier(publicKey);
|
JWSVerifier verifier = new Ed25519Verifier(publicKey);
|
||||||
if (!signed.verify(verifier)) {
|
if (!signed.verify(verifier)) {
|
||||||
@@ -115,31 +138,43 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
|
throw new JwtVerificationException("JWT signature verification failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JWTClaimsSet claims;
|
private static JWTClaimsSet parseClaims(SignedJWT signed) throws JwtVerificationException {
|
||||||
try {
|
try {
|
||||||
claims = signed.getJWTClaimsSet();
|
return signed.getJWTClaimsSet();
|
||||||
} catch (ParseException e) {
|
} catch (ParseException e) {
|
||||||
throw new JwtVerificationException("JWT claims parse error", e);
|
throw new JwtVerificationException("JWT claims parse error", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureExpectedIssuer(JWTClaimsSet claims) throws JwtVerificationException {
|
||||||
if (!expectedIssuer.equals(claims.getIssuer())) {
|
if (!expectedIssuer.equals(claims.getIssuer())) {
|
||||||
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
|
throw new JwtVerificationException("JWT issuer mismatch: " + claims.getIssuer());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureExpectedAudience(JWTClaimsSet claims) throws JwtVerificationException {
|
||||||
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
|
if (claims.getAudience() == null || !claims.getAudience().contains(expectedAudience)) {
|
||||||
throw new JwtVerificationException("JWT audience mismatch");
|
throw new JwtVerificationException("JWT audience mismatch");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Date exp = claims.getExpirationTime();
|
/**
|
||||||
Date iat = claims.getIssueTime();
|
* Construit les LicenseClaims depuis les claims validés (issuer/audience/signature).
|
||||||
|
* Note : on ne refuse pas un JWT expiré ici. C'est au LicenseService de decider ce
|
||||||
|
* qu'il fait d'un JWT expire (grace period, refresh, etc.). La verification de
|
||||||
|
* signature reste valide tant que la cle existe.
|
||||||
|
*/
|
||||||
|
private static LicenseClaims toLicenseClaims(JWTClaimsSet claims) throws JwtVerificationException {
|
||||||
|
// Nimbus n'expose exp/iat qu'en java.util.Date : converti en Instant SANS variable
|
||||||
|
// Date intermediaire (aucune reference au type legacy dans cette classe).
|
||||||
String sub = claims.getSubject();
|
String sub = claims.getSubject();
|
||||||
if (exp == null || iat == null || sub == null) {
|
if (claims.getExpirationTime() == null || claims.getIssueTime() == null || sub == null) {
|
||||||
throw new JwtVerificationException("JWT missing required claims");
|
throw new JwtVerificationException("JWT missing required claims");
|
||||||
}
|
}
|
||||||
|
Instant exp = claims.getExpirationTime().toInstant();
|
||||||
// Note : on ne refuse pas un JWT expire ici. C'est au LicenseService
|
Instant iat = claims.getIssueTime().toInstant();
|
||||||
// de decider ce qu'il fait d'un JWT expire (grace period, refresh, etc.).
|
|
||||||
// La verification de signature reste valide tant que la cle existe.
|
|
||||||
|
|
||||||
String tierId;
|
String tierId;
|
||||||
String instanceId;
|
String instanceId;
|
||||||
@@ -153,13 +188,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
|||||||
throw new JwtVerificationException("JWT missing tier_id or instance_id");
|
throw new JwtVerificationException("JWT missing tier_id or instance_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LicenseClaims(
|
return new LicenseClaims(sub, tierId, instanceId, iat, exp);
|
||||||
sub,
|
|
||||||
tierId,
|
|
||||||
instanceId,
|
|
||||||
iat.toInstant(),
|
|
||||||
exp.toInstant()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +203,7 @@ public class NimbusJwtVerifier implements JwtVerifier {
|
|||||||
.replace("-----END PUBLIC KEY-----", "")
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
.replaceAll("\\s+", "");
|
.replaceAll("\\s+", "");
|
||||||
byte[] der = Base64.getDecoder().decode(base64);
|
byte[] der = Base64.getDecoder().decode(base64);
|
||||||
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Sequence.fromByteArray(der));
|
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(der));
|
||||||
byte[] keyBytes = spki.getPublicKeyData().getOctets();
|
byte[] keyBytes = spki.getPublicKeyData().getOctets();
|
||||||
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
|
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(keyBytes);
|
||||||
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
|
return new OctetKeyPair.Builder(com.nimbusds.jose.jwk.Curve.Ed25519, com.nimbusds.jose.util.Base64URL.from(x))
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ public class CharacterNpcMarkdownBackfill {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
|
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
|
||||||
|
|
||||||
|
// SQL en CONSTANTES de compilation (pas de concaténation avec un paramètre) :
|
||||||
|
// les noms de table sont figés dans les littéraux, aucune donnée dynamique
|
||||||
|
// n'entre dans la requête hors placeholders '?'.
|
||||||
|
// Sélection : fiches avec markdown non vide ET field_values vide ou absent.
|
||||||
|
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
|
||||||
|
private static final String BACKFILL_WHERE =
|
||||||
|
" WHERE markdown_content IS NOT NULL "
|
||||||
|
+ " AND markdown_content <> '' "
|
||||||
|
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
|
||||||
|
private static final String SELECT_CHARACTERS =
|
||||||
|
"SELECT id, markdown_content FROM characters" + BACKFILL_WHERE;
|
||||||
|
private static final String SELECT_NPCS =
|
||||||
|
"SELECT id, markdown_content FROM npcs" + BACKFILL_WHERE;
|
||||||
|
private static final String UPDATE_CHARACTERS =
|
||||||
|
"UPDATE characters SET field_values = ? WHERE id = ?";
|
||||||
|
private static final String UPDATE_NPCS =
|
||||||
|
"UPDATE npcs SET field_values = ? WHERE id = ?";
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final ObjectMapper mapper = new ObjectMapper();
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
@@ -44,8 +62,8 @@ public class CharacterNpcMarkdownBackfill {
|
|||||||
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
|
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int chars = backfillTable("characters");
|
int chars = backfillTable("characters", SELECT_CHARACTERS, UPDATE_CHARACTERS);
|
||||||
int npcs = backfillTable("npcs");
|
int npcs = backfillTable("npcs", SELECT_NPCS, UPDATE_NPCS);
|
||||||
if (chars + npcs > 0) {
|
if (chars + npcs > 0) {
|
||||||
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
|
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
|
||||||
}
|
}
|
||||||
@@ -65,14 +83,7 @@ public class CharacterNpcMarkdownBackfill {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int backfillTable(String table) {
|
private int backfillTable(String label, String selectSql, String updateSql) {
|
||||||
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
|
|
||||||
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
|
|
||||||
String selectSql = "SELECT id, markdown_content FROM " + table
|
|
||||||
+ " WHERE markdown_content IS NOT NULL "
|
|
||||||
+ " AND markdown_content <> '' "
|
|
||||||
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
|
|
||||||
|
|
||||||
var rows = jdbc.queryForList(selectSql);
|
var rows = jdbc.queryForList(selectSql);
|
||||||
int migrated = 0;
|
int migrated = 0;
|
||||||
for (var row : rows) {
|
for (var row : rows) {
|
||||||
@@ -82,10 +93,10 @@ public class CharacterNpcMarkdownBackfill {
|
|||||||
try {
|
try {
|
||||||
json = mapper.writeValueAsString(Map.of("Notes", markdown));
|
json = mapper.writeValueAsString(Map.of("Notes", markdown));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
|
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", label, id, e.getMessage());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
|
jdbc.update(updateSql, json, id);
|
||||||
migrated++;
|
migrated++;
|
||||||
}
|
}
|
||||||
return migrated;
|
return migrated;
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ public class GameSystemSeeder {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
|
private static final Logger log = LoggerFactory.getLogger(GameSystemSeeder.class);
|
||||||
|
|
||||||
|
private static final String AUTHOR_SEED = "LoreMind seed";
|
||||||
|
private static final String FIELD_HISTOIRE = "Histoire";
|
||||||
|
private static final String FIELD_GALERIE = "Galerie";
|
||||||
|
|
||||||
private final GameSystemRepository gameSystemRepository;
|
private final GameSystemRepository gameSystemRepository;
|
||||||
|
|
||||||
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
|
public GameSystemSeeder(GameSystemRepository gameSystemRepository) {
|
||||||
@@ -81,7 +85,7 @@ public class GameSystemSeeder {
|
|||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Nimble (extrait)")
|
.name("Nimble (extrait)")
|
||||||
.description("Système léger et narratif, résolution rapide des combats.")
|
.description("Système léger et narratif, résolution rapide des combats.")
|
||||||
.author("LoreMind seed")
|
.author(AUTHOR_SEED)
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(NIMBLE_RULES)
|
.rulesMarkdown(NIMBLE_RULES)
|
||||||
.characterTemplate(nimbleCharacterTemplate())
|
.characterTemplate(nimbleCharacterTemplate())
|
||||||
@@ -90,7 +94,7 @@ public class GameSystemSeeder {
|
|||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("D&D 5e SRD (extrait)")
|
.name("D&D 5e SRD (extrait)")
|
||||||
.description("Extrait libre des bases du System Reference Document 5.1.")
|
.description("Extrait libre des bases du System Reference Document 5.1.")
|
||||||
.author("LoreMind seed")
|
.author(AUTHOR_SEED)
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(DND_SRD_RULES)
|
.rulesMarkdown(DND_SRD_RULES)
|
||||||
.characterTemplate(dndCharacterTemplate())
|
.characterTemplate(dndCharacterTemplate())
|
||||||
@@ -99,7 +103,7 @@ public class GameSystemSeeder {
|
|||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Homebrew Exemple")
|
.name("Homebrew Exemple")
|
||||||
.description("Template minimaliste à dupliquer pour créer votre propre système.")
|
.description("Template minimaliste à dupliquer pour créer votre propre système.")
|
||||||
.author("LoreMind seed")
|
.author(AUTHOR_SEED)
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||||
.characterTemplate(genericCharacterTemplate())
|
.characterTemplate(genericCharacterTemplate())
|
||||||
@@ -113,10 +117,10 @@ public class GameSystemSeeder {
|
|||||||
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
||||||
private static List<TemplateField> genericCharacterTemplate() {
|
private static List<TemplateField> genericCharacterTemplate() {
|
||||||
return List.of(
|
return List.of(
|
||||||
TemplateField.text("Histoire"),
|
TemplateField.text(FIELD_HISTOIRE),
|
||||||
TemplateField.text("Personnalite"),
|
TemplateField.text("Personnalite"),
|
||||||
TemplateField.text("Apparence"),
|
TemplateField.text("Apparence"),
|
||||||
TemplateField.image("Galerie", ImageLayout.GALLERY),
|
TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY),
|
||||||
TemplateField.text("Notes")
|
TemplateField.text("Notes")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -137,9 +141,9 @@ public class GameSystemSeeder {
|
|||||||
TemplateField.number("Blessures graves max"),
|
TemplateField.number("Blessures graves max"),
|
||||||
TemplateField.text("Capacites de classe"),
|
TemplateField.text("Capacites de classe"),
|
||||||
TemplateField.text("Equipement"),
|
TemplateField.text("Equipement"),
|
||||||
TemplateField.text("Histoire"),
|
TemplateField.text(FIELD_HISTOIRE),
|
||||||
TemplateField.text("Objectifs personnels"),
|
TemplateField.text("Objectifs personnels"),
|
||||||
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +161,8 @@ public class GameSystemSeeder {
|
|||||||
TemplateField.text("Competences"),
|
TemplateField.text("Competences"),
|
||||||
TemplateField.text("Equipement"),
|
TemplateField.text("Equipement"),
|
||||||
TemplateField.text("Sorts"),
|
TemplateField.text("Sorts"),
|
||||||
TemplateField.text("Histoire"),
|
TemplateField.text(FIELD_HISTOIRE),
|
||||||
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
TemplateField.image(FIELD_GALERIE, ImageLayout.GALLERY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
import jakarta.persistence.Converter;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,8 +33,11 @@ public class MapJsonConverter implements AttributeConverter<Map<String, Object>,
|
|||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public Map<String, Object> convertToEntityAttribute(String dbData) {
|
public Map<String, Object> convertToEntityAttribute(String dbData) {
|
||||||
if (dbData == null) {
|
// Map vide (mutable) plutôt que null : les consommateurs itèrent/écrivent
|
||||||
return null;
|
// sans avoir à se défendre contre le null (même contrat que les autres
|
||||||
|
// converters de ce package).
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return new HashMap<>();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return objectMapper.readValue(dbData, Map.class);
|
return objectMapper.readValue(dbData, Map.class);
|
||||||
|
|||||||
@@ -60,71 +60,8 @@ public class TemplateFieldListJsonConverter
|
|||||||
}
|
}
|
||||||
List<TemplateField> result = new ArrayList<>();
|
List<TemplateField> result = new ArrayList<>();
|
||||||
for (JsonNode item : root) {
|
for (JsonNode item : root) {
|
||||||
if (item.isTextual()) {
|
TemplateField field = parseField(item);
|
||||||
// Format legacy : chaine simple, on suppose TEXT par defaut.
|
if (field != null) result.add(field);
|
||||||
// L'id stable est retro-rempli avec le nom (les valeurs de Page
|
|
||||||
// sont deja rangees par nom -> id == name, aucune migration).
|
|
||||||
String name = item.asText();
|
|
||||||
TemplateField legacy = TemplateField.text(name);
|
|
||||||
legacy.setId(name);
|
|
||||||
result.add(legacy);
|
|
||||||
} else if (item.isObject()) {
|
|
||||||
// Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}
|
|
||||||
String name = item.path("name").asText(null);
|
|
||||||
String typeStr = item.path("type").asText("TEXT");
|
|
||||||
FieldType type;
|
|
||||||
try {
|
|
||||||
type = FieldType.valueOf(typeStr);
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
// Type inconnu (ajoute par une version future) : fallback TEXT.
|
|
||||||
type = FieldType.TEXT;
|
|
||||||
}
|
|
||||||
ImageLayout layout = null;
|
|
||||||
if (type == FieldType.IMAGE) {
|
|
||||||
String layoutStr = item.path("layout").asText(null);
|
|
||||||
if (layoutStr != null && !layoutStr.isBlank()) {
|
|
||||||
try {
|
|
||||||
layout = ImageLayout.valueOf(layoutStr);
|
|
||||||
} catch (IllegalArgumentException ex) {
|
|
||||||
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
|
|
||||||
layout = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
List<String> labels = null;
|
|
||||||
if (type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) {
|
|
||||||
JsonNode labelsNode = item.path("labels");
|
|
||||||
if (labelsNode.isArray()) {
|
|
||||||
labels = new ArrayList<>();
|
|
||||||
for (JsonNode label : labelsNode) {
|
|
||||||
if (label.isTextual()) labels.add(label.asText());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
|
|
||||||
// (asText() renverrait la chaine "null"). Historiquement omis a la
|
|
||||||
// relecture -> il etait perdu au save+reload : corrige ici.
|
|
||||||
String foundryPath = item.hasNonNull("foundryPath")
|
|
||||||
? item.get("foundryPath").asText() : null;
|
|
||||||
// id : explicite si present, sinon retro-rempli avec le nom.
|
|
||||||
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
|
|
||||||
if (id == null || id.isBlank()) {
|
|
||||||
id = name;
|
|
||||||
}
|
|
||||||
BlockPosition pos = readPos(item.path("pos"));
|
|
||||||
if (name != null && !name.isBlank()) {
|
|
||||||
result.add(TemplateField.builder()
|
|
||||||
.id(id)
|
|
||||||
.name(name)
|
|
||||||
.type(type)
|
|
||||||
.layout(layout)
|
|
||||||
.labels(labels)
|
|
||||||
.foundryPath(foundryPath)
|
|
||||||
.pos(pos)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -133,6 +70,101 @@ public class TemplateFieldListJsonConverter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Un element du tableau JSON -> TemplateField, ou null si non convertible (ignore). */
|
||||||
|
private static TemplateField parseField(JsonNode item) {
|
||||||
|
if (item.isTextual()) {
|
||||||
|
return parseLegacyField(item.asText());
|
||||||
|
}
|
||||||
|
if (item.isObject()) {
|
||||||
|
return parseObjectField(item);
|
||||||
|
}
|
||||||
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format legacy : chaine simple, on suppose TEXT par defaut. L'id stable est
|
||||||
|
* retro-rempli avec le nom (les valeurs de Page sont deja rangees par nom ->
|
||||||
|
* id == name, aucune migration).
|
||||||
|
*/
|
||||||
|
private static TemplateField parseLegacyField(String name) {
|
||||||
|
TemplateField legacy = TemplateField.text(name);
|
||||||
|
legacy.setId(name);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}. Null si nom vide. */
|
||||||
|
private static TemplateField parseObjectField(JsonNode item) {
|
||||||
|
String name = item.path("name").asText(null);
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
FieldType type = parseType(item.path("type").asText("TEXT"));
|
||||||
|
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
|
||||||
|
// (asText() renverrait la chaine "null"). Historiquement omis a la
|
||||||
|
// relecture -> il etait perdu au save+reload : corrige ici.
|
||||||
|
String foundryPath = item.hasNonNull("foundryPath")
|
||||||
|
? item.get("foundryPath").asText() : null;
|
||||||
|
// id : explicite si present, sinon retro-rempli avec le nom.
|
||||||
|
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
|
||||||
|
if (id == null || id.isBlank()) {
|
||||||
|
id = name;
|
||||||
|
}
|
||||||
|
return TemplateField.builder()
|
||||||
|
.id(id)
|
||||||
|
.name(name)
|
||||||
|
.type(type)
|
||||||
|
.layout(parseLayout(item, type))
|
||||||
|
.labels(parseLabels(item, type))
|
||||||
|
.foundryPath(foundryPath)
|
||||||
|
.pos(readPos(item.path("pos")))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type du champ ; fallback TEXT si inconnu (ajoute par une version future). */
|
||||||
|
private static FieldType parseType(String typeStr) {
|
||||||
|
try {
|
||||||
|
return FieldType.valueOf(typeStr);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return FieldType.TEXT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Layout d'un champ IMAGE ; null si absent/inconnu (-> rendu GALLERY par defaut cote UI). */
|
||||||
|
private static ImageLayout parseLayout(JsonNode item, FieldType type) {
|
||||||
|
if (type != FieldType.IMAGE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String layoutStr = item.path("layout").asText(null);
|
||||||
|
if (layoutStr == null || layoutStr.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ImageLayout.valueOf(layoutStr);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libelles d'un champ KEY_VALUE_LIST/TABLE ; null pour les autres types. */
|
||||||
|
// S1168 : null est ici SEMANTIQUE ("champ sans libelles"), distinct d'une liste vide —
|
||||||
|
// le JSON persiste ecrit "labels":null (compat ascendante) et le front distingue null/[].
|
||||||
|
@SuppressWarnings("java:S1168")
|
||||||
|
private static List<String> parseLabels(JsonNode item, FieldType type) {
|
||||||
|
if (type != FieldType.KEY_VALUE_LIST && type != FieldType.TABLE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JsonNode labelsNode = item.path("labels");
|
||||||
|
if (!labelsNode.isArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> labels = new ArrayList<>();
|
||||||
|
for (JsonNode label : labelsNode) {
|
||||||
|
if (label.isTextual()) labels.add(label.asText());
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
|
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
|
||||||
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
|
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -94,12 +95,12 @@ public class ArcJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
|
* Entité JPA pour la persistance des Campaigns en base de données PostgreSQL.
|
||||||
@@ -64,12 +65,12 @@ public class CampaignJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = createdAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -68,12 +69,12 @@ public class ChapterJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -81,8 +82,8 @@ public class CharacterJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
@@ -90,6 +91,6 @@ public class CharacterJpaEntity {
|
|||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
|
* Entité JPA pour la persistance des Horloges (Clocks) en PostgreSQL.
|
||||||
@@ -64,13 +65,13 @@ public class ClockJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -77,13 +78,13 @@ public class ConversationJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
|
* Persistance d'un message appartenant a une {@link ConversationJpaEntity}.
|
||||||
@@ -54,6 +55,6 @@ public class ConversationMessageJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -83,8 +84,8 @@ public class EnemyJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = createdAt;
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
@@ -92,6 +93,6 @@ public class EnemyJpaEntity {
|
|||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des Fronts en PostgreSQL.
|
* Entité JPA pour la persistance des Fronts en PostgreSQL.
|
||||||
@@ -44,13 +45,13 @@ public class FrontJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -69,8 +70,8 @@ public class GameSystemJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = createdAt;
|
||||||
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||||
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||||
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
|
if (enemyTemplate == null) enemyTemplate = new ArrayList<>();
|
||||||
@@ -78,6 +79,6 @@ public class GameSystemJpaEntity {
|
|||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entite JPA pour les metadonnees d'images en PostgreSQL.
|
* Entite JPA pour les metadonnees d'images en PostgreSQL.
|
||||||
@@ -43,7 +44,7 @@ public class ImageJpaEntity {
|
|||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
if (uploadedAt == null) {
|
if (uploadedAt == null) {
|
||||||
uploadedAt = LocalDateTime.now();
|
uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -60,12 +61,12 @@ public class ItemCatalogJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des Lores en base de données PostgreSQL.
|
* Entité JPA pour la persistance des Lores en base de données PostgreSQL.
|
||||||
@@ -43,12 +44,12 @@ public class LoreJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
|
* Entité JPA pour la persistance des LoreNodes en base de données PostgreSQL.
|
||||||
@@ -48,12 +49,12 @@ public class LoreNodeJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "notebooks", indexes = {
|
@Table(name = "notebooks", indexes = {
|
||||||
@@ -36,12 +37,12 @@ public class NotebookJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "notebook_messages", indexes = {
|
@Table(name = "notebook_messages", indexes = {
|
||||||
@@ -44,6 +45,6 @@ public class NotebookMessageJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "notebook_sources", indexes = {
|
@Table(name = "notebook_sources", indexes = {
|
||||||
@@ -42,6 +43,6 @@ public class NotebookSourceJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
if (createdAt == null) createdAt = LocalDateTime.now();
|
if (createdAt == null) createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -74,8 +75,8 @@ public class NpcJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
if (values == null) values = new HashMap<>();
|
if (values == null) values = new HashMap<>();
|
||||||
if (imageValues == null) imageValues = new HashMap<>();
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
if (keyValueValues == null) keyValueValues = new HashMap<>();
|
||||||
@@ -83,6 +84,6 @@ public class NpcJpaEntity {
|
|||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -94,12 +95,12 @@ public class PageJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne.
|
* Entité JPA pour la table {@code playthroughs} — instances jouées d'une Campagne.
|
||||||
@@ -43,12 +44,12 @@ public class PlaythroughJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -90,12 +91,12 @@ public class QuestJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -62,12 +63,12 @@ public class RandomTableJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -140,12 +141,12 @@ public class SceneJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des entrées de journal de session.
|
* Entité JPA pour la persistance des entrées de journal de session.
|
||||||
@@ -48,13 +49,13 @@ public class SessionEntryJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des Sessions en PostgreSQL.
|
* Entité JPA pour la persistance des Sessions en PostgreSQL.
|
||||||
@@ -61,13 +62,13 @@ public class SessionJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
createdAt = now;
|
createdAt = now;
|
||||||
updatedAt = now;
|
updatedAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL.
|
* Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL.
|
||||||
@@ -44,7 +45,7 @@ public class StoredFileJpaEntity {
|
|||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
if (uploadedAt == null) {
|
if (uploadedAt == null) {
|
||||||
uploadedAt = LocalDateTime.now();
|
uploadedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,12 +55,12 @@ public class TemplateJpaEntity {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +36,11 @@ public class PlaythroughMigrationRunner {
|
|||||||
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
|
private static final Logger LOG = LoggerFactory.getLogger(PlaythroughMigrationRunner.class);
|
||||||
|
|
||||||
private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale";
|
private static final String DEFAULT_PLAYTHROUGH_NAME = "Partie principale";
|
||||||
|
private static final String COLUMN_CAMPAIGN_ID = "campaign_id";
|
||||||
|
|
||||||
|
// S6809 : auto-invocation assumee — "corriger" (auto-injection/@Lazy) changerait la semantique
|
||||||
|
// transactionnelle reelle de cette migration one-shot idempotente.
|
||||||
|
@SuppressWarnings("java:S6809")
|
||||||
@Bean
|
@Bean
|
||||||
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
|
public ApplicationRunner runPlaythroughMigration(JdbcTemplate jdbc) {
|
||||||
return args -> migrate(jdbc);
|
return args -> migrate(jdbc);
|
||||||
@@ -73,7 +78,7 @@ public class PlaythroughMigrationRunner {
|
|||||||
);
|
);
|
||||||
if (campaignsWithoutPlaythrough.isEmpty()) return 0;
|
if (campaignsWithoutPlaythrough.isEmpty()) return 0;
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now(ZoneId.systemDefault());
|
||||||
for (Long campaignId : campaignsWithoutPlaythrough) {
|
for (Long campaignId : campaignsWithoutPlaythrough) {
|
||||||
jdbc.update(
|
jdbc.update(
|
||||||
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
|
"INSERT INTO playthroughs (campaign_id, name, description, created_at, updated_at) " +
|
||||||
@@ -85,7 +90,7 @@ public class PlaythroughMigrationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int migrateSessions(JdbcTemplate jdbc) {
|
private int migrateSessions(JdbcTemplate jdbc) {
|
||||||
if (!columnExists(jdbc, "sessions", "campaign_id")) return 0;
|
if (!columnExists(jdbc, "sessions", COLUMN_CAMPAIGN_ID)) return 0;
|
||||||
return jdbc.update(
|
return jdbc.update(
|
||||||
"UPDATE sessions s " +
|
"UPDATE sessions s " +
|
||||||
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " +
|
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = CAST(s.campaign_id AS BIGINT) LIMIT 1) " +
|
||||||
@@ -94,7 +99,7 @@ public class PlaythroughMigrationRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int migrateCharacters(JdbcTemplate jdbc) {
|
private int migrateCharacters(JdbcTemplate jdbc) {
|
||||||
if (!columnExists(jdbc, "characters", "campaign_id")) return 0;
|
if (!columnExists(jdbc, "characters", COLUMN_CAMPAIGN_ID)) return 0;
|
||||||
return jdbc.update(
|
return jdbc.update(
|
||||||
"UPDATE characters c " +
|
"UPDATE characters c " +
|
||||||
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " +
|
"SET playthrough_id = (SELECT p.id FROM playthroughs p WHERE p.campaign_id = c.campaign_id LIMIT 1) " +
|
||||||
@@ -145,11 +150,13 @@ public class PlaythroughMigrationRunner {
|
|||||||
* Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE.
|
* Idempotent : PostgreSQL ne bronche pas si la colonne est déjà NULLABLE.
|
||||||
*/
|
*/
|
||||||
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
|
private void relaxLegacyNotNullConstraints(JdbcTemplate jdbc) {
|
||||||
relaxNotNull(jdbc, "sessions", "campaign_id");
|
relaxNotNull(jdbc, "sessions", COLUMN_CAMPAIGN_ID);
|
||||||
relaxNotNull(jdbc, "characters", "campaign_id");
|
relaxNotNull(jdbc, "characters", COLUMN_CAMPAIGN_ID);
|
||||||
relaxNotNull(jdbc, "chapters", "progression_status");
|
relaxNotNull(jdbc, "chapters", "progression_status");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// S2077 : DDL non parametrable ; table/colonne proviennent exclusivement d'appels internes hardcodes.
|
||||||
|
@SuppressWarnings("java:S2077")
|
||||||
private void relaxNotNull(JdbcTemplate jdbc, String table, String column) {
|
private void relaxNotNull(JdbcTemplate jdbc, String table, String column) {
|
||||||
if (!columnExists(jdbc, table, column)) return;
|
if (!columnExists(jdbc, table, column)) return;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port ArcRepository.
|
* Adaptateur d'infrastructure qui implémente le Port ArcRepository.
|
||||||
@@ -42,14 +41,14 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
Long longCampaignId = Long.parseLong(campaignId);
|
Long longCampaignId = Long.parseLong(campaignId);
|
||||||
return jpaRepository.findByCampaignId(longCampaignId).stream()
|
return jpaRepository.findByCampaignId(longCampaignId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Arc> findAll() {
|
public List<Arc> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
|
* Adaptateur d'infrastructure qui implémente le Port CampaignRepository.
|
||||||
@@ -43,7 +42,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
|||||||
public List<Campaign> findAll() {
|
public List<Campaign> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -62,7 +61,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
|
|||||||
public List<Campaign> searchByName(String query) {
|
public List<Campaign> searchByName(String query) {
|
||||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) {
|
private Campaign toDomainEntity(CampaignJpaEntity jpaEntity) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port ChapterRepository.
|
* Adaptateur d'infrastructure qui implémente le Port ChapterRepository.
|
||||||
@@ -42,14 +41,14 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
Long longArcId = Long.parseLong(arcId);
|
Long longArcId = Long.parseLong(arcId);
|
||||||
return jpaRepository.findByArcId(longArcId).stream()
|
return jpaRepository.findByArcId(longArcId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Chapter> findAll() {
|
public List<Chapter> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresCharacterRepository implements CharacterRepository {
|
public class PostgresCharacterRepository implements CharacterRepository {
|
||||||
@@ -36,7 +35,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
public List<Character> findByPlaythroughId(String playthroughId) {
|
public List<Character> findByPlaythroughId(String playthroughId) {
|
||||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -53,7 +52,7 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
public List<Character> searchByName(String query) {
|
public List<Character> searchByName(String query) {
|
||||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Character toDomainEntity(CharacterJpaEntity e) {
|
private Character toDomainEntity(CharacterJpaEntity e) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres.
|
* Adaptateur d'infrastructure : implémente le port {@link ClockRepository} via JPA/Postgres.
|
||||||
@@ -37,7 +36,7 @@ public class PostgresClockRepository implements ClockRepository {
|
|||||||
public List<Clock> findByPlaythroughId(String playthroughId) {
|
public List<Clock> findByPlaythroughId(String playthroughId) {
|
||||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import com.loremind.infrastructure.persistence.entity.ConversationJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
|
import com.loremind.infrastructure.persistence.entity.ConversationMessageJpaEntity;
|
||||||
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.ConversationJpaRepository;
|
||||||
import jakarta.persistence.EntityNotFoundException;
|
import jakarta.persistence.EntityNotFoundException;
|
||||||
|
import org.hibernate.Hibernate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur Postgres pour ConversationRepository.
|
* Adaptateur Postgres pour ConversationRepository.
|
||||||
@@ -46,7 +46,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
|||||||
return jpa.findById(Long.parseLong(id))
|
return jpa.findById(Long.parseLong(id))
|
||||||
.map(e -> {
|
.map(e -> {
|
||||||
// Force l'initialisation LAZY avant de sortir de la transaction.
|
// Force l'initialisation LAZY avant de sortir de la transaction.
|
||||||
e.getMessages().size();
|
Hibernate.initialize(e.getMessages());
|
||||||
return toDomain(e, true);
|
return toDomain(e, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
|||||||
} else {
|
} else {
|
||||||
return Collections.emptyList();
|
return Collections.emptyList();
|
||||||
}
|
}
|
||||||
return rows.stream().map(e -> toDomain(e, false)).collect(Collectors.toList());
|
return rows.stream().map(e -> toDomain(e, false)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -88,7 +88,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
|||||||
.build();
|
.build();
|
||||||
conv.getMessages().add(msg);
|
conv.getMessages().add(msg);
|
||||||
// Force updatedAt via @PreUpdate en modifiant la conv (touch).
|
// Force updatedAt via @PreUpdate en modifiant la conv (touch).
|
||||||
conv.setUpdatedAt(java.time.LocalDateTime.now());
|
conv.setUpdatedAt(java.time.LocalDateTime.now(java.time.ZoneId.systemDefault()));
|
||||||
|
|
||||||
ConversationJpaEntity saved = jpa.save(conv);
|
ConversationJpaEntity saved = jpa.save(conv);
|
||||||
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
|
ConversationMessageJpaEntity persisted = saved.getMessages().get(saved.getMessages().size() - 1);
|
||||||
@@ -122,7 +122,7 @@ public class PostgresConversationRepository implements ConversationRepository {
|
|||||||
|
|
||||||
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
|
private Conversation toDomain(ConversationJpaEntity e, boolean withMessages) {
|
||||||
List<ConversationMessage> msgs = withMessages
|
List<ConversationMessage> msgs = withMessages
|
||||||
? e.getMessages().stream().map(this::toDomainMessage).collect(Collectors.toList())
|
? e.getMessages().stream().map(this::toDomainMessage).toList()
|
||||||
: new java.util.ArrayList<>();
|
: new java.util.ArrayList<>();
|
||||||
return Conversation.builder()
|
return Conversation.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresEnemyRepository implements EnemyRepository {
|
public class PostgresEnemyRepository implements EnemyRepository {
|
||||||
@@ -34,7 +33,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
|||||||
public List<Enemy> findByCampaignId(String campaignId) {
|
public List<Enemy> findByCampaignId(String campaignId) {
|
||||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -46,7 +45,7 @@ public class PostgresEnemyRepository implements EnemyRepository {
|
|||||||
public List<Enemy> searchByName(String query) {
|
public List<Enemy> searchByName(String query) {
|
||||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
private Enemy toDomainEntity(EnemyJpaEntity e) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres.
|
* Adaptateur d'infrastructure : implémente le port {@link FrontRepository} via JPA/Postgres.
|
||||||
@@ -36,7 +35,7 @@ public class PostgresFrontRepository implements FrontRepository {
|
|||||||
public List<Front> findByPlaythroughId(String playthroughId) {
|
public List<Front> findByPlaythroughId(String playthroughId) {
|
||||||
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
return jpaRepository.findByPlaythroughIdOrderByOrderAsc(Long.parseLong(playthroughId)).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresGameSystemRepository implements GameSystemRepository {
|
public class PostgresGameSystemRepository implements GameSystemRepository {
|
||||||
@@ -35,7 +34,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
public List<GameSystem> findAll() {
|
public List<GameSystem> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -52,7 +51,7 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
public List<GameSystem> searchByName(String query) {
|
public List<GameSystem> searchByName(String query) {
|
||||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
|
private GameSystem toDomainEntity(GameSystemJpaEntity e) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
|||||||
public List<ItemCatalog> findByCampaignId(String campaignId) {
|
public List<ItemCatalog> findByCampaignId(String campaignId) {
|
||||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -82,7 +82,7 @@ public class PostgresItemCatalogRepository implements ItemCatalogRepository {
|
|||||||
public List<ItemCatalog> searchByName(String query) {
|
public List<ItemCatalog> searchByName(String query) {
|
||||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
private ItemCatalog toDomainEntity(ItemCatalogJpaEntity e) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository.
|
* Adaptateur d'infrastructure qui implémente le Port LoreNodeRepository.
|
||||||
@@ -41,7 +40,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
|||||||
Long longLoreId = Long.parseLong(loreId);
|
Long longLoreId = Long.parseLong(loreId);
|
||||||
return jpaRepository.findByLoreId(longLoreId).stream()
|
return jpaRepository.findByLoreId(longLoreId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -49,19 +48,19 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
|||||||
if (parentId == null || parentId.isEmpty()) {
|
if (parentId == null || parentId.isEmpty()) {
|
||||||
return jpaRepository.findByParentId(null).stream()
|
return jpaRepository.findByParentId(null).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
Long longParentId = Long.parseLong(parentId);
|
Long longParentId = Long.parseLong(parentId);
|
||||||
return jpaRepository.findByParentId(longParentId).stream()
|
return jpaRepository.findByParentId(longParentId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<LoreNode> findAll() {
|
public List<LoreNode> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -85,7 +84,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
|
|||||||
public List<LoreNode> searchByName(String query) {
|
public List<LoreNode> searchByName(String query) {
|
||||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) {
|
private LoreNode toDomainEntity(LoreNodeJpaEntity jpaEntity) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port LoreRepository.
|
* Adaptateur d'infrastructure qui implémente le Port LoreRepository.
|
||||||
@@ -42,7 +41,7 @@ public class PostgresLoreRepository implements LoreRepository {
|
|||||||
public List<Lore> findAll() {
|
public List<Lore> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -61,7 +60,7 @@ public class PostgresLoreRepository implements LoreRepository {
|
|||||||
public List<Lore> searchByName(String query) {
|
public List<Lore> searchByName(String query) {
|
||||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Méthodes de conversion
|
// Méthodes de conversion
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresNotebookRepository implements NotebookRepository {
|
public class PostgresNotebookRepository implements NotebookRepository {
|
||||||
@@ -53,7 +52,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
|||||||
@Override
|
@Override
|
||||||
public List<Notebook> findByCampaignId(String campaignId) {
|
public List<Notebook> findByCampaignId(String campaignId) {
|
||||||
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
|
return notebookJpa.findByCampaignIdOrderByUpdatedAtDesc(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toNotebook).collect(Collectors.toList());
|
.map(this::toNotebook).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -93,7 +92,7 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
|||||||
@Override
|
@Override
|
||||||
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
|
public List<NotebookSource> findSourcesByNotebookId(String notebookId) {
|
||||||
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
return sourceJpa.findByNotebookIdOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||||
.map(this::toSource).collect(Collectors.toList());
|
.map(this::toSource).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -116,19 +115,20 @@ public class PostgresNotebookRepository implements NotebookRepository {
|
|||||||
@Override
|
@Override
|
||||||
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
|
public List<NotebookMessage> findMessagesByNotebookId(String notebookId) {
|
||||||
return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
return messageJpa.findByNotebookIdAndArchivedAtIsNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||||
.map(this::toMessage).collect(Collectors.toList());
|
.map(this::toMessage).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public void archiveMessagesByNotebookId(String notebookId) {
|
public void archiveMessagesByNotebookId(String notebookId) {
|
||||||
messageJpa.archiveActiveMessages(Long.parseLong(notebookId), java.time.LocalDateTime.now());
|
messageJpa.archiveActiveMessages(Long.parseLong(notebookId),
|
||||||
|
java.time.LocalDateTime.now(java.time.ZoneId.systemDefault()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
|
public List<NotebookMessage> findArchivedMessagesByNotebookId(String notebookId) {
|
||||||
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
return messageJpa.findByNotebookIdAndArchivedAtIsNotNullOrderByCreatedAtAsc(Long.parseLong(notebookId)).stream()
|
||||||
.map(this::toMessage).collect(Collectors.toList());
|
.map(this::toMessage).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mapping ---
|
// --- Mapping ---
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresNpcRepository implements NpcRepository {
|
public class PostgresNpcRepository implements NpcRepository {
|
||||||
@@ -37,7 +36,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
public List<Npc> findByCampaignId(String campaignId) {
|
public List<Npc> findByCampaignId(String campaignId) {
|
||||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,7 +53,7 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
public List<Npc> searchByName(String query) {
|
public List<Npc> searchByName(String query) {
|
||||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Npc toDomainEntity(NpcJpaEntity e) {
|
private Npc toDomainEntity(NpcJpaEntity e) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import java.util.ArrayList;
|
|||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port PageRepository.
|
* Adaptateur d'infrastructure qui implémente le Port PageRepository.
|
||||||
@@ -42,7 +41,7 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
public List<Page> findByLoreId(String loreId) {
|
public List<Page> findByLoreId(String loreId) {
|
||||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -50,14 +49,14 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
Long longNodeId = Long.parseLong(nodeId);
|
Long longNodeId = Long.parseLong(nodeId);
|
||||||
return jpaRepository.findByNodeId(longNodeId).stream()
|
return jpaRepository.findByNodeId(longNodeId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Page> findAll() {
|
public List<Page> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -81,7 +80,7 @@ public class PostgresPageRepository implements PageRepository {
|
|||||||
public List<Page> searchByTitle(String query) {
|
public List<Page> searchByTitle(String query) {
|
||||||
return jpaRepository.findByTitleContainingIgnoreCase(query).stream()
|
return jpaRepository.findByTitleContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Page toDomainEntity(PageJpaEntity e) {
|
private Page toDomainEntity(PageJpaEntity e) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresPlaythroughRepository implements PlaythroughRepository {
|
public class PostgresPlaythroughRepository implements PlaythroughRepository {
|
||||||
@@ -34,12 +33,12 @@ public class PostgresPlaythroughRepository implements PlaythroughRepository {
|
|||||||
public List<Playthrough> findByCampaignId(String campaignId) {
|
public List<Playthrough> findByCampaignId(String campaignId) {
|
||||||
return jpa.findByCampaignId(Long.parseLong(campaignId)).stream()
|
return jpa.findByCampaignId(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Playthrough> findAll() {
|
public List<Playthrough> findAll() {
|
||||||
return jpa.findAll().stream().map(this::toDomain).collect(Collectors.toList());
|
return jpa.findAll().stream().map(this::toDomain).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class PostgresQuestProgressionRepository implements QuestProgressionRepository {
|
public class PostgresQuestProgressionRepository implements QuestProgressionRepository {
|
||||||
@@ -25,7 +24,7 @@ public class PostgresQuestProgressionRepository implements QuestProgressionRepos
|
|||||||
public List<QuestProgression> findByPlaythroughId(String playthroughId) {
|
public List<QuestProgression> findByPlaythroughId(String playthroughId) {
|
||||||
return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream()
|
return jpa.findByPlaythroughId(Long.parseLong(playthroughId)).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port QuestRepository.
|
* Adaptateur d'infrastructure qui implémente le Port QuestRepository.
|
||||||
@@ -40,7 +39,7 @@ public class PostgresQuestRepository implements QuestRepository {
|
|||||||
public List<Quest> findByCampaignId(String campaignId) {
|
public List<Quest> findByCampaignId(String campaignId) {
|
||||||
return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream()
|
return jpaRepository.findByCampaignId(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -48,14 +47,14 @@ public class PostgresQuestRepository implements QuestRepository {
|
|||||||
if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée
|
if (arcId == null || arcId.isBlank()) return List.of(); // pas d'arc → aucune quête rattachée
|
||||||
return jpaRepository.findByArcId(Long.parseLong(arcId)).stream()
|
return jpaRepository.findByArcId(Long.parseLong(arcId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Quest> findAll() {
|
public List<Quest> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
|||||||
public List<RandomTable> findByCampaignId(String campaignId) {
|
public List<RandomTable> findByCampaignId(String campaignId) {
|
||||||
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
return jpaRepository.findByCampaignIdOrderByOrderAsc(Long.parseLong(campaignId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -86,7 +86,7 @@ public class PostgresRandomTableRepository implements RandomTableRepository {
|
|||||||
public List<RandomTable> searchByName(String query) {
|
public List<RandomTable> searchByName(String query) {
|
||||||
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
return jpaRepository.findTop20ByNameContainingIgnoreCaseOrderByNameAsc(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
private RandomTable toDomainEntity(RandomTableJpaEntity e) {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port SceneRepository.
|
* Adaptateur d'infrastructure qui implémente le Port SceneRepository.
|
||||||
@@ -43,14 +42,14 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
Long longChapterId = Long.parseLong(chapterId);
|
Long longChapterId = Long.parseLong(chapterId);
|
||||||
return jpaRepository.findByChapterId(longChapterId).stream()
|
return jpaRepository.findByChapterId(longChapterId).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Scene> findAll() {
|
public List<Scene> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
|
* Adaptateur d'infrastructure : implémente le Port SessionEntryRepository.
|
||||||
@@ -38,7 +37,7 @@ public class PostgresSessionEntryRepository implements SessionEntryRepository {
|
|||||||
public List<SessionEntry> findBySessionId(String sessionId) {
|
public List<SessionEntry> findBySessionId(String sessionId) {
|
||||||
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
|
return jpaRepository.findBySessionIdOrderByOccurredAtAsc(sessionId).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant
|
* Adaptateur d'infrastructure : implémente le port SessionRepository en utilisant
|
||||||
@@ -38,14 +37,14 @@ public class PostgresSessionRepository implements SessionRepository {
|
|||||||
public List<Session> findAll() {
|
public List<Session> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Session> findByPlaythroughId(String playthroughId) {
|
public List<Session> findByPlaythroughId(String playthroughId) {
|
||||||
return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream()
|
return jpaRepository.findByPlaythroughIdOrderByStartedAtDesc(Long.parseLong(playthroughId)).stream()
|
||||||
.map(this::toDomain)
|
.map(this::toDomain)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import org.springframework.stereotype.Repository;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adaptateur d'infrastructure qui implémente le Port TemplateRepository.
|
* Adaptateur d'infrastructure qui implémente le Port TemplateRepository.
|
||||||
@@ -40,14 +39,14 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
|||||||
public List<Template> findAll() {
|
public List<Template> findAll() {
|
||||||
return jpaRepository.findAll().stream()
|
return jpaRepository.findAll().stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<Template> findByLoreId(String loreId) {
|
public List<Template> findByLoreId(String loreId) {
|
||||||
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
return jpaRepository.findByLoreId(Long.parseLong(loreId)).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -64,7 +63,7 @@ public class PostgresTemplateRepository implements TemplateRepository {
|
|||||||
public List<Template> searchByName(String query) {
|
public List<Template> searchByName(String query) {
|
||||||
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
return jpaRepository.findByNameContainingIgnoreCase(query).stream()
|
||||||
.map(this::toDomainEntity)
|
.map(this::toDomainEntity)
|
||||||
.collect(Collectors.toList());
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Mapping ----------------------------------------------------------
|
// --- Mapping ----------------------------------------------------------
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.loremind.infrastructure.storage;
|
package com.loremind.infrastructure.storage;
|
||||||
|
|
||||||
import com.loremind.domain.files.ports.FileStorage;
|
import com.loremind.domain.files.ports.FileStorage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -26,6 +28,8 @@ import java.util.UUID;
|
|||||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
||||||
public class FilesystemFileStorageAdapter implements FileStorage {
|
public class FilesystemFileStorageAdapter implements FileStorage {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FilesystemFileStorageAdapter.class);
|
||||||
|
|
||||||
private final Path root;
|
private final Path root;
|
||||||
|
|
||||||
public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
||||||
@@ -36,7 +40,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
|
|||||||
void ensureRootExists() {
|
void ensureRootExists() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(root.resolve("files"));
|
Files.createDirectories(root.resolve("files"));
|
||||||
System.out.println("[Storage] Backend filesystem (fichiers) actif — racine : " + root);
|
log.info("[Storage] Backend filesystem (fichiers) actif — racine : {}", root);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
|
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
|
||||||
}
|
}
|
||||||
@@ -84,7 +88,7 @@ public class FilesystemFileStorageAdapter implements FileStorage {
|
|||||||
try {
|
try {
|
||||||
Files.deleteIfExists(resolveKey(storageKey));
|
Files.deleteIfExists(resolveKey(storageKey));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
System.err.println("[Storage] Erreur suppression fichier (non bloquante) : " + e.getMessage());
|
log.warn("[Storage] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.loremind.infrastructure.storage;
|
package com.loremind.infrastructure.storage;
|
||||||
|
|
||||||
import com.loremind.domain.images.ports.ImageStorage;
|
import com.loremind.domain.images.ports.ImageStorage;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -31,6 +33,8 @@ import java.util.UUID;
|
|||||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem")
|
||||||
public class FilesystemImageStorageAdapter implements ImageStorage {
|
public class FilesystemImageStorageAdapter implements ImageStorage {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FilesystemImageStorageAdapter.class);
|
||||||
|
|
||||||
private final Path root;
|
private final Path root;
|
||||||
|
|
||||||
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
public FilesystemImageStorageAdapter(@Value("${storage.filesystem.path}") String basePath) {
|
||||||
@@ -41,7 +45,7 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
|
|||||||
void ensureRootExists() {
|
void ensureRootExists() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(root);
|
Files.createDirectories(root);
|
||||||
System.out.println("[Storage] Backend filesystem actif — racine : " + root);
|
log.info("[Storage] Backend filesystem actif — racine : {}", root);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
|
throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e);
|
||||||
}
|
}
|
||||||
@@ -92,7 +96,7 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
|
|||||||
Files.deleteIfExists(resolveKey(storageKey));
|
Files.deleteIfExists(resolveKey(storageKey));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
// Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO).
|
// Suppression idempotente : on loggue mais on ne propage pas (cf. MinIO).
|
||||||
System.err.println("[Storage] Erreur suppression (non bloquante) : " + e.getMessage());
|
log.warn("[Storage] Erreur suppression (non bloquante) : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import io.minio.MinioClient;
|
|||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
import io.minio.RemoveObjectArgs;
|
import io.minio.RemoveObjectArgs;
|
||||||
import io.minio.errors.ErrorResponseException;
|
import io.minio.errors.ErrorResponseException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -24,6 +26,8 @@ import java.util.UUID;
|
|||||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
||||||
public class MinioFileStorageAdapter implements FileStorage {
|
public class MinioFileStorageAdapter implements FileStorage {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MinioFileStorageAdapter.class);
|
||||||
|
|
||||||
private final MinioClient minioClient;
|
private final MinioClient minioClient;
|
||||||
private final String bucket;
|
private final String bucket;
|
||||||
|
|
||||||
@@ -90,7 +94,7 @@ public class MinioFileStorageAdapter implements FileStorage {
|
|||||||
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build()
|
||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("[MinIO] Erreur suppression fichier (non bloquante) : " + e.getMessage());
|
log.warn("[MinIO] Erreur suppression fichier (non bloquante) : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import io.minio.MinioClient;
|
|||||||
import io.minio.PutObjectArgs;
|
import io.minio.PutObjectArgs;
|
||||||
import io.minio.RemoveObjectArgs;
|
import io.minio.RemoveObjectArgs;
|
||||||
import io.minio.errors.ErrorResponseException;
|
import io.minio.errors.ErrorResponseException;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -27,6 +29,8 @@ import java.util.UUID;
|
|||||||
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true)
|
||||||
public class MinioImageStorageAdapter implements ImageStorage {
|
public class MinioImageStorageAdapter implements ImageStorage {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MinioImageStorageAdapter.class);
|
||||||
|
|
||||||
private final MinioClient minioClient;
|
private final MinioClient minioClient;
|
||||||
private final String bucket;
|
private final String bucket;
|
||||||
|
|
||||||
@@ -95,7 +99,7 @@ public class MinioImageStorageAdapter implements ImageStorage {
|
|||||||
);
|
);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// Suppression idempotente : on loggue mais on ne propage pas.
|
// Suppression idempotente : on loggue mais on ne propage pas.
|
||||||
System.err.println("[MinIO] Erreur suppression (non bloquante) : " + e.getMessage());
|
log.warn("[MinIO] Erreur suppression (non bloquante) : {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,19 @@ class CampaignContentInserter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
|
insertCampaigns(export, maps, result);
|
||||||
|
insertArcs(export, maps, result);
|
||||||
|
insertItemCatalogs(export, maps, result);
|
||||||
|
insertRandomTables(export, maps, result);
|
||||||
|
insertChapters(export, maps, result);
|
||||||
|
insertQuests(export, maps, result);
|
||||||
|
insertNpcs(export, maps, result);
|
||||||
|
insertEnemies(export, maps, result);
|
||||||
|
insertScenes(export, maps, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Campaign (loreId/gameSystemId String remappes en 2e passe). */
|
||||||
|
private void insertCampaigns(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
|
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
|
||||||
CampaignJpaEntity e = new CampaignJpaEntity();
|
CampaignJpaEntity e = new CampaignJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -90,8 +102,10 @@ class CampaignContentInserter {
|
|||||||
maps.campaignMap.put(d.id(), campaignRepo.save(e).getId());
|
maps.campaignMap.put(d.id(), campaignRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
result.count("campaigns", maps.campaignMap.size());
|
result.count("campaigns", maps.campaignMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- Arc (relatedPageIds remappe en 2e passe)
|
/** Arc (relatedPageIds remappe en 2e passe). */
|
||||||
|
private void insertArcs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
|
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
|
||||||
ArcJpaEntity e = new ArcJpaEntity();
|
ArcJpaEntity e = new ArcJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -110,9 +124,12 @@ class CampaignContentInserter {
|
|||||||
maps.arcMap.put(d.id(), arcRepo.save(e).getId());
|
maps.arcMap.put(d.id(), arcRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
result.count("arcs", maps.arcMap.size());
|
result.count("arcs", maps.arcMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- ItemCatalog (+ items en cascade)
|
/** ItemCatalog (+ items en cascade). */
|
||||||
int catalogCount = 0, itemCount = 0;
|
private void insertItemCatalogs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
|
int catalogCount = 0;
|
||||||
|
int itemCount = 0;
|
||||||
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
|
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
|
||||||
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
|
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -138,9 +155,12 @@ class CampaignContentInserter {
|
|||||||
}
|
}
|
||||||
result.count("itemCatalogs", catalogCount);
|
result.count("itemCatalogs", catalogCount);
|
||||||
result.count("catalogItems", itemCount);
|
result.count("catalogItems", itemCount);
|
||||||
|
}
|
||||||
|
|
||||||
// -- RandomTable (+ entries en cascade)
|
/** RandomTable (+ entries en cascade). */
|
||||||
int tableCount = 0, entryCount = 0;
|
private void insertRandomTables(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
|
int tableCount = 0;
|
||||||
|
int entryCount = 0;
|
||||||
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
|
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
|
||||||
RandomTableJpaEntity e = new RandomTableJpaEntity();
|
RandomTableJpaEntity e = new RandomTableJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -167,9 +187,13 @@ class CampaignContentInserter {
|
|||||||
}
|
}
|
||||||
result.count("randomTables", tableCount);
|
result.count("randomTables", tableCount);
|
||||||
result.count("randomTableEntries", entryCount);
|
result.count("randomTableEntries", entryCount);
|
||||||
|
}
|
||||||
|
|
||||||
// -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
|
/**
|
||||||
// prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
|
* Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de
|
||||||
|
* prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy.
|
||||||
|
*/
|
||||||
|
private void insertChapters(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
|
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
|
||||||
ChapterJpaEntity e = new ChapterJpaEntity();
|
ChapterJpaEntity e = new ChapterJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -185,9 +209,16 @@ class CampaignContentInserter {
|
|||||||
maps.chapterMap.put(d.id(), chapterRepo.save(e).getId());
|
maps.chapterMap.put(d.id(), chapterRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
result.count("chapters", maps.chapterMap.size());
|
result.count("chapters", maps.chapterMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
|
/**
|
||||||
// prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
|
* Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ;
|
||||||
|
* prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt).
|
||||||
|
* Sans champ quests (bundle legacy) : conversion des chapitres qui jouaient le rôle
|
||||||
|
* de quête — questMap est alors clé par ANCIEN CHAPTER id (pas de collision : on
|
||||||
|
* compare chapter-id à chapter-id).
|
||||||
|
*/
|
||||||
|
private void insertQuests(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.QuestDto d : nullSafe(export.quests())) {
|
for (ContentExport.QuestDto d : nullSafe(export.quests())) {
|
||||||
QuestJpaEntity e = new QuestJpaEntity();
|
QuestJpaEntity e = new QuestJpaEntity();
|
||||||
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
|
e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId()));
|
||||||
@@ -206,15 +237,14 @@ class CampaignContentInserter {
|
|||||||
maps.questMap.put(d.id(), questRepo.save(e).getId());
|
maps.questMap.put(d.id(), questRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Quest legacy (bundle SANS champ quests) : conversion des chapitres qui jouaient
|
|
||||||
// le rôle de quête. Ici questMap est clé par ANCIEN CHAPTER id (pas de collision :
|
|
||||||
// on compare chapter-id à chapter-id).
|
|
||||||
if (export.quests() == null) {
|
if (export.quests() == null) {
|
||||||
legacyQuestConverter.convertLegacyChaptersToQuests(export, maps);
|
legacyQuestConverter.convertLegacyChaptersToQuests(export, maps);
|
||||||
}
|
}
|
||||||
result.count("quests", maps.questMap.size());
|
result.count("quests", maps.questMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- Npc (relatedPageIds remappe en 2e passe)
|
/** Npc (relatedPageIds remappe en 2e passe). */
|
||||||
|
private void insertNpcs(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
|
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
|
||||||
NpcJpaEntity e = new NpcJpaEntity();
|
NpcJpaEntity e = new NpcJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -230,8 +260,10 @@ class CampaignContentInserter {
|
|||||||
maps.npcMap.put(d.id(), npcRepo.save(e).getId());
|
maps.npcMap.put(d.id(), npcRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
result.count("npcs", maps.npcMap.size());
|
result.count("npcs", maps.npcMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- Enemy
|
/** Enemy. */
|
||||||
|
private void insertEnemies(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
|
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
|
||||||
EnemyJpaEntity e = new EnemyJpaEntity();
|
EnemyJpaEntity e = new EnemyJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -249,8 +281,10 @@ class CampaignContentInserter {
|
|||||||
maps.enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
maps.enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
||||||
}
|
}
|
||||||
result.count("enemies", maps.enemyMap.size());
|
result.count("enemies", maps.enemyMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
|
/** Scene (enemyIds + relatedPageIds + branches remappes en 2e passe). */
|
||||||
|
private void insertScenes(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
|
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
|
||||||
SceneJpaEntity e = new SceneJpaEntity();
|
SceneJpaEntity e = new SceneJpaEntity();
|
||||||
e.setName(d.name());
|
e.setName(d.name());
|
||||||
@@ -270,15 +304,7 @@ class CampaignContentInserter {
|
|||||||
e.setEnemyIds(d.enemyIds()); // remappe en 2e passe
|
e.setEnemyIds(d.enemyIds()); // remappe en 2e passe
|
||||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
|
e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe
|
||||||
e.setIllustrationImageIds(IdRemapper.remapStringList(maps.imageMap, d.illustrationImageIds()));
|
e.setIllustrationImageIds(IdRemapper.remapStringList(maps.imageMap, d.illustrationImageIds()));
|
||||||
// Battlemaps : ids StoredFile remappes via storedFileMap. Les exports anterieurs
|
e.setBattlemaps(resolveBattlemaps(d, maps));
|
||||||
// a V22 portaient une paire unique -> reconstituee en premiere entree de liste.
|
|
||||||
List<SceneBattlemap> battlemaps = d.battlemaps();
|
|
||||||
if ((battlemaps == null || battlemaps.isEmpty())
|
|
||||||
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
|
|
||||||
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
|
|
||||||
}
|
|
||||||
battlemaps = IdRemapper.remapBattlemaps(maps.storedFileMap, battlemaps);
|
|
||||||
e.setBattlemaps(battlemaps != null ? battlemaps : List.of());
|
|
||||||
e.setGraphX(d.graphX());
|
e.setGraphX(d.graphX());
|
||||||
e.setGraphY(d.graphY());
|
e.setGraphY(d.graphY());
|
||||||
e.setBranches(d.branches()); // remappe en 2e passe
|
e.setBranches(d.branches()); // remappe en 2e passe
|
||||||
@@ -290,6 +316,20 @@ class CampaignContentInserter {
|
|||||||
result.count("scenes", maps.sceneMap.size());
|
result.count("scenes", maps.sceneMap.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Battlemaps d'une scène, ids StoredFile remappes via storedFileMap. Les exports
|
||||||
|
* anterieurs a V22 portaient une paire unique -> reconstituee en premiere entree de liste.
|
||||||
|
*/
|
||||||
|
private static List<SceneBattlemap> resolveBattlemaps(ContentExport.SceneDto d, ImportIdMaps maps) {
|
||||||
|
List<SceneBattlemap> battlemaps = d.battlemaps();
|
||||||
|
if ((battlemaps == null || battlemaps.isEmpty())
|
||||||
|
&& (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) {
|
||||||
|
battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId()));
|
||||||
|
}
|
||||||
|
battlemaps = IdRemapper.remapBattlemaps(maps.storedFileMap, battlemaps);
|
||||||
|
return battlemaps != null ? battlemaps : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
private static <T> List<T> nullSafe(List<T> list) {
|
private static <T> List<T> nullSafe(List<T> list) {
|
||||||
return list != null ? list : List.of();
|
return list != null ? list : List.of();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,30 +191,96 @@ public class ExportService {
|
|||||||
CampaignJpaEntity campaign = campaignRepo.findById(cid)
|
CampaignJpaEntity campaign = campaignRepo.findById(cid)
|
||||||
.orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid));
|
.orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid));
|
||||||
|
|
||||||
// Prep : clôture structurelle de la campagne.
|
StructuralClosure structure = loadStructuralClosure(cid);
|
||||||
|
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
|
||||||
|
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
|
||||||
|
LoreClosure lore = loadLoreClosure(campaign, req);
|
||||||
|
PlayClosure play = loadPlayClosure(cid, req);
|
||||||
|
// Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture.
|
||||||
|
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid);
|
||||||
|
// Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active).
|
||||||
|
BinaryClosure binaries = req.includeImages()
|
||||||
|
? loadBinaryClosure(structure, lore, play, campaignQuests) : BinaryClosure.EMPTY;
|
||||||
|
|
||||||
|
ContentExport.CampaignDto campaignDto = campaignDto(campaign, req.includeLore());
|
||||||
|
|
||||||
|
ContentExport.Manifest manifest =
|
||||||
|
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName());
|
||||||
|
return new ContentExport(manifest,
|
||||||
|
map(gsEntities, this::toGameSystemDto),
|
||||||
|
map(lore.lores(), this::toLoreDto),
|
||||||
|
map(lore.loreNodes(), this::toLoreNodeDto),
|
||||||
|
map(lore.templates(), this::toTemplateDto),
|
||||||
|
map(lore.pages(), this::toPageDto),
|
||||||
|
List.of(campaignDto),
|
||||||
|
map(structure.arcs(), this::toArcDto),
|
||||||
|
map(structure.chapters(), this::toChapterDto),
|
||||||
|
map(structure.scenes(), this::toSceneDto),
|
||||||
|
map(play.characters(), this::toCharacterDto),
|
||||||
|
map(structure.npcs(), this::toNpcDto),
|
||||||
|
map(structure.enemies(), this::toEnemyDto),
|
||||||
|
map(structure.catalogs(), this::toItemCatalogDto),
|
||||||
|
map(structure.tables(), this::toRandomTableDto),
|
||||||
|
map(binaries.images(), this::toImageDto),
|
||||||
|
map(binaries.files(), this::toStoredFileDto),
|
||||||
|
map(play.playthroughs(), this::toPlaythroughDto),
|
||||||
|
map(play.sessions(), this::toSessionDto),
|
||||||
|
map(play.entries(), this::toSessionEntryDto),
|
||||||
|
map(play.flags(), this::toFlagDto),
|
||||||
|
map(play.questProgressions(), this::toQuestProgressionDto),
|
||||||
|
map(campaignQuests, this::toQuestDto),
|
||||||
|
map(play.clocks(), this::toClockDto),
|
||||||
|
map(play.fronts(), this::toFrontDto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clôture structurelle de la campagne : arcs -> chapitres -> scènes, PNJ, ennemis, catalogues, tables. */
|
||||||
|
private record StructuralClosure(
|
||||||
|
List<ArcJpaEntity> arcs, List<ChapterJpaEntity> chapters, List<SceneJpaEntity> scenes,
|
||||||
|
List<NpcJpaEntity> npcs, List<EnemyJpaEntity> enemies,
|
||||||
|
List<ItemCatalogJpaEntity> catalogs, List<RandomTableJpaEntity> tables) {}
|
||||||
|
|
||||||
|
private StructuralClosure loadStructuralClosure(Long cid) {
|
||||||
List<ArcJpaEntity> arcEntities = arcRepo.findByCampaignId(cid);
|
List<ArcJpaEntity> arcEntities = arcRepo.findByCampaignId(cid);
|
||||||
List<ChapterJpaEntity> chapterEntities = arcEntities.stream()
|
List<ChapterJpaEntity> chapterEntities = arcEntities.stream()
|
||||||
.flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
|
.flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
|
||||||
List<SceneJpaEntity> sceneEntities = chapterEntities.stream()
|
List<SceneJpaEntity> sceneEntities = chapterEntities.stream()
|
||||||
.flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
|
.flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
|
||||||
List<NpcJpaEntity> npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid);
|
return new StructuralClosure(arcEntities, chapterEntities, sceneEntities,
|
||||||
List<EnemyJpaEntity> enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
|
npcRepo.findByCampaignIdOrderByOrderAsc(cid), enemyRepo.findByCampaignIdOrderByOrderAsc(cid),
|
||||||
List<ItemCatalogJpaEntity> catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid);
|
itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid), randomTableRepo.findByCampaignIdOrderByOrderAsc(cid));
|
||||||
List<RandomTableJpaEntity> tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid);
|
}
|
||||||
|
|
||||||
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
|
/** Univers (lore) lié à la campagne : optionnel selon {@code req.includeLore()}. */
|
||||||
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
|
private record LoreClosure(
|
||||||
|
List<LoreJpaEntity> lores, List<LoreNodeJpaEntity> loreNodes,
|
||||||
|
List<TemplateJpaEntity> templates, List<PageJpaEntity> pages) {
|
||||||
|
private static final LoreClosure EMPTY = new LoreClosure(List.of(), List.of(), List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
// Univers (lore) lié : optionnel.
|
private LoreClosure loadLoreClosure(CampaignJpaEntity campaign, ExportRequest req) {
|
||||||
Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null;
|
Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null;
|
||||||
List<LoreJpaEntity> loreEntities = lid != null ? singleton(loreRepo, lid) : List.of();
|
if (lid == null) return LoreClosure.EMPTY;
|
||||||
List<LoreNodeJpaEntity> loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of();
|
return new LoreClosure(singleton(loreRepo, lid), loreNodeRepo.findByLoreId(lid),
|
||||||
List<TemplateJpaEntity> templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of();
|
templateRepo.findByLoreId(lid), pageRepo.findByLoreId(lid));
|
||||||
List<PageJpaEntity> pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of();
|
}
|
||||||
|
|
||||||
// Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie,
|
/**
|
||||||
// donc « sans jeu » = sans feuilles de perso.
|
* Espace de jeu (parties -> séances/journal/flags/quêtes + feuilles de perso) : optionnel
|
||||||
List<PlaythroughJpaEntity> ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of();
|
* selon {@code req.includePlay()}. Les feuilles de perso appartiennent à une Partie, donc
|
||||||
|
* « sans jeu » = sans feuilles de perso.
|
||||||
|
*/
|
||||||
|
private record PlayClosure(
|
||||||
|
List<PlaythroughJpaEntity> playthroughs, List<SessionJpaEntity> sessions,
|
||||||
|
List<SessionEntryJpaEntity> entries, List<PlaythroughFlagJpaEntity> flags,
|
||||||
|
List<QuestProgressionJpaEntity> questProgressions, List<ClockJpaEntity> clocks,
|
||||||
|
List<FrontJpaEntity> fronts, List<CharacterJpaEntity> characters) {
|
||||||
|
private static final PlayClosure EMPTY = new PlayClosure(
|
||||||
|
List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayClosure loadPlayClosure(Long cid, ExportRequest req) {
|
||||||
|
if (!req.includePlay()) return PlayClosure.EMPTY;
|
||||||
|
List<PlaythroughJpaEntity> ptEntities = playthroughRepo.findByCampaignId(cid);
|
||||||
List<SessionJpaEntity> sessionEntities = ptEntities.stream()
|
List<SessionJpaEntity> sessionEntities = ptEntities.stream()
|
||||||
.flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
|
.flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
|
||||||
List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream()
|
List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream()
|
||||||
@@ -229,74 +295,52 @@ public class ExportService {
|
|||||||
.flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
|
.flatMap(p -> frontRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
|
||||||
List<CharacterJpaEntity> characterEntities = ptEntities.stream()
|
List<CharacterJpaEntity> characterEntities = ptEntities.stream()
|
||||||
.flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
|
.flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
|
||||||
|
return new PlayClosure(ptEntities, sessionEntities, entryEntities, flagEntities,
|
||||||
|
questEntities, clockEntities, frontEntities, characterEntities);
|
||||||
|
}
|
||||||
|
|
||||||
// Quêtes de la campagne (Niveau 1) — toujours incluses dans la clôture.
|
/** Binaires (images/fichiers) référencés par la clôture exportée. */
|
||||||
List<QuestJpaEntity> campaignQuests = questRepo.findByCampaignId(cid);
|
private record BinaryClosure(List<ImageJpaEntity> images, List<StoredFileJpaEntity> files) {
|
||||||
|
private static final BinaryClosure EMPTY = new BinaryClosure(List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
// Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active).
|
private BinaryClosure loadBinaryClosure(StructuralClosure structure, LoreClosure lore, PlayClosure play,
|
||||||
List<ImageJpaEntity> imageEntities = List.of();
|
List<QuestJpaEntity> campaignQuests) {
|
||||||
List<StoredFileJpaEntity> fileEntities = List.of();
|
Set<String> imageRefs = new LinkedHashSet<>();
|
||||||
if (req.includeImages()) {
|
structure.arcs().forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
|
||||||
Set<String> imageRefs = new LinkedHashSet<>();
|
structure.chapters().forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
|
||||||
arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
|
campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds()));
|
||||||
chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
|
structure.scenes().forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
|
||||||
campaignQuests.forEach(q -> addAll(imageRefs, q.getIllustrationImageIds()));
|
structure.scenes().forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
|
||||||
sceneEntities.forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
|
structure.npcs().forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
|
||||||
sceneEntities.forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
|
structure.enemies().forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
|
||||||
npcEntities.forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
|
play.characters().forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
|
||||||
enemyEntities.forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
|
lore.pages().forEach(p -> addImageValues(imageRefs, p.getImageValues()));
|
||||||
characterEntities.forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
|
List<ImageJpaEntity> imageEntities = imageRefs.stream()
|
||||||
pageEntities.forEach(p -> addImageValues(imageRefs, p.getImageValues()));
|
.map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
|
||||||
imageEntities = imageRefs.stream()
|
.map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
|
||||||
.map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
|
.distinct().toList();
|
||||||
.map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
|
|
||||||
.distinct().toList();
|
|
||||||
|
|
||||||
Set<Long> fileRefs = new LinkedHashSet<>();
|
Set<Long> fileRefs = new LinkedHashSet<>();
|
||||||
sceneEntities.forEach(s -> {
|
structure.scenes().forEach(s -> {
|
||||||
if (s.getBattlemaps() == null) return;
|
if (s.getBattlemaps() == null) return;
|
||||||
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
|
s.getBattlemaps().forEach(bm -> { addLong(fileRefs, bm.mediaFileId()); addLong(fileRefs, bm.dataFileId()); });
|
||||||
});
|
});
|
||||||
fileEntities = fileRefs.stream()
|
List<StoredFileJpaEntity> fileEntities = fileRefs.stream()
|
||||||
.map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
|
.map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
|
||||||
}
|
|
||||||
|
|
||||||
// Campaign DTO : si le lore n'est pas exporté, on neutralise loreId (évite une
|
return new BinaryClosure(imageEntities, fileEntities);
|
||||||
// référence pendante vers un univers absent à l'import).
|
}
|
||||||
ContentExport.CampaignDto campaignDto = toCampaignDto(campaign);
|
|
||||||
if (!req.includeLore()) {
|
|
||||||
campaignDto = new ContentExport.CampaignDto(campaignDto.id(), campaignDto.name(),
|
|
||||||
campaignDto.description(), campaignDto.arcsCount(), campaignDto.playerCount(),
|
|
||||||
null, campaignDto.gameSystemId());
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentExport.Manifest manifest =
|
/**
|
||||||
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName());
|
* DTO Campaign : si le lore n'est pas exporté, on neutralise loreId (évite une
|
||||||
return new ContentExport(manifest,
|
* référence pendante vers un univers absent à l'import).
|
||||||
map(gsEntities, this::toGameSystemDto),
|
*/
|
||||||
map(loreEntities, this::toLoreDto),
|
private ContentExport.CampaignDto campaignDto(CampaignJpaEntity campaign, boolean includeLore) {
|
||||||
map(loreNodeEntities, this::toLoreNodeDto),
|
ContentExport.CampaignDto dto = toCampaignDto(campaign);
|
||||||
map(templateEntities, this::toTemplateDto),
|
if (includeLore) return dto;
|
||||||
map(pageEntities, this::toPageDto),
|
return new ContentExport.CampaignDto(dto.id(), dto.name(), dto.description(),
|
||||||
List.of(campaignDto),
|
dto.arcsCount(), dto.playerCount(), null, dto.gameSystemId());
|
||||||
map(arcEntities, this::toArcDto),
|
|
||||||
map(chapterEntities, this::toChapterDto),
|
|
||||||
map(sceneEntities, this::toSceneDto),
|
|
||||||
map(characterEntities, this::toCharacterDto),
|
|
||||||
map(npcEntities, this::toNpcDto),
|
|
||||||
map(enemyEntities, this::toEnemyDto),
|
|
||||||
map(catalogEntities, this::toItemCatalogDto),
|
|
||||||
map(tableEntities, this::toRandomTableDto),
|
|
||||||
map(imageEntities, this::toImageDto),
|
|
||||||
map(fileEntities, this::toStoredFileDto),
|
|
||||||
map(ptEntities, this::toPlaythroughDto),
|
|
||||||
map(sessionEntities, this::toSessionDto),
|
|
||||||
map(entryEntities, this::toSessionEntryDto),
|
|
||||||
map(flagEntities, this::toFlagDto),
|
|
||||||
map(questEntities, this::toQuestProgressionDto),
|
|
||||||
map(campaignQuests, this::toQuestDto),
|
|
||||||
map(clockEntities, this::toClockDto),
|
|
||||||
map(frontEntities, this::toFrontDto));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Helpers de chargement -----
|
// ----- Helpers de chargement -----
|
||||||
@@ -345,44 +389,63 @@ public class ExportService {
|
|||||||
zip.closeEntry();
|
zip.closeEntry();
|
||||||
|
|
||||||
// Binaires images : uniquement ceux reellement references.
|
// Binaires images : uniquement ceux reellement references.
|
||||||
Set<String> referenced = collectReferencedStorageKeys(export);
|
writeImageBinaries(zip, collectReferencedStorageKeys(export));
|
||||||
Set<String> written = new LinkedHashSet<>();
|
|
||||||
for (String key : referenced) {
|
|
||||||
if (key == null || key.isBlank() || !written.add(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try (InputStream data = imageStorage.download(key)) {
|
|
||||||
if (data == null) {
|
|
||||||
continue; // cle orpheline : on ignore silencieusement
|
|
||||||
}
|
|
||||||
zip.putNextEntry(new ZipEntry("images/" + key));
|
|
||||||
data.transferTo(zip);
|
|
||||||
zip.closeEntry();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binaires fichiers (battlemaps : media + sidecar) : ceux references par
|
// Binaires fichiers (battlemaps : media + sidecar) : ceux references par
|
||||||
// les scenes. Stockes a part sous "files/<storageKey>".
|
// les scenes. Stockes a part sous "files/<storageKey>".
|
||||||
Set<String> referencedFiles = collectReferencedFileStorageKeys(export);
|
writeFileBinaries(zip, collectReferencedFileStorageKeys(export));
|
||||||
Set<String> filesWritten = new LinkedHashSet<>();
|
|
||||||
for (String key : referencedFiles) {
|
|
||||||
if (key == null || key.isBlank() || !filesWritten.add(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try (InputStream data = fileStorage.download(key)) {
|
|
||||||
if (data == null) {
|
|
||||||
continue; // cle orpheline : on ignore silencieusement
|
|
||||||
}
|
|
||||||
zip.putNextEntry(new ZipEntry("files/" + key));
|
|
||||||
data.transferTo(zip);
|
|
||||||
zip.closeEntry();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Echec de la generation du zip d'export", e);
|
throw new UncheckedIOException("Echec de la generation du zip d'export", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ecrit un binaire d'image par cle REFERENCEE (deduplique, cles orphelines/vides ignorees). */
|
||||||
|
private void writeImageBinaries(ZipOutputStream zip, Set<String> referenced) throws IOException {
|
||||||
|
Set<String> written = new LinkedHashSet<>();
|
||||||
|
for (String key : referenced) {
|
||||||
|
if (isWritable(key, written)) {
|
||||||
|
writeImageEntry(zip, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeImageEntry(ZipOutputStream zip, String key) throws IOException {
|
||||||
|
try (InputStream data = imageStorage.download(key)) {
|
||||||
|
if (data == null) {
|
||||||
|
return; // cle orpheline : on ignore silencieusement
|
||||||
|
}
|
||||||
|
zip.putNextEntry(new ZipEntry("images/" + key));
|
||||||
|
data.transferTo(zip);
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ecrit un binaire de fichier (battlemap) par cle REFERENCEE (deduplique, cles orphelines/vides ignorees). */
|
||||||
|
private void writeFileBinaries(ZipOutputStream zip, Set<String> referenced) throws IOException {
|
||||||
|
Set<String> written = new LinkedHashSet<>();
|
||||||
|
for (String key : referenced) {
|
||||||
|
if (isWritable(key, written)) {
|
||||||
|
writeFileEntry(zip, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeFileEntry(ZipOutputStream zip, String key) throws IOException {
|
||||||
|
try (InputStream data = fileStorage.download(key)) {
|
||||||
|
if (data == null) {
|
||||||
|
return; // cle orpheline : on ignore silencieusement
|
||||||
|
}
|
||||||
|
zip.putNextEntry(new ZipEntry("files/" + key));
|
||||||
|
data.transferTo(zip);
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai si la cle est ecrivable : non nulle/vide, et pas deja ecrite (marque au passage). */
|
||||||
|
private static boolean isWritable(String key, Set<String> written) {
|
||||||
|
return key != null && !key.isBlank() && written.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collecte tous les storageKeys references par le contenu exporte :
|
* Collecte tous les storageKeys references par le contenu exporte :
|
||||||
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
|
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
|
||||||
@@ -392,35 +455,58 @@ public class ExportService {
|
|||||||
// Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload),
|
// Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload),
|
||||||
// PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images
|
// PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images
|
||||||
// exportées — même logique que collectReferencedFileStorageKeys pour les fichiers.
|
// exportées — même logique que collectReferencedFileStorageKeys pour les fichiers.
|
||||||
|
java.util.Map<String, String> keyByImageId = indexImagesById(export);
|
||||||
|
Set<String> refs = collectImageIdRefs(export);
|
||||||
|
return resolveStorageKeys(refs, keyByImageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static java.util.Map<String, String> indexImagesById(ContentExport export) {
|
||||||
java.util.Map<String, String> keyByImageId = new java.util.HashMap<>();
|
java.util.Map<String, String> keyByImageId = new java.util.HashMap<>();
|
||||||
for (ContentExport.ImageDto img : export.images()) {
|
for (ContentExport.ImageDto img : export.images()) {
|
||||||
if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey());
|
if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey());
|
||||||
}
|
}
|
||||||
|
return keyByImageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collecte les IDs d'images référencés par toutes les entités exportées (avant résolution en storageKey). */
|
||||||
|
private Set<String> collectImageIdRefs(ContentExport export) {
|
||||||
Set<String> refs = new LinkedHashSet<>();
|
Set<String> refs = new LinkedHashSet<>();
|
||||||
for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
|
for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
|
||||||
for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
|
for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
|
||||||
if (export.quests() != null) {
|
addQuestImageRefs(refs, export.quests());
|
||||||
for (ContentExport.QuestDto q : export.quests()) addAll(refs, q.illustrationImageIds());
|
|
||||||
}
|
|
||||||
for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds());
|
for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds());
|
||||||
for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms());
|
for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms());
|
||||||
for (ContentExport.CharacterDto c : export.characters()) {
|
for (ContentExport.CharacterDto c : export.characters()) addCharacterImageRefs(refs, c);
|
||||||
add(refs, c.portraitImageId());
|
for (ContentExport.NpcDto n : export.npcs()) addNpcImageRefs(refs, n);
|
||||||
add(refs, c.headerImageId());
|
for (ContentExport.EnemyDto e : export.enemies()) addEnemyImageRefs(refs, e);
|
||||||
addImageValues(refs, c.imageValues());
|
|
||||||
}
|
|
||||||
for (ContentExport.NpcDto n : export.npcs()) {
|
|
||||||
add(refs, n.portraitImageId());
|
|
||||||
add(refs, n.headerImageId());
|
|
||||||
addImageValues(refs, n.imageValues());
|
|
||||||
}
|
|
||||||
for (ContentExport.EnemyDto e : export.enemies()) {
|
|
||||||
add(refs, e.portraitImageId());
|
|
||||||
add(refs, e.headerImageId());
|
|
||||||
addImageValues(refs, e.imageValues());
|
|
||||||
}
|
|
||||||
for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues());
|
for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues());
|
||||||
|
return refs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addQuestImageRefs(Set<String> refs, List<ContentExport.QuestDto> quests) {
|
||||||
|
if (quests == null) return;
|
||||||
|
for (ContentExport.QuestDto q : quests) addAll(refs, q.illustrationImageIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCharacterImageRefs(Set<String> refs, ContentExport.CharacterDto c) {
|
||||||
|
add(refs, c.portraitImageId());
|
||||||
|
add(refs, c.headerImageId());
|
||||||
|
addImageValues(refs, c.imageValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addNpcImageRefs(Set<String> refs, ContentExport.NpcDto n) {
|
||||||
|
add(refs, n.portraitImageId());
|
||||||
|
add(refs, n.headerImageId());
|
||||||
|
addImageValues(refs, n.imageValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addEnemyImageRefs(Set<String> refs, ContentExport.EnemyDto e) {
|
||||||
|
add(refs, e.portraitImageId());
|
||||||
|
add(refs, e.headerImageId());
|
||||||
|
addImageValues(refs, e.imageValues());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> resolveStorageKeys(Set<String> refs, java.util.Map<String, String> keyByImageId) {
|
||||||
Set<String> keys = new LinkedHashSet<>();
|
Set<String> keys = new LinkedHashSet<>();
|
||||||
for (String ref : refs) {
|
for (String ref : refs) {
|
||||||
String key = keyByImageId.get(ref);
|
String key = keyByImageId.get(ref);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ final class IdRemapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
|
static List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
|
||||||
if (ids == null) return null;
|
if (ids == null) return List.of();
|
||||||
List<String> out = new ArrayList<>(ids.size());
|
List<String> out = new ArrayList<>(ids.size());
|
||||||
for (String id : ids) out.add(remapStringId(map, id));
|
for (String id : ids) out.add(remapStringId(map, id));
|
||||||
return out;
|
return out;
|
||||||
@@ -58,7 +58,7 @@ final class IdRemapper {
|
|||||||
*/
|
*/
|
||||||
static Map<String, List<String>> remapImageValues(Map<Long, Long> imageMap,
|
static Map<String, List<String>> remapImageValues(Map<Long, Long> imageMap,
|
||||||
Map<String, List<String>> imageValues) {
|
Map<String, List<String>> imageValues) {
|
||||||
if (imageValues == null) return null;
|
if (imageValues == null) return Map.of();
|
||||||
Map<String, List<String>> out = new LinkedHashMap<>();
|
Map<String, List<String>> out = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, List<String>> e : imageValues.entrySet()) {
|
for (Map.Entry<String, List<String>> e : imageValues.entrySet()) {
|
||||||
out.put(e.getKey(), remapStringList(imageMap, e.getValue()));
|
out.put(e.getKey(), remapStringList(imageMap, e.getValue()));
|
||||||
@@ -75,7 +75,7 @@ final class IdRemapper {
|
|||||||
*/
|
*/
|
||||||
static <V> Map<String, Map<String, V>> remapImageFraming(Map<Long, Long> imageMap,
|
static <V> Map<String, Map<String, V>> remapImageFraming(Map<Long, Long> imageMap,
|
||||||
Map<String, Map<String, V>> framing) {
|
Map<String, Map<String, V>> framing) {
|
||||||
if (framing == null) return null;
|
if (framing == null) return Map.of();
|
||||||
Map<String, Map<String, V>> out = new LinkedHashMap<>();
|
Map<String, Map<String, V>> out = new LinkedHashMap<>();
|
||||||
for (Map.Entry<String, Map<String, V>> outer : framing.entrySet()) {
|
for (Map.Entry<String, Map<String, V>> outer : framing.entrySet()) {
|
||||||
Map<String, V> inner = outer.getValue();
|
Map<String, V> inner = outer.getValue();
|
||||||
@@ -98,7 +98,7 @@ final class IdRemapper {
|
|||||||
* mutable (Lombok), on réécrit EN PLACE — la liste vient d'une désérialisation jetable.
|
* mutable (Lombok), on réécrit EN PLACE — la liste vient d'une désérialisation jetable.
|
||||||
*/
|
*/
|
||||||
static List<Room> remapRoomImages(Map<Long, Long> imageMap, List<Room> rooms) {
|
static List<Room> remapRoomImages(Map<Long, Long> imageMap, List<Room> rooms) {
|
||||||
if (rooms == null) return null;
|
if (rooms == null) return List.of();
|
||||||
for (Room r : rooms) {
|
for (Room r : rooms) {
|
||||||
if (r == null) continue;
|
if (r == null) continue;
|
||||||
r.setIllustrationImageIds(remapStringList(imageMap, r.getIllustrationImageIds()));
|
r.setIllustrationImageIds(remapStringList(imageMap, r.getIllustrationImageIds()));
|
||||||
@@ -113,7 +113,7 @@ final class IdRemapper {
|
|||||||
* on reconstruit chaque entrée.
|
* on reconstruit chaque entrée.
|
||||||
*/
|
*/
|
||||||
static List<SceneBattlemap> remapBattlemaps(Map<Long, Long> fileMap, List<SceneBattlemap> battlemaps) {
|
static List<SceneBattlemap> remapBattlemaps(Map<Long, Long> fileMap, List<SceneBattlemap> battlemaps) {
|
||||||
if (battlemaps == null) return null;
|
if (battlemaps == null) return List.of();
|
||||||
List<SceneBattlemap> out = new ArrayList<>(battlemaps.size());
|
List<SceneBattlemap> out = new ArrayList<>(battlemaps.size());
|
||||||
for (SceneBattlemap bm : battlemaps) {
|
for (SceneBattlemap bm : battlemaps) {
|
||||||
out.add(new SceneBattlemap(bm.label(),
|
out.add(new SceneBattlemap(bm.label(),
|
||||||
@@ -129,7 +129,7 @@ final class IdRemapper {
|
|||||||
* Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1).
|
* Quête (v2), {@code chapterMap} pour des prérequis de Chapitre legacy (v1).
|
||||||
*/
|
*/
|
||||||
static List<Prerequisite> remapPrerequisites(Map<Long, Long> idMap, List<Prerequisite> prereqs) {
|
static List<Prerequisite> remapPrerequisites(Map<Long, Long> idMap, List<Prerequisite> prereqs) {
|
||||||
if (prereqs == null) return null;
|
if (prereqs == null) return List.of();
|
||||||
List<Prerequisite> out = new ArrayList<>(prereqs.size());
|
List<Prerequisite> out = new ArrayList<>(prereqs.size());
|
||||||
for (Prerequisite p : prereqs) {
|
for (Prerequisite p : prereqs) {
|
||||||
if (p instanceof Prerequisite.QuestCompleted qc) {
|
if (p instanceof Prerequisite.QuestCompleted qc) {
|
||||||
@@ -144,7 +144,7 @@ final class IdRemapper {
|
|||||||
/** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */
|
/** Remap des nœuds d'une quête : nodeId via {@code sceneMap} (SCENE) ou {@code chapterMap} (CHAPTER). */
|
||||||
static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap,
|
static List<QuestNodeRef> remapQuestNodes(Map<Long, Long> chapterMap, Map<Long, Long> sceneMap,
|
||||||
List<QuestNodeRef> nodes) {
|
List<QuestNodeRef> nodes) {
|
||||||
if (nodes == null) return null;
|
if (nodes == null) return List.of();
|
||||||
List<QuestNodeRef> out = new ArrayList<>(nodes.size());
|
List<QuestNodeRef> out = new ArrayList<>(nodes.size());
|
||||||
for (QuestNodeRef n : nodes) {
|
for (QuestNodeRef n : nodes) {
|
||||||
Map<Long, Long> map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap;
|
Map<Long, Long> map = (n.nodeType() == NodeType.SCENE) ? sceneMap : chapterMap;
|
||||||
@@ -154,7 +154,7 @@ final class IdRemapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
|
static List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
|
||||||
if (branches == null) return null;
|
if (branches == null) return List.of();
|
||||||
List<SceneBranch> out = new ArrayList<>(branches.size());
|
List<SceneBranch> out = new ArrayList<>(branches.size());
|
||||||
for (SceneBranch b : branches) {
|
for (SceneBranch b : branches) {
|
||||||
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind()));
|
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition(), b.kind()));
|
||||||
|
|||||||
@@ -52,41 +52,47 @@ class ImageImporter {
|
|||||||
ImportIdMaps maps,
|
ImportIdMaps maps,
|
||||||
ImportResult.Builder result) {
|
ImportResult.Builder result) {
|
||||||
for (ContentExport.ImageDto meta : nullSafe(export.images())) {
|
for (ContentExport.ImageDto meta : nullSafe(export.images())) {
|
||||||
String storageKey = meta.storageKey();
|
importImage(meta, imageBinaries, maps, result);
|
||||||
if (storageKey == null || storageKey.isBlank()) continue;
|
|
||||||
|
|
||||||
var existing = imageRepo.findByStorageKey(storageKey);
|
|
||||||
if (existing.isPresent()) {
|
|
||||||
// Image déjà présente : réutilisée (pas de réupload). L'ancien id pointe
|
|
||||||
// désormais la ligne existante.
|
|
||||||
mapId(maps, meta.id(), existing.get().getId());
|
|
||||||
result.imageReused();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] data = imageBinaries.get(storageKey);
|
|
||||||
if (data == null) {
|
|
||||||
// Métadonnée sans binaire (ex. image orpheline non embarquée) : rien à
|
|
||||||
// matérialiser, pas de cible de remap.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String contentType = meta.contentType() != null
|
|
||||||
? meta.contentType() : guessContentType(storageKey);
|
|
||||||
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
|
|
||||||
|
|
||||||
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
|
||||||
|
|
||||||
ImageJpaEntity e = new ImageJpaEntity();
|
|
||||||
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
|
|
||||||
e.setContentType(contentType);
|
|
||||||
e.setSizeBytes(size);
|
|
||||||
e.setStorageKey(storageKey);
|
|
||||||
mapId(maps, meta.id(), imageRepo.save(e).getId());
|
|
||||||
result.imageUploaded();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Traite une métadonnée d'image : réutilise la ligne existante, sinon matérialise le binaire. */
|
||||||
|
private void importImage(ContentExport.ImageDto meta, Map<String, byte[]> imageBinaries,
|
||||||
|
ImportIdMaps maps, ImportResult.Builder result) {
|
||||||
|
String storageKey = meta.storageKey();
|
||||||
|
if (storageKey == null || storageKey.isBlank()) return;
|
||||||
|
|
||||||
|
var existing = imageRepo.findByStorageKey(storageKey);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
// Image déjà présente : réutilisée (pas de réupload). L'ancien id pointe
|
||||||
|
// désormais la ligne existante.
|
||||||
|
mapId(maps, meta.id(), existing.get().getId());
|
||||||
|
result.imageReused();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] data = imageBinaries.get(storageKey);
|
||||||
|
if (data == null) {
|
||||||
|
// Métadonnée sans binaire (ex. image orpheline non embarquée) : rien à
|
||||||
|
// matérialiser, pas de cible de remap.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = meta.contentType() != null
|
||||||
|
? meta.contentType() : guessContentType(storageKey);
|
||||||
|
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
|
||||||
|
|
||||||
|
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
||||||
|
|
||||||
|
ImageJpaEntity e = new ImageJpaEntity();
|
||||||
|
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
|
||||||
|
e.setContentType(contentType);
|
||||||
|
e.setSizeBytes(size);
|
||||||
|
e.setStorageKey(storageKey);
|
||||||
|
mapId(maps, meta.id(), imageRepo.save(e).getId());
|
||||||
|
result.imageUploaded();
|
||||||
|
}
|
||||||
|
|
||||||
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
|
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
|
||||||
if (oldId != null && newId != null) maps.imageMap.put(oldId, newId);
|
if (oldId != null && newId != null) maps.imageMap.put(oldId, newId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,20 @@ class ImportReferenceRemapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void remap(ImportIdMaps maps) {
|
void remap(ImportIdMaps maps) {
|
||||||
// LoreNode.parentId
|
remapLoreNodeParents(maps);
|
||||||
|
remapTemplateDefaultNodes(maps);
|
||||||
|
remapCampaignWeakRefs(maps);
|
||||||
|
remapPageRelatedPages(maps);
|
||||||
|
remapArcRelatedPages(maps);
|
||||||
|
remapChapterRelatedPages(maps);
|
||||||
|
remapNpcRelatedPages(maps);
|
||||||
|
remapSceneRefs(maps);
|
||||||
|
remapQuestRefs(maps);
|
||||||
|
remapClockTriggerRefs(maps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LoreNode.parentId. */
|
||||||
|
private void remapLoreNodeParents(ImportIdMaps maps) {
|
||||||
for (LoreNodeJpaEntity e : maps.loreNodesToFix) {
|
for (LoreNodeJpaEntity e : maps.loreNodesToFix) {
|
||||||
Long newParent = maps.loreNodeMap.get(e.getParentId());
|
Long newParent = maps.loreNodeMap.get(e.getParentId());
|
||||||
if (newParent != null) {
|
if (newParent != null) {
|
||||||
@@ -66,8 +79,10 @@ class ImportReferenceRemapper {
|
|||||||
loreNodeRepo.save(e);
|
loreNodeRepo.save(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Template.defaultNodeId
|
/** Template.defaultNodeId. */
|
||||||
|
private void remapTemplateDefaultNodes(ImportIdMaps maps) {
|
||||||
for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) {
|
for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) {
|
||||||
Long newTemplateId = maps.templateMap.get(d.id());
|
Long newTemplateId = maps.templateMap.get(d.id());
|
||||||
Long newNode = maps.loreNodeMap.get(d.defaultNodeId());
|
Long newNode = maps.loreNodeMap.get(d.defaultNodeId());
|
||||||
@@ -78,8 +93,10 @@ class ImportReferenceRemapper {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
|
/** Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). */
|
||||||
|
private void remapCampaignWeakRefs(ImportIdMaps maps) {
|
||||||
for (Long newCampaignId : maps.campaignMap.values()) {
|
for (Long newCampaignId : maps.campaignMap.values()) {
|
||||||
campaignRepo.findById(newCampaignId).ifPresent(c -> {
|
campaignRepo.findById(newCampaignId).ifPresent(c -> {
|
||||||
String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId());
|
String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId());
|
||||||
@@ -89,40 +106,50 @@ class ImportReferenceRemapper {
|
|||||||
campaignRepo.save(c);
|
campaignRepo.save(c);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Page.relatedPageIds
|
/** Page.relatedPageIds. */
|
||||||
|
private void remapPageRelatedPages(ImportIdMaps maps) {
|
||||||
for (Long newPageId : maps.pageMap.values()) {
|
for (Long newPageId : maps.pageMap.values()) {
|
||||||
pageRepo.findById(newPageId).ifPresent(p -> {
|
pageRepo.findById(newPageId).ifPresent(p -> {
|
||||||
p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds()));
|
p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds()));
|
||||||
pageRepo.save(p);
|
pageRepo.save(p);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Arc.relatedPageIds
|
/** Arc.relatedPageIds. */
|
||||||
|
private void remapArcRelatedPages(ImportIdMaps maps) {
|
||||||
for (Long newArcId : maps.arcMap.values()) {
|
for (Long newArcId : maps.arcMap.values()) {
|
||||||
arcRepo.findById(newArcId).ifPresent(a -> {
|
arcRepo.findById(newArcId).ifPresent(a -> {
|
||||||
a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds()));
|
a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds()));
|
||||||
arcRepo.save(a);
|
arcRepo.save(a);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1)
|
/** Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1). */
|
||||||
|
private void remapChapterRelatedPages(ImportIdMaps maps) {
|
||||||
for (Long newChapterId : maps.chapterMap.values()) {
|
for (Long newChapterId : maps.chapterMap.values()) {
|
||||||
chapterRepo.findById(newChapterId).ifPresent(c -> {
|
chapterRepo.findById(newChapterId).ifPresent(c -> {
|
||||||
c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds()));
|
c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds()));
|
||||||
chapterRepo.save(c);
|
chapterRepo.save(c);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Npc.relatedPageIds
|
/** Npc.relatedPageIds. */
|
||||||
|
private void remapNpcRelatedPages(ImportIdMaps maps) {
|
||||||
for (Long newNpcId : maps.npcMap.values()) {
|
for (Long newNpcId : maps.npcMap.values()) {
|
||||||
npcRepo.findById(newNpcId).ifPresent(n -> {
|
npcRepo.findById(newNpcId).ifPresent(n -> {
|
||||||
n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds()));
|
n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds()));
|
||||||
npcRepo.save(n);
|
npcRepo.save(n);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
|
/** Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene). */
|
||||||
|
private void remapSceneRefs(ImportIdMaps maps) {
|
||||||
for (Long newSceneId : maps.sceneMap.values()) {
|
for (Long newSceneId : maps.sceneMap.values()) {
|
||||||
sceneRepo.findById(newSceneId).ifPresent(s -> {
|
sceneRepo.findById(newSceneId).ifPresent(s -> {
|
||||||
s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds()));
|
s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds()));
|
||||||
@@ -131,9 +158,13 @@ class ImportReferenceRemapper {
|
|||||||
sceneRepo.save(s);
|
sceneRepo.save(s);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
|
/**
|
||||||
// relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
|
* Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap),
|
||||||
|
* relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici.
|
||||||
|
*/
|
||||||
|
private void remapQuestRefs(ImportIdMaps maps) {
|
||||||
for (Long newQuestId : maps.questMap.values()) {
|
for (Long newQuestId : maps.questMap.values()) {
|
||||||
questRepo.findById(newQuestId).ifPresent(q -> {
|
questRepo.findById(newQuestId).ifPresent(q -> {
|
||||||
q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites()));
|
q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites()));
|
||||||
@@ -142,9 +173,13 @@ class ImportReferenceRemapper {
|
|||||||
questRepo.save(q);
|
questRepo.save(q);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
|
/**
|
||||||
// (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
|
* Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée
|
||||||
|
* (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper).
|
||||||
|
*/
|
||||||
|
private void remapClockTriggerRefs(ImportIdMaps maps) {
|
||||||
for (Long newClockId : maps.clockMap.values()) {
|
for (Long newClockId : maps.clockMap.values()) {
|
||||||
clockRepo.findById(newClockId).ifPresent(c -> {
|
clockRepo.findById(newClockId).ifPresent(c -> {
|
||||||
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
|
if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class LegacyQuestConverter {
|
|||||||
* quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme
|
* quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme
|
||||||
* pour les quêtes v2.
|
* pour les quêtes v2.
|
||||||
*/
|
*/
|
||||||
|
// S125 : faux positif — commentaires explicatifs (prose), pas de code mort.
|
||||||
|
@SuppressWarnings("java:S125")
|
||||||
void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) {
|
void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) {
|
||||||
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
|
Map<Long, ContentExport.ArcDto> arcById = new HashMap<>();
|
||||||
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
|
for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a);
|
||||||
|
|||||||
@@ -46,38 +46,55 @@ class StoredFileImporter {
|
|||||||
ImportResult.Builder result) {
|
ImportResult.Builder result) {
|
||||||
int imported = 0;
|
int imported = 0;
|
||||||
for (ContentExport.StoredFileDto meta : nullSafe(export.storedFiles())) {
|
for (ContentExport.StoredFileDto meta : nullSafe(export.storedFiles())) {
|
||||||
String storageKey = meta.storageKey();
|
if (importOne(meta, fileBinaries, maps)) {
|
||||||
if (storageKey == null || storageKey.isBlank()) continue;
|
imported++;
|
||||||
|
|
||||||
var existing = fileRepo.findByStorageKey(storageKey);
|
|
||||||
if (existing.isPresent()) {
|
|
||||||
// Deja present : reutilise (pas de reupload). L'ancien id pointe la ligne existante.
|
|
||||||
mapId(maps, meta.id(), existing.get().getId());
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] data = fileBinaries.get(storageKey);
|
|
||||||
if (data == null) {
|
|
||||||
continue; // metadonnee sans binaire : rien a materialiser
|
|
||||||
}
|
|
||||||
|
|
||||||
String contentType = meta.contentType() != null
|
|
||||||
? meta.contentType() : "application/octet-stream";
|
|
||||||
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
|
|
||||||
|
|
||||||
fileStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
|
||||||
|
|
||||||
StoredFileJpaEntity e = new StoredFileJpaEntity();
|
|
||||||
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
|
|
||||||
e.setContentType(contentType);
|
|
||||||
e.setSizeBytes(size);
|
|
||||||
e.setStorageKey(storageKey);
|
|
||||||
mapId(maps, meta.id(), fileRepo.save(e).getId());
|
|
||||||
imported++;
|
|
||||||
}
|
}
|
||||||
result.count("storedFiles", imported);
|
result.count("storedFiles", imported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite une metadonnee de fichier. Reutilise la ligne existante si la cle est deja
|
||||||
|
* connue, sinon materialise le binaire et cree la ligne.
|
||||||
|
*
|
||||||
|
* @return {@code true} si un nouveau binaire a ete stocke + une ligne creee (a compter
|
||||||
|
* dans les imports), {@code false} sinon (cle vide, reutilisation, binaire absent)
|
||||||
|
*/
|
||||||
|
private boolean importOne(ContentExport.StoredFileDto meta,
|
||||||
|
Map<String, byte[]> fileBinaries,
|
||||||
|
ImportIdMaps maps) {
|
||||||
|
String storageKey = meta.storageKey();
|
||||||
|
if (storageKey == null || storageKey.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = fileRepo.findByStorageKey(storageKey);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
// Deja present : reutilise (pas de reupload). L'ancien id pointe la ligne existante.
|
||||||
|
mapId(maps, meta.id(), existing.get().getId());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] data = fileBinaries.get(storageKey);
|
||||||
|
if (data == null) {
|
||||||
|
return false; // metadonnee sans binaire : rien a materialiser
|
||||||
|
}
|
||||||
|
|
||||||
|
String contentType = meta.contentType() != null
|
||||||
|
? meta.contentType() : "application/octet-stream";
|
||||||
|
long size = meta.sizeBytes() > 0 ? meta.sizeBytes() : data.length;
|
||||||
|
|
||||||
|
fileStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
||||||
|
|
||||||
|
StoredFileJpaEntity e = new StoredFileJpaEntity();
|
||||||
|
e.setFilename(meta.filename() != null ? meta.filename() : fileNameOf(storageKey));
|
||||||
|
e.setContentType(contentType);
|
||||||
|
e.setSizeBytes(size);
|
||||||
|
e.setStorageKey(storageKey);
|
||||||
|
mapId(maps, meta.id(), fileRepo.save(e).getId());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
|
private static void mapId(ImportIdMaps maps, Long oldId, Long newId) {
|
||||||
if (oldId != null && newId != null) maps.storedFileMap.put(oldId, newId);
|
if (oldId != null && newId != null) maps.storedFileMap.put(oldId, newId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.loremind.infrastructure.transfer.foundry;
|
|||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||||
|
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||||
|
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||||
import com.loremind.domain.campaigncontext.structure.Room;
|
import com.loremind.domain.campaigncontext.structure.Room;
|
||||||
import com.loremind.domain.campaigncontext.structure.RoomBranch;
|
import com.loremind.domain.campaigncontext.structure.RoomBranch;
|
||||||
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
|
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
|
||||||
@@ -40,6 +43,7 @@ public class FoundryExportService {
|
|||||||
private final CampaignJpaRepository campaignRepo;
|
private final CampaignJpaRepository campaignRepo;
|
||||||
private final ArcJpaRepository arcRepo;
|
private final ArcJpaRepository arcRepo;
|
||||||
private final ChapterJpaRepository chapterRepo;
|
private final ChapterJpaRepository chapterRepo;
|
||||||
|
private final QuestJpaRepository questRepo;
|
||||||
private final SceneJpaRepository sceneRepo;
|
private final SceneJpaRepository sceneRepo;
|
||||||
private final NpcJpaRepository npcRepo;
|
private final NpcJpaRepository npcRepo;
|
||||||
private final EnemyJpaRepository enemyRepo;
|
private final EnemyJpaRepository enemyRepo;
|
||||||
@@ -55,6 +59,7 @@ public class FoundryExportService {
|
|||||||
public FoundryExportService(CampaignJpaRepository campaignRepo,
|
public FoundryExportService(CampaignJpaRepository campaignRepo,
|
||||||
ArcJpaRepository arcRepo,
|
ArcJpaRepository arcRepo,
|
||||||
ChapterJpaRepository chapterRepo,
|
ChapterJpaRepository chapterRepo,
|
||||||
|
QuestJpaRepository questRepo,
|
||||||
SceneJpaRepository sceneRepo,
|
SceneJpaRepository sceneRepo,
|
||||||
NpcJpaRepository npcRepo,
|
NpcJpaRepository npcRepo,
|
||||||
EnemyJpaRepository enemyRepo,
|
EnemyJpaRepository enemyRepo,
|
||||||
@@ -69,6 +74,7 @@ public class FoundryExportService {
|
|||||||
this.campaignRepo = campaignRepo;
|
this.campaignRepo = campaignRepo;
|
||||||
this.arcRepo = arcRepo;
|
this.arcRepo = arcRepo;
|
||||||
this.chapterRepo = chapterRepo;
|
this.chapterRepo = chapterRepo;
|
||||||
|
this.questRepo = questRepo;
|
||||||
this.sceneRepo = sceneRepo;
|
this.sceneRepo = sceneRepo;
|
||||||
this.npcRepo = npcRepo;
|
this.npcRepo = npcRepo;
|
||||||
this.enemyRepo = enemyRepo;
|
this.enemyRepo = enemyRepo;
|
||||||
@@ -124,13 +130,50 @@ public class FoundryExportService {
|
|||||||
|
|
||||||
AssetRegistry assets = new AssetRegistry();
|
AssetRegistry assets = new AssetRegistry();
|
||||||
|
|
||||||
// Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order).
|
ArcsQuestsScenes structure = buildArcsQuestsScenes(campaign, opts, assets);
|
||||||
|
List<FoundryBundle.Persona> npcs = buildNpcs(campaign, opts, npcTemplate, assets);
|
||||||
|
List<FoundryBundle.Persona> enemies = buildEnemies(campaign, opts, enemyTemplate, foundryActorType, assets);
|
||||||
|
List<FoundryBundle.RandomTable> randomTables = buildRandomTables(campaign, opts);
|
||||||
|
|
||||||
|
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign(
|
||||||
|
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
|
||||||
|
|
||||||
|
FoundryBundle.Data data = new FoundryBundle.Data(
|
||||||
|
FORMAT_VERSION, campaignNode,
|
||||||
|
new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()),
|
||||||
|
structure.arcs(), structure.quests(), structure.scenes(),
|
||||||
|
npcs, enemies, randomTables, assets.assets());
|
||||||
|
|
||||||
|
FoundryBundle.Manifest manifest =
|
||||||
|
buildManifest(campaign, exportedAt, structure, npcs, enemies, randomTables, assets);
|
||||||
|
|
||||||
|
return new BuiltBundle(manifest, data, assets.binaries());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arcs -> Quetes -> Scenes, a plat + refs parent (regroupes : construits par la meme triple boucle). */
|
||||||
|
private record ArcsQuestsScenes(
|
||||||
|
List<FoundryBundle.Arc> arcs, List<FoundryBundle.Quest> quests, List<FoundryBundle.Scene> scenes) {}
|
||||||
|
|
||||||
|
/** Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order). */
|
||||||
|
private ArcsQuestsScenes buildArcsQuestsScenes(CampaignJpaEntity campaign, ExportOptions opts, AssetRegistry assets) {
|
||||||
List<FoundryBundle.Arc> arcs = new ArrayList<>();
|
List<FoundryBundle.Arc> arcs = new ArrayList<>();
|
||||||
List<FoundryBundle.Quest> quests = new ArrayList<>();
|
List<FoundryBundle.Quest> quests = new ArrayList<>();
|
||||||
List<FoundryBundle.Scene> scenes = new ArrayList<>();
|
List<FoundryBundle.Scene> scenes = new ArrayList<>();
|
||||||
|
|
||||||
List<ArcJpaEntity> arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
|
Set<String> liveContainerIds = liveQuestContainerIds(campaign);
|
||||||
for (ArcJpaEntity arc : arcEntities) {
|
for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
|
||||||
|
List<ChapterJpaEntity> chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder);
|
||||||
|
// L'arc technique SYSTEM (« Quetes libres ») est masque dans l'appli : seuls
|
||||||
|
// les conteneurs des quetes VIVANTES partent dans l'export. Un conteneur
|
||||||
|
// orphelin (quete supprimee, chapitre garde par deleteQuest car il contenait
|
||||||
|
// des scenes) est invisible cote LoreMind -> il ne doit pas reapparaitre
|
||||||
|
// cote Foundry. Arc omis entierement s'il ne reste rien.
|
||||||
|
if (arc.getType() == ArcType.SYSTEM) {
|
||||||
|
chapters = chapters.stream()
|
||||||
|
.filter(ch -> liveContainerIds.contains(str(ch.getId())))
|
||||||
|
.toList();
|
||||||
|
if (chapters.isEmpty()) continue;
|
||||||
|
}
|
||||||
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
|
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
|
||||||
// leurs illustrations ne sont pas embarquees.
|
// leurs illustrations ne sont pas embarquees.
|
||||||
arcs.add(new FoundryBundle.Arc(
|
arcs.add(new FoundryBundle.Arc(
|
||||||
@@ -139,7 +182,7 @@ public class FoundryExportService {
|
|||||||
arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(),
|
arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(),
|
||||||
opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of()));
|
opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of()));
|
||||||
|
|
||||||
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
|
for (ChapterJpaEntity ch : chapters) {
|
||||||
quests.add(new FoundryBundle.Quest(
|
quests.add(new FoundryBundle.Quest(
|
||||||
str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(),
|
str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(),
|
||||||
ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(),
|
ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(),
|
||||||
@@ -150,40 +193,62 @@ public class FoundryExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new ArcsQuestsScenes(arcs, quests, scenes);
|
||||||
|
}
|
||||||
|
|
||||||
// PNJ : purement journal — hors perimetre sans les journaux.
|
/** Ids des chapitres-conteneurs references par les quetes vivantes de la campagne. */
|
||||||
|
private Set<String> liveQuestContainerIds(CampaignJpaEntity campaign) {
|
||||||
|
Set<String> ids = new HashSet<>();
|
||||||
|
for (QuestJpaEntity q : questRepo.findByCampaignId(campaign.getId())) {
|
||||||
|
for (QuestNodeRef n : q.getNodes() != null ? q.getNodes() : List.<QuestNodeRef>of()) {
|
||||||
|
if (n.nodeType() == NodeType.CHAPTER) ids.add(n.nodeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNJ : purement journal — hors perimetre sans les journaux. */
|
||||||
|
private List<FoundryBundle.Persona> buildNpcs(CampaignJpaEntity campaign, ExportOptions opts,
|
||||||
|
List<TemplateField> npcTemplate, AssetRegistry assets) {
|
||||||
List<FoundryBundle.Persona> npcs = new ArrayList<>();
|
List<FoundryBundle.Persona> npcs = new ArrayList<>();
|
||||||
if (opts.journals()) {
|
if (!opts.journals()) return npcs;
|
||||||
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||||
npcs.add(new FoundryBundle.Persona(
|
npcs.add(new FoundryBundle.Persona(
|
||||||
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
|
str(n.getId()), n.getName(), n.getFolder(), n.getOrder(),
|
||||||
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
|
assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, null, null,
|
||||||
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
|
fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return npcs;
|
||||||
|
}
|
||||||
|
|
||||||
// Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token)
|
/**
|
||||||
// ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que
|
* Ennemis : necessaires aux cartes (acteurs/tokens, portrait = image du token)
|
||||||
// les journaux -> non embarquees en mode cartes seules.
|
* ET aux journaux (bestiaire). Les galeries d'images des champs ne servent que
|
||||||
|
* les journaux -> non embarquees en mode cartes seules.
|
||||||
|
*/
|
||||||
|
private List<FoundryBundle.Persona> buildEnemies(CampaignJpaEntity campaign, ExportOptions opts,
|
||||||
|
List<TemplateField> enemyTemplate, String foundryActorType,
|
||||||
|
AssetRegistry assets) {
|
||||||
List<FoundryBundle.Persona> enemies = new ArrayList<>();
|
List<FoundryBundle.Persona> enemies = new ArrayList<>();
|
||||||
if (opts.maps() || opts.journals()) {
|
if (!opts.maps() && !opts.journals()) return enemies;
|
||||||
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||||
enemies.add(new FoundryBundle.Persona(
|
enemies.add(new FoundryBundle.Persona(
|
||||||
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
str(e.getId()), e.getName(), e.getFolder(), e.getOrder(),
|
||||||
assets.image(e.getPortraitImageId()),
|
assets.image(e.getPortraitImageId()),
|
||||||
opts.journals() ? assets.image(e.getHeaderImageId()) : null,
|
opts.journals() ? assets.image(e.getHeaderImageId()) : null,
|
||||||
e.getLevel(),
|
e.getLevel(),
|
||||||
e.getFoundryRef(),
|
e.getFoundryRef(),
|
||||||
buildFoundryActor(e, enemyTemplate, foundryActorType),
|
buildFoundryActor(e, enemyTemplate, foundryActorType),
|
||||||
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
|
fields(enemyTemplate, e.getValues(), e.getKeyValueValues(),
|
||||||
opts.journals() ? e.getImageValues() : null, assets)));
|
opts.journals() ? e.getImageValues() : null, assets)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return enemies;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<FoundryBundle.RandomTable> buildRandomTables(CampaignJpaEntity campaign, ExportOptions opts) {
|
||||||
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
|
List<FoundryBundle.RandomTable> randomTables = new ArrayList<>();
|
||||||
for (RandomTableJpaEntity t : opts.tables()
|
if (!opts.tables()) return randomTables;
|
||||||
? randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())
|
for (RandomTableJpaEntity t : randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) {
|
||||||
: List.<RandomTableJpaEntity>of()) {
|
|
||||||
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
|
List<FoundryBundle.RandomTableEntry> entries = new ArrayList<>();
|
||||||
if (t.getEntries() != null) {
|
if (t.getEntries() != null) {
|
||||||
for (RandomTableEntryJpaEntity en : t.getEntries()) {
|
for (RandomTableEntryJpaEntity en : t.getEntries()) {
|
||||||
@@ -194,29 +259,25 @@ public class FoundryExportService {
|
|||||||
randomTables.add(new FoundryBundle.RandomTable(
|
randomTables.add(new FoundryBundle.RandomTable(
|
||||||
str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries));
|
str(t.getId()), t.getName(), t.getDescription(), t.getDiceFormula(), entries));
|
||||||
}
|
}
|
||||||
|
return randomTables;
|
||||||
|
}
|
||||||
|
|
||||||
FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign(
|
private FoundryBundle.Manifest buildManifest(CampaignJpaEntity campaign, String exportedAt,
|
||||||
str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId());
|
ArcsQuestsScenes structure, List<FoundryBundle.Persona> npcs,
|
||||||
|
List<FoundryBundle.Persona> enemies,
|
||||||
FoundryBundle.Data data = new FoundryBundle.Data(
|
List<FoundryBundle.RandomTable> randomTables, AssetRegistry assets) {
|
||||||
FORMAT_VERSION, campaignNode,
|
|
||||||
new FoundryBundle.Options(opts.maps(), opts.journals(), opts.tables()),
|
|
||||||
arcs, quests, scenes, npcs, enemies, randomTables, assets.assets());
|
|
||||||
|
|
||||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||||
counts.put("arcs", arcs.size());
|
counts.put("arcs", structure.arcs().size());
|
||||||
counts.put("quests", quests.size());
|
counts.put("quests", structure.quests().size());
|
||||||
counts.put("scenes", scenes.size());
|
counts.put("scenes", structure.scenes().size());
|
||||||
counts.put("npcs", npcs.size());
|
counts.put("npcs", npcs.size());
|
||||||
counts.put("enemies", enemies.size());
|
counts.put("enemies", enemies.size());
|
||||||
counts.put("randomTables", randomTables.size());
|
counts.put("randomTables", randomTables.size());
|
||||||
counts.put("assets", assets.assets().size());
|
counts.put("assets", assets.assets().size());
|
||||||
|
|
||||||
FoundryBundle.Manifest manifest = new FoundryBundle.Manifest(
|
return new FoundryBundle.Manifest(
|
||||||
FORMAT_VERSION, "loremind", appVersion, exportedAt,
|
FORMAT_VERSION, "loremind", appVersion, exportedAt,
|
||||||
str(campaign.getId()), campaign.getName(), CONTENT_FORMAT, counts);
|
str(campaign.getId()), campaign.getName(), CONTENT_FORMAT, counts);
|
||||||
|
|
||||||
return new BuiltBundle(manifest, data, assets.binaries());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialise le bundle dans le flux au format .zip (binaires streames a la volee). */
|
/** Serialise le bundle dans le flux au format .zip (binaires streames a la volee). */
|
||||||
@@ -233,24 +294,36 @@ public class FoundryExportService {
|
|||||||
zip.write(writer.writeValueAsBytes(bundle.data()));
|
zip.write(writer.writeValueAsBytes(bundle.data()));
|
||||||
zip.closeEntry();
|
zip.closeEntry();
|
||||||
|
|
||||||
Set<String> written = new HashSet<>();
|
writeBinaries(zip, bundle.binaries());
|
||||||
for (BinaryRef ref : bundle.binaries()) {
|
|
||||||
if (!written.add(ref.path())) continue; // dedup defensif
|
|
||||||
InputStream data = ref.image()
|
|
||||||
? imageStorage.download(ref.storageKey())
|
|
||||||
: fileStorage.download(ref.storageKey());
|
|
||||||
if (data == null) continue; // cle orpheline : on ignore
|
|
||||||
try (data) {
|
|
||||||
zip.putNextEntry(new ZipEntry(ref.path()));
|
|
||||||
data.transferTo(zip);
|
|
||||||
zip.closeEntry();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Echec de la generation du bundle Foundry", e);
|
throw new UncheckedIOException("Echec de la generation du bundle Foundry", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ecrit chaque binaire reference, dedup par chemin cible (cle orpheline ignoree). */
|
||||||
|
private void writeBinaries(ZipOutputStream zip, List<BinaryRef> binaries) throws IOException {
|
||||||
|
Set<String> written = new HashSet<>();
|
||||||
|
for (BinaryRef ref : binaries) {
|
||||||
|
if (written.add(ref.path())) {
|
||||||
|
writeBinaryEntry(zip, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeBinaryEntry(ZipOutputStream zip, BinaryRef ref) throws IOException {
|
||||||
|
InputStream data = ref.image()
|
||||||
|
? imageStorage.download(ref.storageKey())
|
||||||
|
: fileStorage.download(ref.storageKey());
|
||||||
|
if (data == null) {
|
||||||
|
return; // cle orpheline : on ignore
|
||||||
|
}
|
||||||
|
try (data) {
|
||||||
|
zip.putNextEntry(new ZipEntry(ref.path()));
|
||||||
|
data.transferTo(zip);
|
||||||
|
zip.closeEntry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Mapping Scene -----
|
// ----- Mapping Scene -----
|
||||||
|
|
||||||
private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) {
|
private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets, ExportOptions opts) {
|
||||||
@@ -326,21 +399,33 @@ public class FoundryExportService {
|
|||||||
private FoundryBundle.FoundryActor buildFoundryActor(EnemyJpaEntity e, List<TemplateField> template, String actorType) {
|
private FoundryBundle.FoundryActor buildFoundryActor(EnemyJpaEntity e, List<TemplateField> template, String actorType) {
|
||||||
if (actorType == null || actorType.isBlank() || template == null) return null;
|
if (actorType == null || actorType.isBlank() || template == null) return null;
|
||||||
if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null;
|
if (e.getFoundryRef() != null && !e.getFoundryRef().isBlank()) return null;
|
||||||
|
|
||||||
Map<String, Object> system = new LinkedHashMap<>();
|
Map<String, Object> system = new LinkedHashMap<>();
|
||||||
Map<String, String> values = e.getValues();
|
Map<String, String> values = e.getValues();
|
||||||
boolean any = false;
|
boolean any = false;
|
||||||
for (TemplateField f : template) {
|
for (TemplateField f : template) {
|
||||||
if (f == null || f.getName() == null) continue;
|
FieldContribution c = foundryFieldContribution(f, values);
|
||||||
String path = f.getFoundryPath();
|
if (c != null) {
|
||||||
if (path == null || path.isBlank()) continue;
|
setPath(system, c.path(), c.value());
|
||||||
String raw = values != null ? values.get(f.getName()) : null;
|
any = true;
|
||||||
if (raw == null || raw.isBlank()) continue;
|
}
|
||||||
setPath(system, path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
|
|
||||||
any = true;
|
|
||||||
}
|
}
|
||||||
return any ? new FoundryBundle.FoundryActor(actorType, system) : null;
|
return any ? new FoundryBundle.FoundryActor(actorType, system) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Chemin Foundry ("a.b.c") + valeur d'un champ template pour {@link #buildFoundryActor}. */
|
||||||
|
private record FieldContribution(String path, Object value) {}
|
||||||
|
|
||||||
|
/** Contribution d'un champ, ou null si non mappe/sans valeur (nom, chemin Foundry ou valeur absent). */
|
||||||
|
private static FieldContribution foundryFieldContribution(TemplateField f, Map<String, String> values) {
|
||||||
|
if (f == null || f.getName() == null) return null;
|
||||||
|
String path = f.getFoundryPath();
|
||||||
|
if (path == null || path.isBlank()) return null;
|
||||||
|
String raw = values != null ? values.get(f.getName()) : null;
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
return new FieldContribution(path, f.getType() == FieldType.NUMBER ? parseNumber(raw) : raw);
|
||||||
|
}
|
||||||
|
|
||||||
/** Pose une valeur dans un objet imbriqué selon un chemin pointé ("a.b.c"). */
|
/** Pose une valeur dans un objet imbriqué selon un chemin pointé ("a.b.c"). */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void setPath(Map<String, Object> root, String path, Object value) {
|
private static void setPath(Map<String, Object> root, String path, Object value) {
|
||||||
@@ -370,52 +455,72 @@ public class FoundryExportService {
|
|||||||
Map<String, Map<String, String>> keyValueValues,
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
Map<String, List<String>> imageValues,
|
Map<String, List<String>> imageValues,
|
||||||
AssetRegistry assets) {
|
AssetRegistry assets) {
|
||||||
List<FoundryBundle.Field> out = new ArrayList<>();
|
|
||||||
// Repli : pas de template -> paires brutes label=cle.
|
|
||||||
if (template == null || template.isEmpty()) {
|
if (template == null || template.isEmpty()) {
|
||||||
if (values != null) {
|
return rawFields(values);
|
||||||
for (Map.Entry<String, String> e : values.entrySet()) {
|
|
||||||
if (e.getValue() != null && !e.getValue().isBlank()) {
|
|
||||||
out.add(new FoundryBundle.Field("text", e.getKey(), e.getValue(), null, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
}
|
||||||
|
List<FoundryBundle.Field> out = new ArrayList<>();
|
||||||
for (TemplateField f : template) {
|
for (TemplateField f : template) {
|
||||||
if (f == null || f.getName() == null || f.getType() == null) continue;
|
FoundryBundle.Field field = toField(f, values, keyValueValues, imageValues, assets);
|
||||||
FieldType type = f.getType();
|
if (field != null) {
|
||||||
if (type == FieldType.TEXT || type == FieldType.NUMBER) {
|
out.add(field);
|
||||||
String v = values != null ? values.get(f.getName()) : null;
|
|
||||||
if (v != null && !v.isBlank()) {
|
|
||||||
out.add(new FoundryBundle.Field(type == FieldType.NUMBER ? "number" : "text",
|
|
||||||
f.getName(), v, null, null));
|
|
||||||
}
|
|
||||||
} else if (type == FieldType.KEY_VALUE_LIST) {
|
|
||||||
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
|
|
||||||
List<String> labels = f.getLabels();
|
|
||||||
if (inner != null && labels != null) {
|
|
||||||
List<FoundryBundle.Entry> entries = new ArrayList<>();
|
|
||||||
for (String label : labels) {
|
|
||||||
String v = inner.get(label);
|
|
||||||
if (v != null && !v.isBlank()) entries.add(new FoundryBundle.Entry(label, v));
|
|
||||||
}
|
|
||||||
if (!entries.isEmpty()) {
|
|
||||||
out.add(new FoundryBundle.Field("keyValueList", f.getName(), null, entries, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (type == FieldType.IMAGE) {
|
|
||||||
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
|
|
||||||
List<String> assetIds = assets.images(ids);
|
|
||||||
if (!assetIds.isEmpty()) {
|
|
||||||
out.add(new FoundryBundle.Field("image", f.getName(), null, null, assetIds));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// TABLE : pas de stockage cote PNJ/Ennemi -> ignore.
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Repli sans template : paires brutes label=cle (valeurs non vides uniquement). */
|
||||||
|
private static List<FoundryBundle.Field> rawFields(Map<String, String> values) {
|
||||||
|
List<FoundryBundle.Field> out = new ArrayList<>();
|
||||||
|
if (values != null) {
|
||||||
|
for (Map.Entry<String, String> e : values.entrySet()) {
|
||||||
|
if (e.getValue() != null && !e.getValue().isBlank()) {
|
||||||
|
out.add(new FoundryBundle.Field("text", e.getKey(), e.getValue(), null, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un champ Foundry pour ce TemplateField (dispatch par type), ou null si non mappe/non applicable/vide. */
|
||||||
|
private static FoundryBundle.Field toField(TemplateField f,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, Map<String, String>> keyValueValues,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
AssetRegistry assets) {
|
||||||
|
if (f == null || f.getName() == null || f.getType() == null) return null;
|
||||||
|
return switch (f.getType()) {
|
||||||
|
case TEXT, NUMBER -> textOrNumberField(f, values);
|
||||||
|
case KEY_VALUE_LIST -> keyValueField(f, keyValueValues);
|
||||||
|
case IMAGE -> imageField(f, imageValues, assets);
|
||||||
|
case TABLE -> null; // pas de stockage cote PNJ/Ennemi -> ignore.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FoundryBundle.Field textOrNumberField(TemplateField f, Map<String, String> values) {
|
||||||
|
String v = values != null ? values.get(f.getName()) : null;
|
||||||
|
if (v == null || v.isBlank()) return null;
|
||||||
|
return new FoundryBundle.Field(f.getType() == FieldType.NUMBER ? "number" : "text", f.getName(), v, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FoundryBundle.Field keyValueField(TemplateField f, Map<String, Map<String, String>> keyValueValues) {
|
||||||
|
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
|
||||||
|
List<String> labels = f.getLabels();
|
||||||
|
if (inner == null || labels == null) return null;
|
||||||
|
List<FoundryBundle.Entry> entries = new ArrayList<>();
|
||||||
|
for (String label : labels) {
|
||||||
|
String v = inner.get(label);
|
||||||
|
if (v != null && !v.isBlank()) entries.add(new FoundryBundle.Entry(label, v));
|
||||||
|
}
|
||||||
|
return entries.isEmpty() ? null : new FoundryBundle.Field("keyValueList", f.getName(), null, entries, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FoundryBundle.Field imageField(TemplateField f, Map<String, List<String>> imageValues,
|
||||||
|
AssetRegistry assets) {
|
||||||
|
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
|
||||||
|
List<String> assetIds = assets.images(ids);
|
||||||
|
return assetIds.isEmpty() ? null : new FoundryBundle.Field("image", f.getName(), null, null, assetIds);
|
||||||
|
}
|
||||||
|
|
||||||
// ----- Registre des assets (images + battlemaps), dedup + index -----
|
// ----- Registre des assets (images + battlemaps), dedup + index -----
|
||||||
|
|
||||||
private final class AssetRegistry {
|
private final class AssetRegistry {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user