Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m50s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 18s

Corrections fonctionnelles
- order initialisé à la création des pages et dossiers de lore (nextOrderFor) :
  un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0
- storePortrait : type MIME canonique dérivé de l'extension du fichier
  (un data URL "image/jpg" n'est plus rejeté silencieusement)
- takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view,
  folder-view et campaign-detail (fuites mémoire)

Factorisation (suppression de duplication)
- shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName
  mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar
  (folderChildren). Le tri des dossiers est désormais cohérent entre la
  sidebar et les vues cartes (insensible casse/accents partout)
- DataSyncService.onChange()/persist() remplacent le câblage
  changed$ + reorder recopié dans 5 vues
- campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent
  le forkJoin dupliqué entre ngOnInit et reload
- ReorderSupport (domain/shared) : squelette générique remplaçant les
  8 boucles de réordonnancement copiées dans les services
- FoundryExportService : GameSystem résolu une seule fois dans buildBundle
- module Foundry : walkScalars mutualise la récursion de flattenStats et
  flattenStructure (comportements préservés) ; esc() de l'importer aligné
  sur foundry.utils.escapeHTML

Suppression de code mort
- characterService/characters retirés de loadCampaignTreeData et de ses
  16 appelants (arguments, injections, imports et littéraux CampaignTreeData)
- CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back
- BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant
- commentaire trompeur du PDF corrigé

Tests
- mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui
  vérifiaient encore l'ancien comportement (tri alphabétique, non-classés
  en enfants directs) → alignés sur le modèle actuel (tri par order +
  pseudo-dossier "Sans dossier")
This commit is contained in:
2026-06-26 09:10:20 +02:00
parent 3896c5b4cb
commit a9242ba1e1
94 changed files with 2234 additions and 546 deletions

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Arc;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ArcRepository;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
@@ -117,4 +118,16 @@ public class ArcService {
public boolean arcExists(String id) {
return arcRepository.existsById(id);
}
/**
* Réordonne les arcs d'une campagne : {@code order} = position dans la liste fournie.
* Les ids inconnus sont ignorés. Transactionnel.
*/
@Transactional
public void reorderArcs(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> arcRepository.findById(id).orElse(null),
(arc, i) -> arc.setOrder(i),
arcRepository::save);
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Chapter;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.ChapterRepository;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
@@ -97,4 +98,19 @@ public class ChapterService {
public boolean chapterExists(String id) {
return chapterRepository.existsById(id);
}
/**
* Réordonne (et déplace) les chapitres d'un arc : {@code order} = position. Si
* {@code arcId} est fourni, on réaffecte le chapitre à cet arc. Transactionnel.
*/
@Transactional
public void reorderChapters(String arcId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> chapterRepository.findById(id).orElse(null),
(chapter, i) -> {
if (arcId != null && !arcId.isBlank()) chapter.setArcId(arcId);
chapter.setOrder(i);
},
chapterRepository::save);
}
}

View File

@@ -1,6 +1,7 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Enemy;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.EnemyRepository;
import com.loremind.domain.images.Image;
import com.loremind.application.images.ImageService;
@@ -91,6 +92,19 @@ public class EnemyService {
enemyRepository.deleteById(id);
}
/**
* Réordonne (et reclasse) les ennemis d'un dossier : {@code order} = position, et
* le dossier de chaque ennemi est posé à {@code folder} (glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderEnemies(String folder, List<String> orderedIds) {
String f = normalize(folder);
ReorderSupport.reorder(orderedIds,
id -> enemyRepository.findById(id).orElse(null),
(enemy, i) -> { enemy.setFolder(f); enemy.setOrder(i); },
enemyRepository::save);
}
// --- Import de monstres depuis un compendium Foundry -------------------
/**
@@ -130,9 +144,12 @@ public class EnemyService {
String ext = contentType.contains("png") ? "png"
: contentType.contains("jpeg") || contentType.contains("jpg") ? "jpg"
: contentType.contains("gif") ? "gif" : "webp";
// Type MIME canonique dérivé de l'extension : un data URL "image/jpg" (non
// standard) serait rejeté par ImageService (qui n'accepte que "image/jpeg").
String mimeType = "jpg".equals(ext) ? "image/jpeg" : "image/" + ext;
String filename = (name == null || name.isBlank() ? "monstre" : name.replaceAll("[^a-zA-Z0-9._-]", "_")) + "." + ext;
try {
Image img = imageService.upload(filename, contentType,
Image img = imageService.upload(filename, mimeType,
new ByteArrayInputStream(bytes), bytes.length);
return img.getId();
} catch (RuntimeException e) {

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.Npc;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.NpcRepository;
import org.springframework.stereotype.Service;
@@ -102,6 +103,19 @@ public class NpcService {
npcRepository.deleteById(id);
}
/**
* Réordonne (et reclasse) les PNJ d'un dossier : {@code order} = position, et le
* dossier de chaque PNJ est posé à {@code folder} (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderNpcs(String folder, List<String> orderedIds) {
String f = normalizeFolder(folder);
ReorderSupport.reorder(orderedIds,
id -> npcRepository.findById(id).orElse(null),
(npc, i) -> { npc.setFolder(f); npc.setOrder(i); },
npcRepository::save);
}
public List<Npc> searchNpcs(String query) {
if (query == null || query.isBlank()) return List.of();
return npcRepository.searchByName(query.trim());

View File

@@ -2,6 +2,7 @@ package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Campaign;
import com.loremind.domain.campaigncontext.RandomTable;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.RandomTableEntry;
import com.loremind.domain.campaigncontext.ports.CampaignRepository;
import com.loremind.domain.campaigncontext.ports.RandomTableGenerator;
@@ -85,6 +86,15 @@ public class RandomTableService {
repository.deleteById(id);
}
/** Réordonne les tables aléatoires d'une campagne : {@code order} = position. */
@org.springframework.transaction.annotation.Transactional
public void reorderTables(List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> repository.findById(id).orElse(null),
(table, i) -> table.setOrder(i),
repository::save);
}
public List<RandomTable> searchTables(String query) {
if (query == null || query.isBlank()) return List.of();
return repository.searchByName(query.trim());

View File

@@ -1,10 +1,12 @@
package com.loremind.application.campaigncontext;
import com.loremind.domain.campaigncontext.Scene;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.campaigncontext.ports.SceneRepository;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@@ -92,6 +94,25 @@ public class SceneService {
return sceneRepository.existsById(id);
}
/**
* Réordonne les scènes d'un chapitre : {@code order} = position. Si {@code chapterId}
* diffère (scène déplacée vers un autre chapitre), on réaffecte le chapitre et on
* vide ses branches (elles ne valent que dans le chapitre d'origine). Transactionnel.
*/
@Transactional
public void reorderScenes(String chapterId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> sceneRepository.findById(id).orElse(null),
(scene, i) -> {
if (chapterId != null && !chapterId.isBlank() && !chapterId.equals(scene.getChapterId())) {
scene.setChapterId(chapterId);
scene.setBranches(new ArrayList<>());
}
scene.setOrder(i);
},
sceneRepository::save);
}
/**
* Vérifie les invariants du graphe narratif :
* 1. Pas d'auto-référence (scène qui pointe sur elle-même).

View File

@@ -1,6 +1,7 @@
package com.loremind.application.lorecontext;
import com.loremind.domain.lorecontext.LoreNode;
import com.loremind.domain.shared.ReorderSupport;
import com.loremind.domain.lorecontext.Page;
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
import com.loremind.domain.lorecontext.ports.PageRepository;
@@ -45,10 +46,20 @@ public class LoreNodeService {
.icon(changes.getIcon())
.parentId(changes.getParentId())
.loreId(changes.getLoreId())
.order(nextOrderFor(changes.getLoreId(), changes.getParentId()))
.build();
return loreNodeRepository.save(loreNode);
}
/** Prochain ordre pour un dossier neuf : après ses frères (même lore + même parent). */
private int nextOrderFor(String loreId, String parentId) {
if (loreId == null) return 0;
return loreNodeRepository.findByLoreId(loreId).stream()
.filter(n -> java.util.Objects.equals(n.getParentId(), parentId))
.mapToInt(LoreNode::getOrder)
.max().orElse(-1) + 1;
}
public Optional<LoreNode> getLoreNodeById(String id) {
return loreNodeRepository.findById(id);
}
@@ -57,6 +68,37 @@ public class LoreNodeService {
return loreNodeRepository.findAll();
}
/**
* Réordonne (et déplace) des dossiers frères : {@code order} = position, et
* {@code parentId} = dossier parent cible (null = racine). Évite les cycles : un
* dossier n'est pas réaffecté sous lui-même ni sous un de ses descendants.
*/
@Transactional
public void reorderNodes(String parentId, List<String> orderedIds) {
String parent = (parentId == null || parentId.isBlank()) ? null : parentId;
ReorderSupport.reorder(orderedIds,
id -> loreNodeRepository.findById(id).orElse(null),
(node, i) -> {
if (parent == null || !isSelfOrDescendant(parent, node.getId())) {
node.setParentId(parent);
}
node.setOrder(i);
},
loreNodeRepository::save);
}
/** True si {@code candidateId} est {@code nodeId} ou un de ses descendants (anti-cycle). */
private boolean isSelfOrDescendant(String candidateId, String nodeId) {
String cur = candidateId;
int guard = 0;
while (cur != null && guard++ < 100) {
if (cur.equals(nodeId)) return true;
LoreNode n = loreNodeRepository.findById(cur).orElse(null);
cur = n != null ? n.getParentId() : null;
}
return false;
}
public List<LoreNode> getLoreNodesByLoreId(String loreId) {
return loreNodeRepository.findByLoreId(loreId);
}

View File

@@ -5,6 +5,7 @@ import com.loremind.domain.lorecontext.ports.PageRepository;
import org.springframework.stereotype.Service;
import com.loremind.domain.shared.CollectionUtils;
import com.loremind.domain.shared.ReorderSupport;
import java.util.ArrayList;
import java.util.HashMap;
@@ -33,6 +34,7 @@ public class PageService {
.nodeId(nodeId)
.templateId(templateId)
.title(title)
.order(nextOrderFor(nodeId))
.values(new HashMap<>())
.tags(new ArrayList<>())
.relatedPageIds(new ArrayList<>())
@@ -40,6 +42,14 @@ public class PageService {
return pageRepository.save(page);
}
/** Prochain ordre pour une page neuve : après les pages existantes du même dossier. */
private int nextOrderFor(String nodeId) {
if (nodeId == null) return 0;
return pageRepository.findByNodeId(nodeId).stream()
.mapToInt(Page::getOrder)
.max().orElse(-1) + 1;
}
public Optional<Page> getPageById(String id) {
return pageRepository.findById(id);
}
@@ -87,4 +97,19 @@ public class PageService {
public void deletePage(String id) {
pageRepository.deleteById(id);
}
/**
* Réordonne (et déplace) les pages d'un dossier : {@code order} = position dans la
* liste, et {@code nodeId} = dossier cible (déplacement par glisser-déposer).
*/
@org.springframework.transaction.annotation.Transactional
public void reorderPages(String nodeId, List<String> orderedIds) {
ReorderSupport.reorder(orderedIds,
id -> pageRepository.findById(id).orElse(null),
(page, i) -> {
if (nodeId != null && !nodeId.isBlank()) page.setNodeId(nodeId);
page.setOrder(i);
},
pageRepository::save);
}
}

View File

@@ -24,6 +24,8 @@ public class LoreNode {
private String icon;
private String parentId; // Auto-référence pour l'arborescence
private String loreId; // Référence vers le Lore parent
/** Position du dossier parmi ses frères (glisser-déposer). */
private int order;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@@ -29,6 +29,9 @@ public class Page {
private String templateId;
private String title;
/** Position de la page dans son dossier (glisser-déposer). */
private int order;
/** Valeurs des champs dynamiques TEXT définis par le Template. */
private Map<String, String> values;

View File

@@ -0,0 +1,39 @@
package com.loremind.domain.shared;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.ObjIntConsumer;
/**
* Squelette commun du réordonnancement par glisser-déposer : parcourt la liste
* ordonnée d'ids, charge chaque entité, lui applique sa position (et toute
* réaffectation de parent via {@code applyOrder}), puis la sauve. Les ids inconnus
* sont ignorés. Évite de recopier cette boucle dans chaque service ordonnable.
*/
public final class ReorderSupport {
private ReorderSupport() {}
/**
* @param orderedIds liste ordonnée des ids (null = no-op)
* @param finder id → entité (ou null si introuvable)
* @param applyOrder (entité, index) → pose l'ordre et l'éventuel parent
* @param saver persiste l'entité
*/
public static <T> void reorder(List<String> orderedIds,
Function<String, T> finder,
ObjIntConsumer<T> applyOrder,
Consumer<T> saver) {
if (orderedIds == null) return;
int i = 0;
for (String id : orderedIds) {
T entity = finder.apply(id);
if (entity != null) {
applyOrder.accept(entity, i);
saver.accept(entity);
}
i++;
}
}
}

View File

@@ -36,6 +36,10 @@ public class LoreNodeJpaEntity {
@Column(name = "lore_id", nullable = false)
private Long loreId;
/** Position parmi les dossiers frères. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -45,6 +45,10 @@ public class PageJpaEntity {
@Column(nullable = false)
private String title;
/** Position dans le dossier. Colonne quotée : `order` est un mot-clé SQL. */
@Column(name = "\"order\"", nullable = false)
private int order;
@Column(name = "values_json", columnDefinition = "TEXT")
@Convert(converter = StringMapJsonConverter.class)
private Map<String, String> values;

View File

@@ -95,6 +95,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
.icon(jpaEntity.getIcon())
.parentId(jpaEntity.getParentId() != null ? jpaEntity.getParentId().toString() : null)
.loreId(jpaEntity.getLoreId().toString())
.order(jpaEntity.getOrder())
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.build();
@@ -111,6 +112,7 @@ public class PostgresLoreNodeRepository implements LoreNodeRepository {
.icon(loreNode.getIcon())
.parentId(parentId)
.loreId(Long.parseLong(loreNode.getLoreId()))
.order(loreNode.getOrder())
.createdAt(loreNode.getCreatedAt())
.updatedAt(loreNode.getUpdatedAt())
.build();

View File

@@ -91,6 +91,7 @@ public class PostgresPageRepository implements PageRepository {
.nodeId(e.getNodeId() != null ? e.getNodeId().toString() : null)
.templateId(e.getTemplateId() != null ? e.getTemplateId().toString() : null)
.title(e.getTitle())
.order(e.getOrder())
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
@@ -111,6 +112,7 @@ public class PostgresPageRepository implements PageRepository {
.templateId(p.getTemplateId() != null && !p.getTemplateId().isBlank()
? Long.parseLong(p.getTemplateId()) : null)
.title(p.getTitle())
.order(p.getOrder())
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())

View File

@@ -99,9 +99,10 @@ public class FoundryExportService {
CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId))
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
String foundryActorType = resolveActorType(campaign.getGameSystemId());
GameSystemJpaEntity gameSystem = resolveGameSystem(campaign.getGameSystemId());
List<TemplateField> npcTemplate = gameSystem != null ? gameSystem.getNpcTemplate() : null;
List<TemplateField> enemyTemplate = gameSystem != null ? gameSystem.getEnemyTemplate() : null;
String foundryActorType = gameSystem != null ? gameSystem.getFoundryActorType() : null;
AssetRegistry assets = new AssetRegistry();
@@ -275,23 +276,11 @@ public class FoundryExportService {
// ----- Resolution des champs PNJ/Ennemi via le template -----
private List<TemplateField> resolveTemplate(String gameSystemId, boolean npc) {
/** Résout le GameSystem UNE fois (templates PNJ/ennemi + type d'acteur en sont tirés). */
private GameSystemJpaEntity resolveGameSystem(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate())
.orElse(null);
} catch (NumberFormatException ex) {
return null;
}
}
private String resolveActorType(String gameSystemId) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(GameSystemJpaEntity::getFoundryActorType)
.orElse(null);
return gameSystemRepo.findById(Long.parseLong(gameSystemId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}

View File

@@ -0,0 +1,611 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.domain.shared.template.FieldType;
import com.loremind.domain.shared.template.TemplateField;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.*;
/**
* Export d'UNE campagne en livret PDF (joli document imprimable) : structure narrative
* (arcs/quetes/scenes), PNJ &amp; ennemis (bestiaire), lore, et battlemaps en illustration.
* <p>
* Rendu XHTML+CSS -&gt; PDF via openhtmltopdf (100 % JVM). Les images sont rincees,
* redimensionnees et re-encodees en JPEG (ImageIO + decodeur WebP TwelveMonkeys) puis
* embarquees en data-URI : garantit le rendu (WebP compris) et borne la taille du PDF.
*/
@Service
public class PdfExportService {
private static final Logger log = LoggerFactory.getLogger(PdfExportService.class);
/** Cotes max (px) avant re-encodage : portraits compacts, battlemaps/illustrations larges. */
private static final int PORTRAIT_MAX = 700;
private static final int ILLUSTRATION_MAX = 1500;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final GameSystemJpaRepository gameSystemRepo;
private final ImageJpaRepository imageRepo;
private final StoredFileJpaRepository storedFileRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final PageJpaRepository pageRepo;
private final TemplateJpaRepository templateRepo;
private final ImageStorage imageStorage;
private final FileStorage fileStorage;
public PdfExportService(CampaignJpaRepository campaignRepo, ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo,
NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo,
GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo,
StoredFileJpaRepository storedFileRepo,
LoreNodeJpaRepository loreNodeRepo, PageJpaRepository pageRepo,
TemplateJpaRepository templateRepo, ImageStorage imageStorage,
FileStorage fileStorage) {
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.gameSystemRepo = gameSystemRepo;
this.imageRepo = imageRepo;
this.storedFileRepo = storedFileRepo;
this.loreNodeRepo = loreNodeRepo;
this.pageRepo = pageRepo;
this.templateRepo = templateRepo;
this.imageStorage = imageStorage;
this.fileStorage = fileStorage;
}
/** Nom de la campagne (pour le nom de fichier). @throws NoSuchElementException si absente. */
public String campaignName(String campaignId) {
return campaign(campaignId).getName();
}
/** Construit le PDF complet de la campagne. @throws NoSuchElementException si absente. */
public byte[] export(String campaignId) {
CampaignJpaEntity campaign = campaign(campaignId);
String xhtml = buildXhtml(campaign);
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withHtmlContent(xhtml, null);
builder.toStream(out);
builder.run();
return out.toByteArray();
} catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du PDF", e);
}
}
private CampaignJpaEntity campaign(String campaignId) {
return campaignRepo.findById(Long.parseLong(campaignId))
.orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId));
}
// ====================================================================== XHTML
private String buildXhtml(CampaignJpaEntity campaign) {
List<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
StringBuilder body = new StringBuilder();
StringBuilder bookmarks = new StringBuilder();
cover(body, campaign);
narrative(body, bookmarks, campaign);
personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false);
personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true);
lore(body, bookmarks, campaign);
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<html xmlns=\"http://www.w3.org/1999/xhtml\"><head><meta charset=\"utf-8\"/>\n"
+ "<style>" + CSS + "</style>\n"
+ "<bookmarks>" + bookmarks + "</bookmarks>\n"
+ "</head><body>" + body + "</body></html>";
}
private void cover(StringBuilder b, CampaignJpaEntity c) {
b.append("<div class=\"cover\">");
b.append("<div class=\"subtitle\">Livret de campagne</div>");
b.append("<h1 class=\"cover-title\">").append(esc(c.getName())).append("</h1>");
if (notBlank(c.getDescription())) {
b.append("<div class=\"cover-desc\">").append(multiline(c.getDescription())).append("</div>");
}
b.append("</div>");
}
// ----- Structure narrative : arcs -> quetes -> scenes -----
private void narrative(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
// Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes.
List<ArcJpaEntity> arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder);
if (arcs.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"part-narrative\">Structure narrative</h1>");
bm.append(bookmark("Structure narrative", "part-narrative", () -> {
StringBuilder sub = new StringBuilder();
for (ArcJpaEntity arc : arcs) sub.append(bookmark(arc.getName(), "arc-" + arc.getId(), null));
return sub.toString();
}));
for (ArcJpaEntity arc : arcs) {
b.append("<div class=\"arc\"><h2 class=\"arc-head\" id=\"arc-").append(arc.getId()).append("\">")
.append("<span class=\"eyebrow eyebrow-light\">Arc</span>")
.append(esc(arc.getName())).append("</h2>");
illustrations(b, arc.getIllustrationImageIds());
block(b, "Description", arc.getDescription());
block(b, "Themes", arc.getThemes());
block(b, "Enjeux", arc.getStakes());
block(b, "Recompenses", arc.getRewards());
block(b, "Resolution", arc.getResolution());
block(b, "Notes MJ", arc.getGmNotes());
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
b.append("<div class=\"quest\"><div class=\"quest-head\">")
.append("<span class=\"eyebrow\">Quête</span>")
.append(esc(ch.getName())).append("</div>");
illustrations(b, ch.getIllustrationImageIds());
block(b, "Description", ch.getDescription());
block(b, "Objectifs joueurs", ch.getPlayerObjectives());
block(b, "Enjeux narratifs", ch.getNarrativeStakes());
block(b, "Notes MJ", ch.getGmNotes());
for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) {
scene(b, sc);
}
b.append("</div>");
}
b.append("</div>");
}
}
private void scene(StringBuilder b, SceneJpaEntity sc) {
b.append("<div class=\"scene\"><div class=\"scene-head\">")
.append("<span class=\"eyebrow\">Scène</span>")
.append(esc(sc.getName())).append("</div>");
block(b, "Lieu", sc.getLocation());
block(b, "Moment", sc.getTiming());
block(b, "Ambiance", sc.getAtmosphere());
block(b, "Narration joueur", sc.getPlayerNarration());
block(b, "Notes secretes MJ", sc.getGmSecretNotes());
block(b, "Choix & consequences", sc.getChoicesConsequences());
block(b, "Difficulte du combat", sc.getCombatDifficulty());
illustrations(b, sc.getIllustrationImageIds());
// Battlemap (image uniquement ; les videos ne sont pas rendables en PDF).
String battlemap = fileImageUri(sc.getBattlemapMediaFileId());
if (battlemap != null) {
b.append("<div class=\"illus\"><img src=\"").append(battlemap).append("\"/></div>");
}
b.append("</div>");
}
// ----- PNJ / Ennemis (groupes par dossier) -----
/** Une fiche persona generique pour la mise en page (PNJ ou ennemi). */
private record PersonaRow(int order, String name, String folder, String level, String portraitId,
Map<String, String> values, Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues, Map<String, String> foundryStats) {}
private List<PersonaRow> npcEntries(CampaignJpaEntity c) {
List<PersonaRow> out = new ArrayList<>();
for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
out.add(new PersonaRow(n.getOrder(), n.getName(), n.getFolder(), null, n.getPortraitImageId(),
n.getValues(), n.getKeyValueValues(), n.getImageValues(), null));
}
return out;
}
private List<PersonaRow> enemyEntries(CampaignJpaEntity c) {
List<PersonaRow> out = new ArrayList<>();
for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(c.getId())) {
out.add(new PersonaRow(e.getOrder(), e.getName(), e.getFolder(), e.getLevel(), e.getPortraitImageId(),
e.getValues(), e.getKeyValueValues(), e.getImageValues(), e.getFoundryStats()));
}
return out;
}
private void personas(StringBuilder b, StringBuilder bm, String partId, String title,
List<PersonaRow> rows, List<TemplateField> template, boolean enemy) {
if (rows.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"").append(partId).append("\">").append(esc(title)).append("</h1>");
bm.append(bookmark(title, partId, null));
// Groupement par dossier (dossiers tries, non-classes en dernier).
Map<String, List<PersonaRow>> byFolder = new TreeMap<>();
List<PersonaRow> ungrouped = new ArrayList<>();
for (PersonaRow r : rows) {
String f = r.folder() != null ? r.folder().trim() : "";
if (f.isEmpty()) ungrouped.add(r);
else byFolder.computeIfAbsent(f, k -> new ArrayList<>()).add(r);
}
for (Map.Entry<String, List<PersonaRow>> e : byFolder.entrySet()) {
b.append("<h3 class=\"folder\">").append(esc(e.getKey().replace("/", " / "))).append("</h3>");
e.getValue().sort(java.util.Comparator.comparingInt(PersonaRow::order));
for (PersonaRow r : e.getValue()) personaCard(b, r, template, enemy);
}
if (!ungrouped.isEmpty()) {
if (!byFolder.isEmpty()) b.append("<h3 class=\"folder\">Sans dossier</h3>");
ungrouped.sort(java.util.Comparator.comparingInt(PersonaRow::order));
for (PersonaRow r : ungrouped) personaCard(b, r, template, enemy);
}
}
private void personaCard(StringBuilder b, PersonaRow r, List<TemplateField> template, boolean enemy) {
// Mise en page en TABLE (portrait | contenu) : openhtmltopdf gere mal le float
// + overflow (le texte se superposait au portrait).
b.append("<div class=\"card\"><table class=\"persona\"><tr>");
String portrait = imageUri(r.portraitId(), PORTRAIT_MAX);
if (portrait != null) {
b.append("<td class=\"persona-portrait\"><img src=\"").append(portrait).append("\"/></td>");
}
b.append("<td class=\"persona-content\">");
b.append("<div class=\"persona-name\">").append(esc(r.name()));
if (notBlank(r.level())) b.append(" <span class=\"level\">Niv. ").append(esc(r.level())).append("</span>");
b.append("</div>");
renderFields(b, template, r.values(), r.keyValueValues(), r.imageValues(), null);
if (enemy && r.foundryStats() != null) {
Map<String, String> clean = cleanStats(r.foundryStats());
if (!clean.isEmpty()) {
b.append("<div class=\"field-label\">Statistiques</div><table class=\"stats-table\"><tbody>");
for (Map.Entry<String, String> s : clean.entrySet()) {
b.append("<tr><th>").append(esc(s.getKey())).append("</th><td>")
.append(esc(s.getValue())).append("</td></tr>");
}
b.append("</tbody></table>");
}
}
b.append("</td></tr></table></div>");
}
/** Valeurs de stats considerees comme "vides"/bruit (masquees dans le livret). */
private static final Set<String> STAT_NOISE_VALUES = Set.of("0", "0.0", "false", "none", "null", "", "-", "");
/** Premier segment de chemin a elaguer du libelle (purement structurel). */
private static final Set<String> STAT_DROP_PREFIX = Set.of("attributes", "details", "system", "data");
/**
* Nettoie le snapshot de stats Foundry pour le livret : retire le bruit (valeurs
* 0/false/none/vides, options techniques type rollMode) et humanise les clés
* ("attributes.hp.value" -> "Hp value"). Conserve l'ordre alphabetique des clés.
*/
private static Map<String, String> cleanStats(Map<String, String> stats) {
Map<String, String> out = new LinkedHashMap<>();
for (Map.Entry<String, String> e : new TreeMap<>(stats).entrySet()) {
String key = e.getKey();
String val = e.getValue();
if (key == null || val == null) continue;
String v = val.trim();
if (STAT_NOISE_VALUES.contains(v.toLowerCase())) continue;
String lk = key.toLowerCase();
if (lk.contains("rollmode") || lk.endsWith(".defaultrollmode")) continue;
out.put(humanizeStatKey(key), v);
}
return out;
}
/** "attributes.hp.value" -> "Hp value" ; "details.creatureType" -> "Creature type". */
private static String humanizeStatKey(String key) {
String[] parts = key.split("\\.");
int start = (parts.length > 1 && STAT_DROP_PREFIX.contains(parts[0].toLowerCase())) ? 1 : 0;
String s = String.join(" ", Arrays.asList(parts).subList(start, parts.length));
s = s.replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase().trim(); // camelCase -> mots
return s.isEmpty() ? key : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
// ----- Lore (pages groupees par dossier) -----
private void lore(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) {
String loreId = campaign.getLoreId();
if (loreId == null || loreId.isBlank()) return;
Long lid;
try { lid = Long.parseLong(loreId); } catch (NumberFormatException ex) { return; }
List<PageJpaEntity> pages = pageRepo.findByLoreId(lid);
if (pages.isEmpty()) return;
b.append("<h1 class=\"part\" id=\"part-lore\">Lore</h1>");
bm.append(bookmark("Lore", "part-lore", null));
// Chemins de dossiers (LoreNode) + templates par id.
Map<Long, LoreNodeJpaEntity> nodes = new HashMap<>();
for (LoreNodeJpaEntity n : loreNodeRepo.findByLoreId(lid)) nodes.put(n.getId(), n);
Map<Long, TemplateJpaEntity> templates = new HashMap<>();
for (TemplateJpaEntity t : templateRepo.findByLoreId(lid)) templates.put(t.getId(), t);
Map<String, List<PageJpaEntity>> byPath = new TreeMap<>();
for (PageJpaEntity p : pages) {
byPath.computeIfAbsent(nodePath(p.getNodeId(), nodes), k -> new ArrayList<>()).add(p);
}
for (Map.Entry<String, List<PageJpaEntity>> e : byPath.entrySet()) {
String label = e.getKey().isEmpty() ? "Sans dossier" : e.getKey();
b.append("<h3 class=\"folder\">").append(esc(label)).append("</h3>");
e.getValue().sort(java.util.Comparator.comparingInt(PageJpaEntity::getOrder));
for (PageJpaEntity p : e.getValue()) {
b.append("<div class=\"card\"><div class=\"card-body\"><div class=\"persona-name\">")
.append(esc(p.getTitle())).append("</div>");
List<TemplateField> tpl = p.getTemplateId() != null
? fieldsOf(templates.get(p.getTemplateId())) : null;
renderFields(b, tpl, p.getValues(), p.getKeyValueValues(), p.getImageValues(), p.getTableValues());
b.append("</div></div>");
}
}
}
private String nodePath(Long nodeId, Map<Long, LoreNodeJpaEntity> nodes) {
if (nodeId == null) return "";
Deque<String> parts = new ArrayDeque<>();
Long cur = nodeId;
int guard = 0;
while (cur != null && guard++ < 30) {
LoreNodeJpaEntity n = nodes.get(cur);
if (n == null) break;
if (n.getName() != null) parts.addFirst(n.getName());
cur = n.getParentId();
}
return String.join(" / ", parts);
}
// ----- Rendu des champs pilotes par template -----
private void renderFields(StringBuilder b, List<TemplateField> template, Map<String, String> values,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues,
Map<String, List<Map<String, String>>> tableValues) {
// Repli : pas de template -> paires brutes cle=valeur.
if (template == null || template.isEmpty()) {
if (values != null) {
for (Map.Entry<String, String> e : values.entrySet()) block(b, e.getKey(), e.getValue());
}
return;
}
for (TemplateField f : template) {
if (f == null || f.getName() == null || f.getType() == null) continue;
FieldType type = f.getType();
switch (type) {
case TEXT, NUMBER -> block(b, f.getName(), values != null ? values.get(f.getName()) : null);
case KEY_VALUE_LIST -> {
Map<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
List<String> labels = f.getLabels();
if (inner != null && labels != null) {
StringBuilder rows = new StringBuilder();
for (String label : labels) {
String v = inner.get(label);
if (notBlank(v)) rows.append("<tr><th>").append(esc(label)).append("</th><td>")
.append(esc(v)).append("</td></tr>");
}
if (rows.length() > 0) {
b.append("<div class=\"field-label\">").append(esc(f.getName()))
.append("</div><table class=\"stats-table\"><tbody>").append(rows)
.append("</tbody></table>");
}
}
}
case IMAGE -> {
List<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
illustrations(b, ids);
}
case TABLE -> {
List<Map<String, String>> data = tableValues != null ? tableValues.get(f.getName()) : null;
List<String> cols = f.getLabels();
if (data != null && !data.isEmpty() && cols != null && !cols.isEmpty()) {
b.append("<div class=\"field-label\">").append(esc(f.getName()))
.append("</div><table class=\"stats-table\"><thead><tr>");
for (String col : cols) b.append("<th>").append(esc(col)).append("</th>");
b.append("</tr></thead><tbody>");
for (Map<String, String> row : data) {
b.append("<tr>");
for (String col : cols) b.append("<td>").append(esc(row.get(col))).append("</td>");
b.append("</tr>");
}
b.append("</tbody></table>");
}
}
}
}
}
/** Bloc "label + valeur multiligne" si la valeur est non vide. */
private void block(StringBuilder b, String label, String value) {
if (!notBlank(value)) return;
b.append("<div class=\"field\"><div class=\"field-label\">").append(esc(label))
.append("</div><div class=\"field-value\">").append(multiline(value)).append("</div></div>");
}
/** Galerie d'illustrations (liste d'ids d'images) -> blocs image. */
private void illustrations(StringBuilder b, List<String> imageIds) {
if (imageIds == null) return;
for (String id : imageIds) {
String uri = imageUri(id, ILLUSTRATION_MAX);
if (uri != null) b.append("<div class=\"illus\"><img src=\"").append(uri).append("\"/></div>");
}
}
// ====================================================================== Images
/** Data-URI JPEG redimensionne d'une image LoreMind, ou null. */
private String imageUri(String imageId, int maxDim) {
if (imageId == null || imageId.isBlank()) return null;
ImageJpaEntity e;
try {
e = imageRepo.findById(Long.parseLong(imageId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
return encode(imageStorage.download(e.getStorageKey()), maxDim, imageId);
}
/** Data-URI d'une battlemap (fichier stocke) seulement si c'est une image (pas une video). */
private String fileImageUri(String fileId) {
if (fileId == null || fileId.isBlank()) return null;
StoredFileJpaEntity e;
try {
e = storedFileRepo.findById(Long.parseLong(fileId)).orElse(null);
} catch (NumberFormatException ex) {
return null;
}
if (e == null) return null;
String ct = e.getContentType();
if (ct == null || !ct.startsWith("image/")) return null; // mp4/webm -> ignore
return encode(fileStorage.download(e.getStorageKey()), ILLUSTRATION_MAX, fileId);
}
/** Lit un flux image, le redimensionne (max maxDim) et le re-encode en data-URI JPEG. */
private String encode(InputStream in, int maxDim, String ref) {
if (in == null) return null;
try (in) {
BufferedImage src = ImageIO.read(in);
if (src == null) {
log.debug("Image PDF ignoree (format non decode) : {}", ref);
return null;
}
int w = src.getWidth(), h = src.getHeight();
double scale = Math.min(1.0, (double) maxDim / Math.max(w, h));
int nw = Math.max(1, (int) Math.round(w * scale));
int nh = Math.max(1, (int) Math.round(h * scale));
BufferedImage dst = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB);
Graphics2D g = dst.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setColor(Color.WHITE); // fond blanc : aplatit la transparence (JPEG sans alpha)
g.fillRect(0, 0, nw, nh);
g.drawImage(src, 0, 0, nw, nh, null);
g.dispose();
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(dst, "jpeg", out);
return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
} catch (IOException | RuntimeException ex) {
log.warn("Image PDF ignoree ({}) : {}", ref, ex.getMessage());
return null;
}
}
// ====================================================================== Helpers
private List<TemplateField> resolveTemplate(String gameSystemId, boolean npc) {
if (gameSystemId == null || gameSystemId.isBlank()) return null;
try {
return gameSystemRepo.findById(Long.parseLong(gameSystemId))
.map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate())
.orElse(null);
} catch (NumberFormatException ex) {
return null;
}
}
private static List<TemplateField> fieldsOf(TemplateJpaEntity t) {
return t != null ? t.getFields() : null;
}
/** Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes. */
private static <T> List<T> sortByOrder(List<T> list, java.util.function.ToIntFunction<T> order) {
List<T> copy = new ArrayList<>(list);
copy.sort(java.util.Comparator.comparingInt(order));
return copy;
}
private static boolean notBlank(String s) {
return s != null && !s.isBlank();
}
/** Un signet PDF (avec enfants optionnels fournis par `children`). */
private static String bookmark(String name, String anchor, java.util.function.Supplier<String> children) {
String inner = children != null ? children.get() : "";
return "<bookmark name=\"" + esc(name == null ? "" : name) + "\" href=\"#" + anchor + "\">" + inner + "</bookmark>";
}
/** Echappe le texte pour XHTML et retire les caracteres interdits en XML 1.0. */
private static String esc(String s) {
if (s == null) return "";
StringBuilder b = new StringBuilder(s.length() + 16);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 && c != '\n' && c != '\t') continue; // \r normalise en amont
switch (c) {
case '&' -> b.append("&amp;");
case '<' -> b.append("&lt;");
case '>' -> b.append("&gt;");
case '"' -> b.append("&quot;");
default -> b.append(c);
}
}
return b.toString();
}
/** Comme esc, mais les sauts de ligne deviennent des &lt;br/&gt;. */
private static String multiline(String s) {
if (s == null) return "";
return esc(s.replace("\r\n", "\n").replace('\r', '\n')).replace("\n", "<br/>");
}
// CSS print (CSS 2.1 + paged media supporte par openhtmltopdf : pas de flexbox/grid).
private static final String CSS = """
@page { size: A4; margin: 2cm 1.7cm;
@bottom-center { content: counter(page); font-size: 9pt; color: #999; } }
body { font-family: 'Helvetica', sans-serif; font-size: 10.5pt; color: #222; line-height: 1.45; }
.cover { text-align: center; padding-top: 7cm; page-break-after: always; }
.cover .subtitle { font-size: 12pt; letter-spacing: .3em; text-transform: uppercase; color: #8a7bc8; }
.cover-title { font-size: 32pt; text-transform: uppercase; letter-spacing: .03em; color: #2e2a4a; margin: .4cm 0; border: none; }
.cover-desc { margin: 1cm auto 0; max-width: 13cm; color: #444; text-align: left; }
.cover .meta { margin-top: 1.2cm; color: #777; font-size: 10pt; }
h1.part { page-break-before: always; font-size: 21pt; text-transform: uppercase; letter-spacing: .05em;
color: #2e2a4a; border-bottom: 2pt solid #8a7bc8; padding-bottom: 3pt; margin: 0 0 .5cm; }
h2 { font-size: 16pt; color: #4a3f7a; margin: 1.1em 0 .3em; }
h3.folder { color: #8a7bc8; text-transform: uppercase; letter-spacing: .04em; font-size: 11pt;
border-bottom: 1pt dotted #ccc; margin-top: 1em; }
/* Hierarchie narrative : Arc (bandeau) > Quete (en-tete teinte) > Scene (carte). */
.arc { margin: .5cm 0 .3cm; }
.arc-head { background: #2e2a4a; color: #fff; font-size: 15pt; font-weight: bold;
padding: .22cm .4cm; border-radius: 4pt; margin: 0 0 .35cm; }
.quest { margin: .5cm 0 .35cm; }
.quest-head { background: #f1eef9; border-left: 5pt solid #8a7bc8; padding: .15cm .4cm;
font-size: 13pt; font-weight: bold; color: #463b78; }
.scene { margin: .3cm 0 .35cm .35cm; border: 1pt solid #e6e6ee; border-left: 3pt solid #9bb06a;
border-radius: 4pt; padding: .25cm .4cm; background: #fbfbfd; }
.scene-head { font-size: 12pt; font-weight: bold; color: #5a6e3a; margin-bottom: .12cm; }
.eyebrow { display: block; font-size: 7pt; text-transform: uppercase; letter-spacing: .15em;
font-weight: normal; color: #9182bd; }
.eyebrow-light { color: #c9c0e8; }
.field { margin: .22cm 0; }
.field-label { font-size: 8pt; text-transform: uppercase; letter-spacing: .07em;
color: #8076a3; font-weight: bold; margin-bottom: .03cm; }
.field-value { color: #222; }
.card { page-break-inside: avoid; border: 1pt solid #e2e2e2; border-radius: 4pt;
padding: .35cm .4cm; margin: .35cm 0; background: #fafafa; }
.card-body { display: block; }
.persona { width: 100%; border-collapse: collapse; }
.persona-portrait { width: 3cm; vertical-align: top; padding: 0 .45cm 0 0; }
.persona-portrait img { width: 3cm; border: 1pt solid #ccc; border-radius: 3pt; }
.persona-content { vertical-align: top; }
.persona-name { font-size: 12pt; font-weight: bold; color: #2e2a4a; margin-bottom: .12cm; }
.level { font-size: 9pt; color: #8a7bc8; font-weight: normal; }
.illus { margin: .3cm 0; }
.illus img { width: 100%; border: 1pt solid #ccc; border-radius: 3pt; }
.stats-table { width: 100%; border-collapse: collapse; font-size: 9pt; margin: .15cm 0 .3cm; }
.stats-table th, .stats-table td { border: 1pt solid #e0e0e0; padding: 2pt 5pt; text-align: left; vertical-align: top; }
.stats-table th { background: #f0eef7; width: 35%; color: #555; }
""";
}

View File

@@ -70,4 +70,13 @@ public class ArcController {
}
return ResponseEntity.ok(arcService.getDeletionImpact(id));
}
/** Réordonne les arcs (drag-and-drop) : order = position dans orderedIds. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
arcService.reorderArcs(req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(List<String> orderedIds) {}
}

View File

@@ -89,4 +89,13 @@ public class ChapterController {
}
return ResponseEntity.ok(chapterService.getDeletionImpact(id));
}
/** Réordonne (et déplace) les chapitres d'un arc : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
chapterService.reorderChapters(req.arcId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String arcId, List<String> orderedIds) {}
}

View File

@@ -57,6 +57,15 @@ public class EnemyController {
return ResponseEntity.noContent().build();
}
/** Réordonne (et reclasse) les ennemis d'un dossier : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
enemyService.reorderEnemies(req.folder(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String folder, List<String> orderedIds) {}
private EnemyService.EnemyData toData(EnemyRequest req) {
return new EnemyService.EnemyData(
req.name(), req.level(), req.folder(),

View File

@@ -109,4 +109,13 @@ public class LoreNodeController {
}
return ResponseEntity.ok(loreNodeService.getDeletionImpact(id));
}
/** Réordonne (et déplace) des dossiers : order = position, parentId = parent cible. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
loreNodeService.reorderNodes(req.parentId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String parentId, List<String> orderedIds) {}
}

View File

@@ -73,6 +73,15 @@ public class NpcController {
return ResponseEntity.noContent().build();
}
/** Réordonne (et reclasse) les PNJ d'un dossier : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
npcService.reorderNpcs(req.folder(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String folder, List<String> orderedIds) {}
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
return new NpcService.NpcData(
dto.getName(),

View File

@@ -86,4 +86,13 @@ public class PageController {
pageService.deletePage(id);
return ResponseEntity.noContent().build();
}
/** Réordonne (et déplace) les pages d'un dossier : order = position, nodeId = cible. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
pageService.reorderPages(req.nodeId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String nodeId, List<String> orderedIds) {}
}

View File

@@ -0,0 +1,57 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.transfer.pdf.PdfExportService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.NoSuchElementException;
/**
* Export d'une campagne en livret PDF.
* <p>
* {@code GET /api/campaigns/{campaignId}/pdf-export} -> application/pdf (structure
* narrative + PNJ/ennemis + lore + battlemaps).
*/
@RestController
@RequestMapping("/api/campaigns/{campaignId}/pdf-export")
public class PdfExportController {
private final PdfExportService pdfExportService;
public PdfExportController(PdfExportService pdfExportService) {
this.pdfExportService = pdfExportService;
}
@GetMapping(produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> export(@PathVariable String campaignId) {
byte[] pdf;
String name;
try {
name = pdfExportService.campaignName(campaignId);
pdf = pdfExportService.export(campaignId);
} catch (NoSuchElementException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}
String filename = slug(name) + ".pdf";
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf);
}
/** Nom de fichier sur : alphanum + tirets, le reste en "_". */
private static String slug(String name) {
if (name == null || name.isBlank()) return "campagne";
String s = name.trim().replaceAll("[^a-zA-Z0-9-_]+", "_").replaceAll("_+", "_");
s = s.replaceAll("^_|_$", "");
return s.isBlank() ? "campagne" : s;
}
}

View File

@@ -90,6 +90,15 @@ public class RandomTableController {
}
}
/** Réordonne les tables aléatoires : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
service.reorderTables(req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(List<String> orderedIds) {}
public record GenerateRequest(String campaignId, String description, String diceFormula) {}
public record ImproviseRequest(String campaignId, String tableName, String resultLabel, String resultDetail) {}

View File

@@ -62,4 +62,13 @@ public class SceneController {
sceneService.deleteScene(id);
return ResponseEntity.noContent().build();
}
/** Réordonne (et déplace) les scènes d'un chapitre : order = position. */
@PutMapping("/reorder")
public ResponseEntity<Void> reorder(@RequestBody ReorderRequest req) {
sceneService.reorderScenes(req.chapterId(), req.orderedIds());
return ResponseEntity.noContent().build();
}
public record ReorderRequest(String chapterId, List<String> orderedIds) {}
}

View File

@@ -14,4 +14,5 @@ public class LoreNodeDTO {
private String icon;
private String parentId;
private String loreId;
private int order;
}

View File

@@ -17,6 +17,7 @@ public class PageDTO {
private String nodeId;
private String templateId;
private String title;
private int order;
private Map<String, String> values;
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
private Map<String, List<String>> imageValues;

View File

@@ -21,6 +21,7 @@ public class LoreNodeMapper {
dto.setIcon(loreNode.getIcon());
dto.setParentId(loreNode.getParentId());
dto.setLoreId(loreNode.getLoreId());
dto.setOrder(loreNode.getOrder());
return dto;
}
@@ -35,6 +36,7 @@ public class LoreNodeMapper {
.icon(dto.getIcon())
.parentId(dto.getParentId())
.loreId(dto.getLoreId())
.order(dto.getOrder())
.build();
}
}

View File

@@ -21,6 +21,7 @@ public class PageMapper {
dto.setNodeId(page.getNodeId());
dto.setTemplateId(page.getTemplateId());
dto.setTitle(page.getTitle());
dto.setOrder(page.getOrder());
dto.setValues(CollectionUtils.copyMap(page.getValues()));
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
@@ -41,6 +42,7 @@ public class PageMapper {
.nodeId(dto.getNodeId())
.templateId(dto.getTemplateId())
.title(dto.getTitle())
.order(dto.getOrder())
.values(CollectionUtils.copyMap(dto.getValues()))
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))

View File

@@ -0,0 +1,9 @@
-- ============================================================================
-- V6 : colonne `order` sur les pages et dossiers de lore (réordonnancement
-- manuel par glisser-déposer, comme arcs/chapitres/scènes).
-- ============================================================================
-- `order` est un mot-clé SQL -> colonne quotée (cohérent avec arc/chapter/scene).
-- Défaut 0 : les lignes existantes prennent 0 (ordre indéfini jusqu'au 1er drag).
alter table pages add column "order" integer not null default 0;
alter table lore_nodes add column "order" integer not null default 0;

View File

@@ -0,0 +1,50 @@
package com.loremind.infrastructure.transfer.pdf;
import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity;
import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity;
import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository;
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* Export PDF d'une campagne : le pipeline XHTML -> openhtmltopdf produit bien un PDF.
*/
@SpringBootTest
@Transactional
class PdfExportServiceTest {
@Autowired private PdfExportService pdfExportService;
@Autowired private CampaignJpaRepository campaignRepo;
@Autowired private EnemyJpaRepository enemyRepo;
@Autowired private NpcJpaRepository npcRepo;
@Test
void exportsCampaignToPdf() {
CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
.name("Les éclats stellaires").description("Une campagne de test & démo <héros>.")
.arcsCount(0).build());
npcRepo.save(NpcJpaEntity.builder()
.campaignId(camp.getId()).name("Azrak").folder("Azrak").order(0)
.values(Map.of("Notes", "Marchand suspect.\nDeuxième ligne.")).build());
enemyRepo.save(EnemyJpaEntity.builder()
.campaignId(camp.getId()).name("Bandit").folder("Foundry/Briarban").order(0)
.level("3").foundryStats(Map.of("attributes.hp.value", "11")).build());
byte[] pdf = pdfExportService.export(String.valueOf(camp.getId()));
assertNotNull(pdf);
assertTrue(pdf.length > 1000, "le PDF doit avoir un contenu substantiel");
// Signature de fichier PDF (%PDF-).
assertEquals("%PDF-", new String(pdf, 0, 5, java.nio.charset.StandardCharsets.US_ASCII));
assertEquals("Les éclats stellaires", pdfExportService.campaignName(String.valueOf(camp.getId())));
}
}