Compare commits

..

8 Commits

Author SHA1 Message Date
ad3ea0e9e6 CI : build web cassé par la sortie de npm 12 (npm@latest exige Node >= 22)
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 24s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 3m14s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Failing after 1m1s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 45s
E2E Tests / e2e (push) Failing after 4m6s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 1m12s
Build & Push Images / build (brain) (push) Successful in 2m14s
Build & Push Images / build (web) (push) Successful in 1m54s
Build & Push Images / build (core) (push) Successful in 3m41s
Build & Push Images / build-switcher (push) Successful in 48s
Suppression du « npm install -g npm@latest » du Dockerfile web : cible
mouvante inutile — le npm embarqué dans node:20 suffit pour npm ci
(lockfileVersion 3) et le build Angular.
2026-07-10 09:31:58 +02:00
0c44e42a22 Quêtes libres : fin des conteneurs fantômes (cascade, impact annoncé, export filtré, migration V24)
Some checks failed
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 52s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 2m36s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m11s
Build & Push Images / build (web) (push) Failing after 35s
Build & Push Images / build (brain) (push) Successful in 1m18s
Build & Push Images / build-switcher (push) Successful in 42s
Build & Push Images / build (core) (push) Successful in 3m8s
Sidebar Lore : le panneau Templates ne peut plus écraser l'arbre

Plafonné à 45 % de la sidebar avec ascenseur interne sur la liste —
l'en-tête, le repli et le bouton + restent toujours visibles, l'arbre
garde son propre scroll (remonté par un utilisateur avec ~30 templates)

Passage v1.0.3
2026-07-10 09:19:26 +02:00
1c8b24c5dd CI : Trivy en résolution 100 % hors-ligne (--offline-scan)
All checks were successful
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 55s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Successful in 32s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 42s
Build & Push Images / build (brain) (push) Successful in 1m26s
Build & Push Images / build (web) (push) Successful in 1m59s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build-switcher (push) Successful in 16s
Le préchargement du cache Maven ne suffisait pas : Trivy va chercher les
POMs parents (ex. netty-parent) sur Maven Central même quand ~/.m2 est
rempli, et l'IP du runner finit rate-limitée (429, blocage 30 min).
--offline-scan supprime toute requête réseau pendant la résolution — sans
perte : dependency:go-offline garantit juste avant que tout est en cache.
2026-07-07 12:09:04 +02:00
177967af55 Sécurité : purge des 22 CVE remontées par Trivy (premier run du gate)
Some checks failed
Build & Push Images / build (web) (push) Successful in 1m58s
Build & Push Images / build (core) (push) Successful in 3m13s
Build & Push Images / build-switcher (push) Successful in 22s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 43s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m0s
Build & Push Images / build (brain) (push) Successful in 1m12s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m35s
- core : overrides BOM Spring Boot — tomcat-embed 10.1.55 (3 CRITICAL dont
  bypass d'auth digest), jackson-databind 2.21.4 (exécution de code arbitraire),
  netty 4.1.135.Final (lot DoS/bypass hostname/DNS), postgresql 42.7.11
- web : Angular 21.2.16 → 21.2.17 (HttpTransferCache, DoS formatDate,
  DOM clobbering hydration) — package-lock régénéré (peer-deps exactes
  entre paquets @angular : montée impossible paquet par paquet)

Vérifié : test-compile core, build + tests web, re-scan Trivy local propr
2026-07-07 01:22:54 +02:00
42c4b53ced Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
2026-07-07 01:05:00 +02:00
3e9e828225 Mise en place de la qualité sur la branche beta également
Some checks failed
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 1m5s
Build & Push Images / build (brain) (push) Successful in 2m5s
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Failing after 14m12s
Build & Push Images / build (web) (push) Failing after 2m7s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (core) (push) Successful in 3m27s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Failing after 8m26s
2026-07-07 00:16:23 +02:00
51eb907078 Mise en place de la version 1.0.2-bêta
Some checks failed
Build & Push Images / build (core) (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build-switcher (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
2026-07-07 00:12:40 +02:00
9f0359d8c0 Dernière fournée de corrections Sonar
Mise en place de MegaLinter ; web-lint pour la partie code quality sur la CI, Trivy pour la partie CVE
2026-07-07 00:11:52 +02:00
290 changed files with 7000 additions and 3366 deletions

View File

@@ -82,9 +82,11 @@ jobs:
if: failure()
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
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: web/playwright-report/

View 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
View File

@@ -129,3 +129,5 @@ web/coverage/
brain/htmlcov/
brain/.coverage
foundry-module/
plan-promotion-loremind.md
post-reddit-foundryvtt.md

43
.mega-linter.yml Normal file
View 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

View File

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

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId>
<version>1.0.1-beta</version>
<version>1.0.3</version>
<name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description>
@@ -24,6 +24,19 @@
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
<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>
<dependencies>

View File

@@ -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) ? */
private boolean isContainerOf(Quest quest, Chapter chapter) {
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())
.map(a -> a.getType() == ArcType.SYSTEM)
.orElse(false);
@@ -156,6 +161,18 @@ public class QuestService {
/**
* 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}
* 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
@@ -167,28 +184,47 @@ public class QuestService {
progressionRepository.deleteByQuestId(id);
questRepository.deleteById(id);
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());
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
if (node.nodeType() != NodeType.CHAPTER) continue;
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
boolean container = isContainerOf(quest, ch);
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
boolean referencedElsewhere = remaining.stream()
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
&& ch.getId().equals(n.nodeId())));
if (container && empty && !referencedElsewhere) {
chapterRepository.deleteById(ch.getId());
}
if (!container || referencedElsewhere) return;
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) {
return nodes != null ? nodes : List.of();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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