Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
All checks were successful
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<>())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 & ennemis (bestiaire), lore, et battlemaps en illustration.
|
||||
* <p>
|
||||
* Rendu XHTML+CSS -> 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("&");
|
||||
case '<' -> b.append("<");
|
||||
case '>' -> b.append(">");
|
||||
case '"' -> b.append(""");
|
||||
default -> b.append(c);
|
||||
}
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
|
||||
/** Comme esc, mais les sauts de ligne deviennent des <br/>. */
|
||||
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; }
|
||||
""";
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,5 @@ public class LoreNodeDTO {
|
||||
private String icon;
|
||||
private String parentId;
|
||||
private String loreId;
|
||||
private int order;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
9
core/src/main/resources/db/migration/V6__lore_order.sql
Normal file
9
core/src/main/resources/db/migration/V6__lore_order.sql
Normal 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;
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user