diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
index 7701c20..ccb57de 100644
--- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
+++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java
@@ -48,7 +48,7 @@ public class CampaignService {
this.playthroughService = playthroughService;
}
- public record CampaignData(String name, String description, String loreId, String gameSystemId) {}
+ public record CampaignData(String name, String description, String loreId, String gameSystemId, int playerCount) {}
public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
@@ -59,6 +59,7 @@ public class CampaignService {
.loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0)
+ .playerCount(data.playerCount())
.build();
Campaign saved = campaignRepository.save(campaign);
@@ -87,6 +88,7 @@ public class CampaignService {
campaign.setDescription(data.description());
campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId()));
+ campaign.setPlayerCount(data.playerCount());
return campaignRepository.save(campaign);
}
diff --git a/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java
index 172a49d..19bef2b 100644
--- a/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java
+++ b/core/src/main/java/com/loremind/application/conversationcontext/ConversationService.java
@@ -11,11 +11,9 @@ import java.util.Optional;
/**
* Service d'application du contexte Conversation.
- *
* Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat).
- *
* Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine,
diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
index e6dfee1..feec046 100644
--- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
+++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java
@@ -24,6 +24,9 @@ public class Campaign {
private LocalDateTime updatedAt;
private int arcsCount;
+ /** Nombre de joueurs attendus à la table (métadonnée saisie à la création). */
+ private int playerCount;
+
/**
* Référence faible vers un Lore. Nullable.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
index 886fcdf..1c33c15 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java
@@ -37,6 +37,9 @@ public class CampaignJpaEntity {
@Column(name = "arcs_count", nullable = false)
private int arcsCount;
+ @Column(name = "player_count", nullable = false)
+ private int playerCount;
+
/**
* ID du Lore associé (nullable).
* Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte.
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
index 5023bbc..cdc036e 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java
@@ -73,6 +73,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt())
.arcsCount(jpaEntity.getArcsCount())
+ .playerCount(jpaEntity.getPlayerCount())
.loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId())
.build();
@@ -87,6 +88,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.createdAt(campaign.getCreatedAt())
.updatedAt(campaign.getUpdatedAt())
.arcsCount(campaign.getArcsCount())
+ .playerCount(campaign.getPlayerCount())
.loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId())
.build();
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java
new file mode 100644
index 0000000..edcea92
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportRequest.java
@@ -0,0 +1,31 @@
+package com.loremind.infrastructure.transfer;
+
+/**
+ * Périmètre d'un export de contenu.
+ *
+ * - {@code campaignId == null} → Sauvegarde complète : tout le contenu de la base.
+ * - {@code campaignId != null} → export ciblé : la campagne et sa clôture (arcs →
+ * chapitres → scènes, PNJ, ennemis, catalogues, tables, son système de jeu), plus
+ * éventuellement son univers (lore) et son espace de jeu (parties/sessions/feuilles de perso),
+ * selon les options.
+ *
+ * Les options ne s'appliquent qu'à l'export ciblé ; une sauvegarde complète prend tout.
+ *
+ * @param campaignId campagne à exporter, ou {@code null} pour tout exporter
+ * @param includeLore inclure l'univers lié (lore, dossiers, templates, pages)
+ * @param includePlay inclure l'espace de jeu (parties, sessions, journal, flags,
+ * progression de quêtes, feuilles de personnages)
+ * @param includeImages inclure les binaires d'images/fichiers référencés (sinon : métadonnées seules)
+ */
+public record ExportRequest(Long campaignId, boolean includeLore, boolean includePlay, boolean includeImages) {
+
+ /** Sauvegarde complète : toute la base, jeu et images compris. */
+ public static ExportRequest full() {
+ return new ExportRequest(null, true, true, true);
+ }
+
+ /** Vrai si l'export couvre toute la base (pas de campagne ciblée). */
+ public boolean isFull() {
+ return campaignId == null;
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java
index fea16b7..bcc01de 100644
--- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java
@@ -2,6 +2,7 @@ package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
+import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
@@ -39,7 +40,7 @@ import java.util.zip.ZipOutputStream;
@Service
public class ExportService {
- private static final int FORMAT_VERSION = 1;
+ private static final int FORMAT_VERSION = 2;
// Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME
// format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
@@ -63,6 +64,11 @@ public class ExportService {
private final ImageStorage imageStorage;
private final StoredFileJpaRepository storedFileRepo;
private final FileStorage fileStorage;
+ private final PlaythroughJpaRepository playthroughRepo;
+ private final SessionJpaRepository sessionRepo;
+ private final SessionEntryJpaRepository sessionEntryRepo;
+ private final PlaythroughFlagJpaRepository playthroughFlagRepo;
+ private final QuestProgressionJpaRepository questProgressionRepo;
private final ObjectMapper objectMapper;
private final String appVersion;
@@ -84,6 +90,11 @@ public class ExportService {
ImageStorage imageStorage,
StoredFileJpaRepository storedFileRepo,
FileStorage fileStorage,
+ PlaythroughJpaRepository playthroughRepo,
+ SessionJpaRepository sessionRepo,
+ SessionEntryJpaRepository sessionEntryRepo,
+ PlaythroughFlagJpaRepository playthroughFlagRepo,
+ QuestProgressionJpaRepository questProgressionRepo,
ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo;
@@ -104,55 +115,183 @@ public class ExportService {
this.imageStorage = imageStorage;
this.storedFileRepo = storedFileRepo;
this.fileStorage = fileStorage;
+ this.playthroughRepo = playthroughRepo;
+ this.sessionRepo = sessionRepo;
+ this.sessionEntryRepo = sessionEntryRepo;
+ this.playthroughFlagRepo = playthroughFlagRepo;
+ this.questProgressionRepo = questProgressionRepo;
this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
}
+ /** Sauvegarde complète (rétro-compat) : tout le contenu de la base. */
+ public ContentExport buildExport(String exportedAt) {
+ return buildExport(exportedAt, ExportRequest.full());
+ }
+
/**
- * Charge toutes les entites du perimetre et les mappe vers les DTO plats.
+ * Construit l'export selon le périmètre demandé : {@link ExportRequest#isFull() sauvegarde
+ * complète} (toute la base) ou export ciblé d'une campagne et de sa clôture.
*
* @param exportedAt horodatage ISO stampe par la couche appelante (controller)
*/
- public ContentExport buildExport(String exportedAt) {
+ public ContentExport buildExport(String exportedAt, ExportRequest req) {
+ return req.isFull() ? buildFullExport(exportedAt) : buildCampaignExport(exportedAt, req);
+ }
+
+ private ContentExport buildFullExport(String exportedAt) {
ContentExport.Manifest manifest =
- new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt);
+ new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, "complète");
+ return new ContentExport(manifest,
+ map(gameSystemRepo.findAll(), this::toGameSystemDto),
+ map(loreRepo.findAll(), this::toLoreDto),
+ map(loreNodeRepo.findAll(), this::toLoreNodeDto),
+ map(templateRepo.findAll(), this::toTemplateDto),
+ map(pageRepo.findAll(), this::toPageDto),
+ map(campaignRepo.findAll(), this::toCampaignDto),
+ map(arcRepo.findAll(), this::toArcDto),
+ map(chapterRepo.findAll(), this::toChapterDto),
+ map(sceneRepo.findAll(), this::toSceneDto),
+ map(characterRepo.findAll(), this::toCharacterDto),
+ map(npcRepo.findAll(), this::toNpcDto),
+ map(enemyRepo.findAll(), this::toEnemyDto),
+ map(itemCatalogRepo.findAll(), this::toItemCatalogDto),
+ map(randomTableRepo.findAll(), this::toRandomTableDto),
+ map(imageRepo.findAll(), this::toImageDto),
+ map(storedFileRepo.findAll(), this::toStoredFileDto),
+ map(playthroughRepo.findAll(), this::toPlaythroughDto),
+ map(sessionRepo.findAll(), this::toSessionDto),
+ map(sessionEntryRepo.findAll(), this::toSessionEntryDto),
+ map(playthroughFlagRepo.findAll(), this::toFlagDto),
+ map(questProgressionRepo.findAll(), this::toQuestProgressionDto));
+ }
- List gameSystems = gameSystemRepo.findAll().stream()
- .map(this::toGameSystemDto).toList();
- List lores = loreRepo.findAll().stream()
- .map(this::toLoreDto).toList();
- List loreNodes = loreNodeRepo.findAll().stream()
- .map(this::toLoreNodeDto).toList();
- List templates = templateRepo.findAll().stream()
- .map(this::toTemplateDto).toList();
- List pages = pageRepo.findAll().stream()
- .map(this::toPageDto).toList();
- List campaigns = campaignRepo.findAll().stream()
- .map(this::toCampaignDto).toList();
- List arcs = arcRepo.findAll().stream()
- .map(this::toArcDto).toList();
- List chapters = chapterRepo.findAll().stream()
- .map(this::toChapterDto).toList();
- List scenes = sceneRepo.findAll().stream()
- .map(this::toSceneDto).toList();
- List characters = characterRepo.findAll().stream()
- .map(this::toCharacterDto).toList();
- List npcs = npcRepo.findAll().stream()
- .map(this::toNpcDto).toList();
- List enemies = enemyRepo.findAll().stream()
- .map(this::toEnemyDto).toList();
- List itemCatalogs = itemCatalogRepo.findAll().stream()
- .map(this::toItemCatalogDto).toList();
- List randomTables = randomTableRepo.findAll().stream()
- .map(this::toRandomTableDto).toList();
- List images = imageRepo.findAll().stream()
- .map(this::toImageDto).toList();
- List storedFiles = storedFileRepo.findAll().stream()
- .map(this::toStoredFileDto).toList();
+ /**
+ * Export CIBLÉ : la campagne et sa clôture (arcs → chapitres → scènes, PNJ, ennemis,
+ * catalogues, tables, système de jeu lié), plus — selon les options — son univers (lore)
+ * et son espace de jeu (parties → sessions/journal/flags/quêtes + feuilles de perso).
+ * Les images/fichiers exportés sont uniquement ceux RÉFÉRENCÉS par la clôture.
+ */
+ private ContentExport buildCampaignExport(String exportedAt, ExportRequest req) {
+ Long cid = req.campaignId();
+ CampaignJpaEntity campaign = campaignRepo.findById(cid)
+ .orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid));
- return new ContentExport(manifest, gameSystems, lores, loreNodes, templates,
- pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies,
- itemCatalogs, randomTables, images, storedFiles);
+ // Prep : clôture structurelle de la campagne.
+ List arcEntities = arcRepo.findByCampaignId(cid);
+ List chapterEntities = arcEntities.stream()
+ .flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
+ List sceneEntities = chapterEntities.stream()
+ .flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
+ List npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid);
+ List enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
+ List catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid);
+ List tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid);
+
+ // Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
+ List gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
+
+ // Univers (lore) lié : optionnel.
+ Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null;
+ List loreEntities = lid != null ? singleton(loreRepo, lid) : List.of();
+ List loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of();
+ List templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of();
+ List pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of();
+
+ // Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie,
+ // donc « sans jeu » = sans feuilles de perso.
+ List ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of();
+ List sessionEntities = ptEntities.stream()
+ .flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
+ List entryEntities = sessionEntities.stream()
+ .flatMap(s -> sessionEntryRepo.findBySessionIdOrderByOccurredAtAsc(String.valueOf(s.getId())).stream()).toList();
+ List flagEntities = ptEntities.stream()
+ .flatMap(p -> playthroughFlagRepo.findByPlaythroughId(p.getId()).stream()).toList();
+ List questEntities = ptEntities.stream()
+ .flatMap(p -> questProgressionRepo.findByPlaythroughId(p.getId()).stream()).toList();
+ List characterEntities = ptEntities.stream()
+ .flatMap(p -> characterRepo.findByPlaythroughIdOrderByOrderAsc(p.getId()).stream()).toList();
+
+ // Images/fichiers : uniquement les binaires RÉFÉRENCÉS par la clôture (si option active).
+ List imageEntities = List.of();
+ List fileEntities = List.of();
+ if (req.includeImages()) {
+ Set imageRefs = new LinkedHashSet<>();
+ arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
+ chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
+ sceneEntities.forEach(s -> addAll(imageRefs, s.getIllustrationImageIds()));
+ sceneEntities.forEach(s -> addRoomImageRefs(imageRefs, s.getRooms()));
+ npcEntities.forEach(n -> { add(imageRefs, n.getPortraitImageId()); add(imageRefs, n.getHeaderImageId()); addImageValues(imageRefs, n.getImageValues()); });
+ enemyEntities.forEach(e -> { add(imageRefs, e.getPortraitImageId()); add(imageRefs, e.getHeaderImageId()); addImageValues(imageRefs, e.getImageValues()); });
+ characterEntities.forEach(c -> { add(imageRefs, c.getPortraitImageId()); add(imageRefs, c.getHeaderImageId()); addImageValues(imageRefs, c.getImageValues()); });
+ pageEntities.forEach(p -> addImageValues(imageRefs, p.getImageValues()));
+ imageEntities = imageRefs.stream()
+ .map(ExportService::parseLongOrNull).filter(java.util.Objects::nonNull)
+ .map(id -> imageRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull)
+ .distinct().toList();
+
+ Set fileRefs = new LinkedHashSet<>();
+ sceneEntities.forEach(s -> { addLong(fileRefs, s.getBattlemapMediaFileId()); addLong(fileRefs, s.getBattlemapDataFileId()); });
+ fileEntities = fileRefs.stream()
+ .map(id -> storedFileRepo.findById(id).orElse(null)).filter(java.util.Objects::nonNull).toList();
+ }
+
+ // Campaign DTO : si le lore n'est pas exporté, on neutralise loreId (évite une
+ // référence pendante vers un univers absent à l'import).
+ ContentExport.CampaignDto campaignDto = toCampaignDto(campaign);
+ if (!req.includeLore()) {
+ campaignDto = new ContentExport.CampaignDto(campaignDto.id(), campaignDto.name(),
+ campaignDto.description(), campaignDto.arcsCount(), campaignDto.playerCount(),
+ null, campaignDto.gameSystemId());
+ }
+
+ ContentExport.Manifest manifest =
+ new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt, campaign.getName());
+ return new ContentExport(manifest,
+ map(gsEntities, this::toGameSystemDto),
+ map(loreEntities, this::toLoreDto),
+ map(loreNodeEntities, this::toLoreNodeDto),
+ map(templateEntities, this::toTemplateDto),
+ map(pageEntities, this::toPageDto),
+ List.of(campaignDto),
+ map(arcEntities, this::toArcDto),
+ map(chapterEntities, this::toChapterDto),
+ map(sceneEntities, this::toSceneDto),
+ map(characterEntities, this::toCharacterDto),
+ map(npcEntities, this::toNpcDto),
+ map(enemyEntities, this::toEnemyDto),
+ map(catalogEntities, this::toItemCatalogDto),
+ map(tableEntities, this::toRandomTableDto),
+ map(imageEntities, this::toImageDto),
+ map(fileEntities, this::toStoredFileDto),
+ map(ptEntities, this::toPlaythroughDto),
+ map(sessionEntities, this::toSessionDto),
+ map(entryEntities, this::toSessionEntryDto),
+ map(flagEntities, this::toFlagDto),
+ map(questEntities, this::toQuestProgressionDto));
+ }
+
+ // ----- Helpers de chargement -----
+
+ private static List map(List in, java.util.function.Function f) {
+ return in.stream().map(f).toList();
+ }
+
+ /** Liste 0/1 élément : l'entité d'id donné si présente. */
+ private static >
+ List singleton(R repo, Long id) {
+ if (id == null) return List.of();
+ return repo.findById(id).map(List::of).orElseGet(List::of);
+ }
+
+ private static Long parseLongOrNull(String s) {
+ if (s == null || s.isBlank()) return null;
+ try { return Long.parseLong(s.trim()); } catch (NumberFormatException e) { return null; }
+ }
+
+ private static void addLong(Set set, String idStr) {
+ Long id = parseLongOrNull(idStr);
+ if (id != null) set.add(id);
}
/**
@@ -222,33 +361,39 @@ public class ExportService {
* (portrait + header + imageValues), Page (imageValues).
*/
private Set collectReferencedStorageKeys(ContentExport export) {
- Set keys = new LinkedHashSet<>();
- for (ContentExport.ArcDto a : export.arcs()) {
- addAll(keys, a.illustrationImageIds());
- }
- for (ContentExport.ChapterDto c : export.chapters()) {
- addAll(keys, c.illustrationImageIds());
- }
- for (ContentExport.SceneDto s : export.scenes()) {
- addAll(keys, s.illustrationImageIds());
+ // Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload),
+ // PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images
+ // exportées — même logique que collectReferencedFileStorageKeys pour les fichiers.
+ java.util.Map keyByImageId = new java.util.HashMap<>();
+ for (ContentExport.ImageDto img : export.images()) {
+ if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey());
}
+ Set refs = new LinkedHashSet<>();
+ for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
+ for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
+ for (ContentExport.SceneDto s : export.scenes()) addAll(refs, s.illustrationImageIds());
+ for (ContentExport.SceneDto s : export.scenes()) addRoomImageRefs(refs, s.rooms());
for (ContentExport.CharacterDto c : export.characters()) {
- add(keys, c.portraitImageId());
- add(keys, c.headerImageId());
- addImageValues(keys, c.imageValues());
+ add(refs, c.portraitImageId());
+ add(refs, c.headerImageId());
+ addImageValues(refs, c.imageValues());
}
for (ContentExport.NpcDto n : export.npcs()) {
- add(keys, n.portraitImageId());
- add(keys, n.headerImageId());
- addImageValues(keys, n.imageValues());
+ add(refs, n.portraitImageId());
+ add(refs, n.headerImageId());
+ addImageValues(refs, n.imageValues());
}
for (ContentExport.EnemyDto e : export.enemies()) {
- add(keys, e.portraitImageId());
- add(keys, e.headerImageId());
- addImageValues(keys, e.imageValues());
+ add(refs, e.portraitImageId());
+ add(refs, e.headerImageId());
+ addImageValues(refs, e.imageValues());
}
- for (ContentExport.PageDto p : export.pages()) {
- addImageValues(keys, p.imageValues());
+ for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues());
+
+ Set keys = new LinkedHashSet<>();
+ for (String ref : refs) {
+ String key = keyByImageId.get(ref);
+ if (key != null && !key.isBlank()) keys.add(key);
}
return keys;
}
@@ -288,6 +433,15 @@ public class ExportService {
if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l));
}
+ /** Réfs d'images portées par les salles (Room) d'une scène : galerie + plan. */
+ private void addRoomImageRefs(Set keys, List rooms) {
+ if (rooms == null) return;
+ for (Room r : rooms) {
+ addAll(keys, r.getIllustrationImageIds());
+ add(keys, r.getMapImageId());
+ }
+ }
+
// ----- Mappers entite -> DTO -----
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
@@ -320,7 +474,7 @@ public class ExportService {
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(),
- e.getArcsCount(), e.getLoreId(), e.getGameSystemId());
+ e.getArcsCount(), e.getPlayerCount(), e.getLoreId(), e.getGameSystemId());
}
private ContentExport.ArcDto toArcDto(ArcJpaEntity e) {
@@ -400,4 +554,31 @@ public class ExportService {
return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey());
}
+
+ // ----- Mappers espace de jeu -----
+
+ private ContentExport.PlaythroughDto toPlaythroughDto(PlaythroughJpaEntity e) {
+ return new ContentExport.PlaythroughDto(e.getId(), e.getCampaignId(), e.getName(), e.getDescription());
+ }
+
+ private ContentExport.SessionDto toSessionDto(SessionJpaEntity e) {
+ return new ContentExport.SessionDto(e.getId(), e.getName(), e.getCampaignId(), e.getPlaythroughId(),
+ e.getStartedAt() != null ? e.getStartedAt().toString() : null,
+ e.getEndedAt() != null ? e.getEndedAt().toString() : null);
+ }
+
+ private ContentExport.SessionEntryDto toSessionEntryDto(SessionEntryJpaEntity e) {
+ return new ContentExport.SessionEntryDto(e.getId(), e.getSessionId(),
+ e.getType() != null ? e.getType().name() : null, e.getContent(),
+ e.getOccurredAt() != null ? e.getOccurredAt().toString() : null);
+ }
+
+ private ContentExport.PlaythroughFlagDto toFlagDto(PlaythroughFlagJpaEntity e) {
+ return new ContentExport.PlaythroughFlagDto(e.getId(), e.getPlaythroughId(), e.getName(), e.isValue());
+ }
+
+ private ContentExport.QuestProgressionDto toQuestProgressionDto(QuestProgressionJpaEntity e) {
+ return new ContentExport.QuestProgressionDto(e.getId(), e.getPlaythroughId(), e.getChapterId(),
+ e.getStatus() != null ? e.getStatus().name() : null);
+ }
}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
index 67bb060..229d762 100644
--- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
@@ -1,6 +1,8 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.loremind.domain.campaigncontext.ProgressionStatus;
+import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
@@ -61,6 +63,11 @@ public class ImportService {
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
+ private final PlaythroughJpaRepository playthroughRepo;
+ private final SessionJpaRepository sessionRepo;
+ private final SessionEntryJpaRepository sessionEntryRepo;
+ private final PlaythroughFlagJpaRepository playthroughFlagRepo;
+ private final QuestProgressionJpaRepository questProgressionRepo;
private final ImageImporter imageImporter;
private final StoredFileImporter storedFileImporter;
private final ObjectMapper objectMapper;
@@ -79,6 +86,11 @@ public class ImportService {
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
+ PlaythroughJpaRepository playthroughRepo,
+ SessionJpaRepository sessionRepo,
+ SessionEntryJpaRepository sessionEntryRepo,
+ PlaythroughFlagJpaRepository playthroughFlagRepo,
+ QuestProgressionJpaRepository questProgressionRepo,
ImageImporter imageImporter,
StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) {
@@ -96,6 +108,11 @@ public class ImportService {
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
+ this.playthroughRepo = playthroughRepo;
+ this.sessionRepo = sessionRepo;
+ this.sessionEntryRepo = sessionEntryRepo;
+ this.playthroughFlagRepo = playthroughFlagRepo;
+ this.questProgressionRepo = questProgressionRepo;
this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper;
@@ -126,6 +143,8 @@ public class ImportService {
Map enemyMap = new HashMap<>();
Map characterMap = new HashMap<>();
Map sceneMap = new HashMap<>();
+ Map playthroughMap = new HashMap<>();
+ Map sessionMap = new HashMap<>();
// -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
@@ -206,12 +225,61 @@ public class ImportService {
e.setName(d.name());
e.setDescription(d.description());
e.setArcsCount(d.arcsCount());
+ e.setPlayerCount(d.playerCount());
e.setLoreId(d.loreId()); // remappe plus bas
e.setGameSystemId(d.gameSystemId()); // remappe plus bas
campaignMap.put(d.id(), campaignRepo.save(e).getId());
}
result.count("campaigns", campaignMap.size());
+ // -- Playthrough (Partie) : campaignId remappe
+ for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) {
+ PlaythroughJpaEntity e = new PlaythroughJpaEntity();
+ e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
+ e.setName(d.name());
+ e.setDescription(d.description());
+ playthroughMap.put(d.id(), playthroughRepo.save(e).getId());
+ }
+ result.count("playthroughs", playthroughMap.size());
+
+ // -- Session : campaignId (ref faible String) + playthroughId remappes
+ for (ContentExport.SessionDto d : nullSafe(export.sessions())) {
+ SessionJpaEntity e = new SessionJpaEntity();
+ e.setName(d.name());
+ e.setCampaignId(IdRemapper.remapStringId(campaignMap, d.campaignId()));
+ e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
+ e.setStartedAt(parseDateTime(d.startedAt()));
+ e.setEndedAt(parseDateTime(d.endedAt()));
+ sessionMap.put(d.id(), sessionRepo.save(e).getId());
+ }
+ result.count("sessions", sessionMap.size());
+
+ // -- SessionEntry : sessionId (ref faible String) remappe
+ int sessionEntryCount = 0;
+ for (ContentExport.SessionEntryDto d : nullSafe(export.sessionEntries())) {
+ SessionEntryJpaEntity e = new SessionEntryJpaEntity();
+ e.setSessionId(IdRemapper.remapStringId(sessionMap, d.sessionId()));
+ e.setType(parseEntryType(d.type()));
+ e.setContent(d.content());
+ e.setOccurredAt(parseDateTime(d.occurredAt()));
+ sessionEntryRepo.save(e);
+ sessionEntryCount++;
+ }
+ result.count("sessionEntries", sessionEntryCount);
+
+ // -- PlaythroughFlag : playthroughId remappe (la contrainte unique (playthroughId,name)
+ // ne saute pas, le playthroughId etant neuf).
+ int flagCount = 0;
+ for (ContentExport.PlaythroughFlagDto d : nullSafe(export.playthroughFlags())) {
+ PlaythroughFlagJpaEntity e = new PlaythroughFlagJpaEntity();
+ e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
+ e.setName(d.name());
+ e.setValue(d.value());
+ playthroughFlagRepo.save(e);
+ flagCount++;
+ }
+ result.count("playthroughFlags", flagCount);
+
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
@@ -307,6 +375,19 @@ public class ImportService {
}
result.count("chapters", chapterMap.size());
+ // -- QuestProgression : playthroughId + chapterId remappes (chapitres deja inseres ;
+ // contrainte unique (playthroughId, chapterId) preservee car playthroughId neuf).
+ int questProgCount = 0;
+ for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) {
+ QuestProgressionJpaEntity e = new QuestProgressionJpaEntity();
+ e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId()));
+ e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId()));
+ e.setStatus(parseProgressionStatus(d.status()));
+ questProgressionRepo.save(e);
+ questProgCount++;
+ }
+ result.count("questProgressions", questProgCount);
+
// -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity();
@@ -353,7 +434,9 @@ public class ImportService {
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId()));
- e.setPlaythroughId(null); // Playthrough hors perimetre d'export
+ // playthroughId remappe vers la Partie importee (ou null si le jeu n'etait pas
+ // dans l'export -> la map est vide). Evite une reference pendante.
+ e.setPlaythroughId(playthroughMap.get(d.playthroughId()));
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
}
@@ -518,6 +601,36 @@ public class ImportService {
return list != null ? list : List.of();
}
+ /** Parse un horodatage ISO LocalDateTime, ou null si absent/illisible. */
+ private static java.time.LocalDateTime parseDateTime(String s) {
+ if (s == null || s.isBlank()) return null;
+ try {
+ return java.time.LocalDateTime.parse(s.trim());
+ } catch (java.time.format.DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ /** Parse un EntryType, repli sur NOTE si inconnu/absent (jamais d'echec). */
+ private static EntryType parseEntryType(String s) {
+ if (s == null) return EntryType.NOTE;
+ try {
+ return EntryType.valueOf(s);
+ } catch (IllegalArgumentException e) {
+ return EntryType.NOTE;
+ }
+ }
+
+ /** Parse un ProgressionStatus, repli sur NOT_STARTED si inconnu/absent. */
+ private static ProgressionStatus parseProgressionStatus(String s) {
+ if (s == null) return ProgressionStatus.NOT_STARTED;
+ try {
+ return ProgressionStatus.valueOf(s);
+ } catch (IllegalArgumentException e) {
+ return ProgressionStatus.NOT_STARTED;
+ }
+ }
+
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer);
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java
index 7958088..ea2d96f 100644
--- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java
@@ -36,18 +36,27 @@ public record ContentExport(
List itemCatalogs,
List randomTables,
List images,
- List storedFiles
+ List storedFiles,
+ // --- Espace de jeu (format v2). Absent des exports v1 -> listes null a la
+ // relecture (Jackson) -> traitees comme vides cote import (nullSafe). ---
+ List playthroughs,
+ List sessions,
+ List sessionEntries,
+ List playthroughFlags,
+ List questProgressions
) {
/**
* Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis
* la couche requete (PAS Instant.now() ici, pour rester deterministe et
- * testable).
+ * testable). {@code scope} decrit le perimetre ("complète" ou nom de campagne)
+ * a titre informatif.
*/
public record Manifest(
int formatVersion,
String appVersion,
- String exportedAt
+ String exportedAt,
+ String scope
) {}
public record GameSystemDto(
@@ -108,6 +117,7 @@ public record ContentExport(
String name,
String description,
int arcsCount,
+ int playerCount,
String loreId,
String gameSystemId
) {}
@@ -278,4 +288,49 @@ public record ContentExport(
long sizeBytes,
String storageKey
) {}
+
+ // ===================================================================== Jeu (v2)
+
+ /** Une Partie (Playthrough) : un run d'une campagne par un groupe. */
+ public record PlaythroughDto(
+ Long id,
+ Long campaignId,
+ String name,
+ String description
+ ) {}
+
+ /** Une séance de jeu rattachée à une Partie. Horodatages en ISO-8601 (ou null). */
+ public record SessionDto(
+ Long id,
+ String name,
+ String campaignId,
+ Long playthroughId,
+ String startedAt,
+ String endedAt
+ ) {}
+
+ /** Une entrée du journal d'une séance ({@code type} = nom de l'EntryType). */
+ public record SessionEntryDto(
+ Long id,
+ String sessionId,
+ String type,
+ String content,
+ String occurredAt
+ ) {}
+
+ /** Un drapeau de Partie (flag narratif booléen). */
+ public record PlaythroughFlagDto(
+ Long id,
+ Long playthroughId,
+ String name,
+ boolean value
+ ) {}
+
+ /** La progression d'une quête (Chapter) dans une Partie ({@code status} = nom du ProgressionStatus). */
+ public record QuestProgressionDto(
+ Long id,
+ Long playthroughId,
+ Long chapterId,
+ String status
+ ) {}
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
index 097cec7..1bb1e79 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/CampaignController.java
@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign(
- new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId())
+ new CampaignService.CampaignData(campaign.getName(), campaign.getDescription(), campaign.getLoreId(), campaign.getGameSystemId(), campaign.getPlayerCount())
);
return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
}
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign(
id,
- new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId())
+ new CampaignService.CampaignData(campaignDTO.getName(), campaignDTO.getDescription(), campaignDTO.getLoreId(), campaignDTO.getGameSystemId(), campaignDTO.getPlayerCount())
);
return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java
index 087d833..05e4c34 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java
@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller;
+import com.loremind.infrastructure.transfer.ExportRequest;
import com.loremind.infrastructure.transfer.ExportService;
import com.loremind.infrastructure.transfer.ImportResult;
import com.loremind.infrastructure.transfer.ImportService;
@@ -47,22 +48,52 @@ public class DataTransferController {
this.demoMode = demoMode;
}
+ /**
+ * Export portable. Sans {@code campaignId} : sauvegarde complète (toute la base).
+ * Avec {@code campaignId} : export ciblé d'une campagne et de sa clôture, les options
+ * {@code includeLore/Play/Images} (par défaut vraies) pilotant l'univers, l'espace de
+ * jeu et les binaires d'images embarqués.
+ */
@GetMapping(value = "/export", produces = "application/zip")
- public ResponseEntity export() {
+ public ResponseEntity export(
+ @RequestParam(required = false) Long campaignId,
+ @RequestParam(defaultValue = "true") boolean includeLore,
+ @RequestParam(defaultValue = "true") boolean includePlay,
+ @RequestParam(defaultValue = "true") boolean includeImages) {
guardDemoMode();
// Stamp de l'horodatage cote requete (PAS dans un init statique).
String exportedAt = Instant.now().toString();
- ContentExport content = exportService.buildExport(exportedAt);
+ ExportRequest req = campaignId == null
+ ? ExportRequest.full()
+ : new ExportRequest(campaignId, includeLore, includePlay, includeImages);
+
+ ContentExport content;
+ try {
+ content = exportService.buildExport(exportedAt, req);
+ } catch (java.util.NoSuchElementException e) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
+ }
StreamingResponseBody body = out -> exportService.writeZip(content, out);
+ String filename = "loremind-" + slug(content.manifest().scope()) + ".zip";
return ResponseEntity.ok()
- .header(HttpHeaders.CONTENT_DISPOSITION,
- "attachment; filename=\"loremind-export.zip\"")
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(body);
}
+ /** Slug ASCII minuscule (pour le nom de fichier) à partir d'un libellé de scope. */
+ private static String slug(String s) {
+ if (s == null || s.isBlank()) return "export";
+ String slug = java.text.Normalizer.normalize(s, java.text.Normalizer.Form.NFD)
+ .replaceAll("\\p{M}+", "")
+ .toLowerCase()
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("(^-+|-+$)", "");
+ return slug.isBlank() ? "export" : slug;
+ }
+
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity importData(@RequestParam("file") MultipartFile file) {
guardDemoMode();
diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
index 6be48c4..d05c5c5 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/CampaignDTO.java
@@ -13,6 +13,8 @@ public class CampaignDTO {
private String name;
private String description;
private int arcsCount;
+ /** Nombre de joueurs attendus à la table. */
+ private int playerCount;
/** Nullable : campagne sans univers associé. */
private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */
diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
index c16a87b..9881f1d 100644
--- a/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
+++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/CampaignMapper.java
@@ -20,6 +20,7 @@ public class CampaignMapper {
dto.setName(campaign.getName());
dto.setDescription(campaign.getDescription());
dto.setArcsCount(campaign.getArcsCount());
+ dto.setPlayerCount(campaign.getPlayerCount());
dto.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId());
return dto;
@@ -35,6 +36,7 @@ public class CampaignMapper {
.name(dto.getName())
.description(dto.getDescription())
.arcsCount(dto.getArcsCount())
+ .playerCount(dto.getPlayerCount())
.loreId(dto.getLoreId())
.gameSystemId(dto.getGameSystemId())
.build();
diff --git a/core/src/main/resources/db/migration/V7__campaign_player_count.sql b/core/src/main/resources/db/migration/V7__campaign_player_count.sql
new file mode 100644
index 0000000..648c425
--- /dev/null
+++ b/core/src/main/resources/db/migration/V7__campaign_player_count.sql
@@ -0,0 +1,5 @@
+-- Nombre de joueurs attendus à la table.
+-- Le champ était saisi à la création côté front (défaut 4) mais jamais persisté :
+-- le backend (domaine/entité/DTO) ne le portait pas, d'où un affichage "0 joueurs".
+-- Défaut 4 (valeur par défaut du formulaire) pour les campagnes existantes.
+ALTER TABLE campaigns ADD COLUMN player_count INTEGER NOT NULL DEFAULT 4;
diff --git a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
index 454a2e9..12da9b3 100644
--- a/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
+++ b/core/src/test/java/com/loremind/application/campaigncontext/CampaignServiceTest.java
@@ -70,7 +70,8 @@ public class CampaignServiceTest {
"New Campaign",
"Description",
"lore-123",
- null
+ null,
+ 4
);
// Le repo renvoie la Campaign telle que passée — on teste la normalisation
// du loreId dans le service, pas le comportement du repo.
@@ -93,7 +94,8 @@ public class CampaignServiceTest {
"New Campaign",
"Description",
null,
- null
+ null,
+ 4
);
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -114,7 +116,8 @@ public class CampaignServiceTest {
"New Campaign",
"Description",
" ",
- null
+ null,
+ 4
);
when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@@ -179,7 +182,8 @@ public class CampaignServiceTest {
"Updated Campaign",
"Updated Description",
"lore-456",
- null
+ null,
+ 4
);
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
@@ -200,7 +204,8 @@ public class CampaignServiceTest {
"Updated Campaign",
"Updated Description",
"lore-456",
- null
+ null,
+ 4
);
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());
diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java
index c7226c2..21f59c8 100644
--- a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java
+++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportImportRoundTripTest.java
@@ -2,9 +2,11 @@ package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Prerequisite;
+import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage;
+import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
@@ -17,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -28,23 +31,19 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
/**
- * Aller-retour EXPORT → ZIP → IMPORT (mode FUSION) du contenu portable.
+ * Aller-retour EXPORT → ZIP → IMPORT (mode FUSION) du contenu portable, JEU COMPRIS.
*
- * On sème un graphe complet (système de jeu, lore/dossiers/template/page, campagne,
- * arc, chapitres avec prérequis, scène avec branches/ennemis/battlemap, PNJ, ennemi,
- * personnage, catalogue d'objets, table aléatoire, image + fichier), on l'exporte,
+ * On sème un graphe complet — prep (système, lore/dossiers/template/page, campagne, arc,
+ * chapitres avec prérequis, scène, PNJ, ennemi) ET espace de jeu (partie, séance, journal,
+ * flag, progression de quête, feuille de perso) — plus une image et un fichier. On l'exporte,
* on le réimporte, puis on vérifie :
*
- *
le zip embarque {@code manifest.json}, {@code data.json} et les binaires
- * d'images/fichiers RÉFÉRENCÉS ;
- *
l'import DOUBLE chaque type (l'export étant global) ;
- *
les clés d'images (uniques) sont RÉUTILISÉES, pas dupliquées ;
- *
les références sont remappées en 2e passe (l'arc importé pointe la page
- * importée, pas l'originale) ;
- *
le {@code playthroughId} d'un personnage est remis à null à l'import.
+ *
le zip embarque manifest/data + les binaires d'images (référencées par ID,
+ * résolu en clé de stockage) et de fichiers RÉFÉRENCÉS ;
+ *
l'import DOUBLE chaque type, jeu compris ;
+ *
les références sont remappées : l'arc importé pointe la page importée, et la feuille
+ * de perso / séance / progression importées pointent la Partie / le chapitre importés.
*
- * Le stockage binaire (MinIO) est mocké : aucun appel réseau, et on contrôle ce que
- * {@code download} renvoie pour les clés référencées.
*/
@SpringBootTest
@Transactional
@@ -69,6 +68,11 @@ class ContentExportImportRoundTripTest {
@Autowired private RandomTableJpaRepository randomTableRepo;
@Autowired private ImageJpaRepository imageRepo;
@Autowired private StoredFileJpaRepository storedFileRepo;
+ @Autowired private PlaythroughJpaRepository playthroughRepo;
+ @Autowired private SessionJpaRepository sessionRepo;
+ @Autowired private SessionEntryJpaRepository sessionEntryRepo;
+ @Autowired private PlaythroughFlagJpaRepository playthroughFlagRepo;
+ @Autowired private QuestProgressionJpaRepository questProgressionRepo;
@MockitoBean private ImageStorage imageStorage;
@MockitoBean private FileStorage fileStorage;
@@ -77,8 +81,8 @@ class ContentExportImportRoundTripTest {
private static final String FILE_KEY = "files/round-trip-map.json";
@Test
- void roundTrip_duplicatesEveryContentTypeAndRemapsReferences() throws IOException {
- // ----- 1. Graphe de contenu riche -----
+ void roundTrip_duplicatesEveryContentTypeIncludingPlayAndRemapsReferences() throws IOException {
+ // ----- 1. Prep -----
GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder()
.name("RT System").description("d").foundryActorType("npc").isPublic(true).build());
@@ -89,18 +93,20 @@ class ContentExportImportRoundTripTest {
.name("RT Child").loreId(lore.getId()).parentId(rootNode.getId()).order(1).build());
TemplateJpaEntity template = templateRepo.save(TemplateJpaEntity.builder()
.loreId(lore.getId()).name("RT Template").defaultNodeId(rootNode.getId()).build());
- PageJpaEntity page = pageRepo.save(PageJpaEntity.builder()
- .loreId(lore.getId()).nodeId(rootNode.getId()).templateId(template.getId())
- .title("RT Page").imageValues(Map.of("gallery", List.of(IMG_KEY))).build());
- // Auto-référence : vérifie le remap des relatedPageIds (2e passe).
- page.setRelatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId()))));
- pageRepo.save(page);
- imageRepo.save(ImageJpaEntity.builder()
+ // L'image est référencée par son ID (convention de prod), pas par sa clé de stockage.
+ ImageJpaEntity image = imageRepo.save(ImageJpaEntity.builder()
.filename("abc.png").contentType("image/png").sizeBytes(7).storageKey(IMG_KEY).build());
+ String imageRef = String.valueOf(image.getId());
StoredFileJpaEntity storedFile = storedFileRepo.save(StoredFileJpaEntity.builder()
.filename("map.json").contentType("application/json").sizeBytes(6).storageKey(FILE_KEY).build());
+ PageJpaEntity page = pageRepo.save(PageJpaEntity.builder()
+ .loreId(lore.getId()).nodeId(rootNode.getId()).templateId(template.getId())
+ .title("RT Page").imageValues(Map.of("gallery", List.of(imageRef))).build());
+ page.setRelatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))); // auto-réf -> remap
+ pageRepo.save(page);
+
CampaignJpaEntity campaign = campaignRepo.save(CampaignJpaEntity.builder()
.name("RT Campaign").description("d").arcsCount(1)
.loreId(String.valueOf(lore.getId())).gameSystemId(String.valueOf(gs.getId())).build());
@@ -108,7 +114,7 @@ class ContentExportImportRoundTripTest {
ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder()
.name("RT Arc").campaignId(campaign.getId()).order(0).type(ArcType.HUB)
.relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId()))))
- .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY))).build());
+ .illustrationImageIds(new ArrayList<>(List.of(imageRef))).build());
ChapterJpaEntity chapterA = chapterRepo.save(ChapterJpaEntity.builder()
.name("RT Chapter A").arcId(arc.getId()).order(0).build());
@@ -127,17 +133,14 @@ class ContentExportImportRoundTripTest {
.name("RT Scene").chapterId(chapterB.getId()).order(0)
.enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId()))))
.relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId()))))
- .illustrationImageIds(new ArrayList<>(List.of(IMG_KEY)))
+ .illustrationImageIds(new ArrayList<>(List.of(imageRef)))
.battlemapMediaFileId(String.valueOf(storedFile.getId()))
.branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build());
npcRepo.save(NpcJpaEntity.builder()
- .name("RT Npc").campaignId(campaign.getId()).portraitImageId(IMG_KEY).folder("Ville").order(0)
+ .name("RT Npc").campaignId(campaign.getId()).portraitImageId(imageRef).folder("Ville").order(0)
.relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))).build());
- characterRepo.save(CharacterJpaEntity.builder()
- .name("RT Hero").campaignId(campaign.getId()).playthroughId(999L).order(0).build());
-
ItemCatalogJpaEntity catalog = ItemCatalogJpaEntity.builder()
.name("RT Catalog").campaignId(campaign.getId()).order(0).build();
CatalogItemJpaEntity item = CatalogItemJpaEntity.builder()
@@ -152,77 +155,114 @@ class ContentExportImportRoundTripTest {
table.setEntries(new ArrayList<>(List.of(entry)));
randomTableRepo.save(table);
- // Comptes AVANT export (incluent un éventuel seed) — l'export est GLOBAL.
+ // ----- 1b. Espace de jeu -----
+ PlaythroughJpaEntity pt = playthroughRepo.save(PlaythroughJpaEntity.builder()
+ .campaignId(campaign.getId()).name("RT Partie").description("d").build());
+ SessionJpaEntity session = sessionRepo.save(SessionJpaEntity.builder()
+ .name("RT Séance").campaignId(String.valueOf(campaign.getId())).playthroughId(pt.getId())
+ .startedAt(LocalDateTime.of(2026, 1, 1, 20, 0)).build());
+ sessionEntryRepo.save(SessionEntryJpaEntity.builder()
+ .sessionId(String.valueOf(session.getId())).type(EntryType.NOTE).content("Début")
+ .occurredAt(LocalDateTime.of(2026, 1, 1, 20, 5)).build());
+ playthroughFlagRepo.save(PlaythroughFlagJpaEntity.builder()
+ .playthroughId(pt.getId()).name("porte_ouverte").value(true).build());
+ questProgressionRepo.save(QuestProgressionJpaEntity.builder()
+ .playthroughId(pt.getId()).chapterId(chapterA.getId()).status(ProgressionStatus.IN_PROGRESS).build());
+ characterRepo.save(CharacterJpaEntity.builder()
+ .name("RT Hero").campaignId(campaign.getId()).playthroughId(pt.getId()).order(0).build());
+
+ // Comptes AVANT export (l'export complet est GLOBAL → tout sera doublé).
long campaignsBefore = campaignRepo.count();
long arcsBefore = arcRepo.count();
- long chaptersBefore = chapterRepo.count();
- long scenesBefore = sceneRepo.count();
- long npcsBefore = npcRepo.count();
- long enemiesBefore = enemyRepo.count();
long pagesBefore = pageRepo.count();
- long gsBefore = gameSystemRepo.count();
long imagesBefore = imageRepo.count();
+ long playthroughsBefore = playthroughRepo.count();
+ long sessionsBefore = sessionRepo.count();
+ long entriesBefore = sessionEntryRepo.count();
+ long flagsBefore = playthroughFlagRepo.count();
+ long questsBefore = questProgressionRepo.count();
+ long charactersBefore = characterRepo.count();
Long campaignId0 = campaign.getId();
Long pageId0 = page.getId();
+ Long ptId0 = pt.getId();
+ Long chapterAId0 = chapterA.getId();
when(imageStorage.download(IMG_KEY)).thenAnswer(inv -> new ByteArrayInputStream("PNGDATA".getBytes()));
when(fileStorage.download(FILE_KEY)).thenAnswer(inv -> new ByteArrayInputStream("{\"x\":1}".getBytes()));
- // ----- 2. Export logique + sérialisation ZIP -----
+ // ----- 2. Export + ZIP -----
ContentExport export = exportService.buildExport("2026-01-02T03:04:05Z");
- assertEquals(1, export.manifest().formatVersion());
- assertEquals("2026-01-02T03:04:05Z", export.manifest().exportedAt());
- assertNotNull(export.manifest().appVersion());
- assertFalse(export.campaigns().isEmpty());
+ assertEquals(2, export.manifest().formatVersion());
+ assertEquals("complète", export.manifest().scope());
+ assertFalse(export.playthroughs().isEmpty());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
exportService.writeZip(export, baos);
Map zip = readZip(baos.toByteArray());
assertTrue(zip.containsKey("manifest.json"));
assertTrue(zip.containsKey("data.json"));
- // Binaire image référencé (portrait/illustration) embarqué + fichier de battlemap.
- assertTrue(zip.containsKey("images/" + IMG_KEY), "binaire image référencé attendu dans le zip");
+ // Le binaire image référencé par ID est bien résolu vers sa clé puis embarqué.
+ assertTrue(zip.containsKey("images/" + IMG_KEY), "binaire image (réf par ID) attendu dans le zip");
assertTrue(zip.containsKey("files/" + FILE_KEY), "binaire fichier référencé attendu dans le zip");
- // ----- 3. Import (mode fusion : nouveaux ids) -----
+ // ----- 3. Import (fusion) -----
ImportResult result = importService.importZip(new ByteArrayInputStream(baos.toByteArray()));
- // Tout le contenu exporté est ré-inséré → chaque type est doublé.
assertEquals(2 * campaignsBefore, campaignRepo.count());
assertEquals(2 * arcsBefore, arcRepo.count());
- assertEquals(2 * chaptersBefore, chapterRepo.count());
- assertEquals(2 * scenesBefore, sceneRepo.count());
- assertEquals(2 * npcsBefore, npcRepo.count());
- assertEquals(2 * enemiesBefore, enemyRepo.count());
assertEquals(2 * pagesBefore, pageRepo.count());
- assertEquals(2 * gsBefore, gameSystemRepo.count());
- // Clé image UNIQUE déjà présente → réutilisée, pas de doublon.
- assertEquals(imagesBefore, imageRepo.count());
- assertTrue(result.imagesReused() >= 1, "l'image référencée doit être réutilisée");
- assertEquals((int) campaignsBefore, result.created().get("campaigns"));
+ assertEquals(imagesBefore, imageRepo.count()); // clé unique réutilisée
+ assertTrue(result.imagesReused() >= 1);
+ // Espace de jeu également dupliqué.
+ assertEquals(2 * playthroughsBefore, playthroughRepo.count());
+ assertEquals(2 * sessionsBefore, sessionRepo.count());
+ assertEquals(2 * entriesBefore, sessionEntryRepo.count());
+ assertEquals(2 * flagsBefore, playthroughFlagRepo.count());
+ assertEquals(2 * questsBefore, questProgressionRepo.count());
+ assertEquals(2 * charactersBefore, characterRepo.count());
+ assertEquals((int) playthroughsBefore, result.created().get("playthroughs"));
- // ----- 4. Remapping des références (2e passe) -----
- // 4a. L'arc importé référence la PAGE importée (remappée), pas l'originale.
- Long importedCampaignId = onlyOther(campaignRepo.findAll().stream()
- .filter(c -> "RT Campaign".equals(c.getName())).map(CampaignJpaEntity::getId).toList(), campaignId0);
- Long importedPageId = onlyOther(pageRepo.findAll().stream()
- .filter(p -> "RT Page".equals(p.getTitle())).map(PageJpaEntity::getId).toList(), pageId0);
+ // ----- 4. Remapping -----
+ Long importedCampaignId = onlyOther(idsByName(campaignRepo.findAll(), CampaignJpaEntity::getName,
+ "RT Campaign", CampaignJpaEntity::getId), campaignId0);
+ Long importedPageId = onlyOther(idsByName(pageRepo.findAll(), PageJpaEntity::getTitle,
+ "RT Page", PageJpaEntity::getId), pageId0);
+ Long importedPtId = onlyOther(idsByName(playthroughRepo.findAll(), PlaythroughJpaEntity::getName,
+ "RT Partie", PlaythroughJpaEntity::getId), ptId0);
+ Long importedChapterAId = onlyOther(idsByName(chapterRepo.findAll(), ChapterJpaEntity::getName,
+ "RT Chapter A", ChapterJpaEntity::getId), chapterAId0);
+
+ // 4a. Prep : l'arc importé pointe la page importée (pas l'originale).
ArcJpaEntity importedArc = arcRepo.findAll().stream()
.filter(a -> "RT Arc".equals(a.getName()) && importedCampaignId.equals(a.getCampaignId()))
.findFirst().orElseThrow();
- assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)),
- "l'arc importé doit pointer la page importée (remap), pas l'originale");
+ assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)));
assertFalse(importedArc.getRelatedPageIds().contains(String.valueOf(pageId0)));
- // 4b. Le personnage importé a son playthroughId remis à null (hors périmètre).
- List heroes = characterRepo.findAll().stream()
- .filter(c -> "RT Hero".equals(c.getName())).toList();
- assertEquals(2, heroes.size());
- assertTrue(heroes.stream().anyMatch(c -> c.getPlaythroughId() == null));
- assertTrue(heroes.stream().anyMatch(c -> Long.valueOf(999L).equals(c.getPlaythroughId())));
+ // 4b. Jeu : la feuille de perso importée pointe la Partie importée (playthroughId préservé, pas null).
+ CharacterJpaEntity importedHero = characterRepo.findAll().stream()
+ .filter(c -> "RT Hero".equals(c.getName()) && importedPtId.equals(c.getPlaythroughId()))
+ .findFirst().orElseThrow();
+ assertNotNull(importedHero);
+
+ // 4c. Jeu : la séance importée pointe la Partie importée ; la progression importée le chapitre importé.
+ SessionJpaEntity importedSession = sessionRepo.findAll().stream()
+ .filter(s -> "RT Séance".equals(s.getName()) && importedPtId.equals(s.getPlaythroughId()))
+ .findFirst().orElseThrow();
+ // La ref faible String campaignId de la séance est remappée vers la campagne importée.
+ assertEquals(String.valueOf(importedCampaignId), importedSession.getCampaignId());
+ // L'entrée de journal importée pointe la séance importée (ref faible String sessionId).
+ SessionEntryJpaEntity importedEntry = sessionEntryRepo.findAll().stream()
+ .filter(e -> String.valueOf(importedSession.getId()).equals(e.getSessionId()))
+ .findFirst().orElseThrow();
+ assertEquals("Début", importedEntry.getContent());
+
+ QuestProgressionJpaEntity importedQuest = questProgressionRepo.findAll().stream()
+ .filter(q -> importedPtId.equals(q.getPlaythroughId()))
+ .findFirst().orElseThrow();
+ assertEquals(importedChapterAId, importedQuest.getChapterId());
}
- /** Lit toutes les entrées (nom → octets) d'un zip en mémoire. */
private static Map readZip(byte[] bytes) throws IOException {
Map out = new LinkedHashMap<>();
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) {
@@ -239,7 +279,13 @@ class ContentExportImportRoundTripTest {
return out;
}
- /** Renvoie l'unique id de la liste différent de {@code original} (l'élément importé). */
+ /** Ids des entités dont le libellé == name. */
+ private static List idsByName(List all, java.util.function.Function nameOf,
+ String name, java.util.function.Function idOf) {
+ return all.stream().filter(e -> name.equals(nameOf.apply(e))).map(idOf).toList();
+ }
+
+ /** L'unique id de la liste différent de {@code original} (l'élément importé). */
private static Long onlyOther(List ids, Long original) {
return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow();
}
diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java
new file mode 100644
index 0000000..9e23900
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/transfer/ContentExportV1ImportTest.java
@@ -0,0 +1,76 @@
+package com.loremind.infrastructure.transfer;
+
+import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository;
+import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository;
+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.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Rétro-compatibilité : un export AU FORMAT v1 (avant l'ajout de l'espace de jeu et du
+ * champ {@code scope}) doit toujours s'importer. On forge le {@code data.json} v1 À LA MAIN
+ * (un {@code buildExport} actuel produirait du v2) : Manifest à 3 champs sans {@code scope},
+ * AUCUNE des 5 listes de jeu, et une campagne SANS {@code playerCount}. Verrouille à la fois
+ * la désérialisation tolérante (Jackson : champs absents → null) et le {@code nullSafe} en
+ * bout de chaîne d'import.
+ */
+@SpringBootTest
+@Transactional
+class ContentExportV1ImportTest {
+
+ @Autowired private ImportService importService;
+ @Autowired private CampaignJpaRepository campaignRepo;
+ @Autowired private ArcJpaRepository arcRepo;
+
+ @Test
+ void importsLegacyV1ArchiveWithoutPlaySectionOrScope() throws Exception {
+ String dataJson = """
+ {
+ "manifest": { "formatVersion": 1, "appVersion": "0.9.0", "exportedAt": "2025-01-01T00:00:00Z" },
+ "campaigns": [
+ { "id": 1, "name": "V1 Camp", "description": "ancienne", "arcsCount": 1, "loreId": null, "gameSystemId": null }
+ ],
+ "arcs": [
+ { "id": 10, "name": "V1 Arc", "description": "d", "campaignId": 1, "order": 0, "type": "LINEAR",
+ "icon": null, "themes": null, "stakes": null, "gmNotes": null, "rewards": null, "resolution": null,
+ "relatedPageIds": [], "illustrationImageIds": [] }
+ ]
+ }
+ """;
+ byte[] zip = zipWithDataJson(dataJson);
+
+ long campaignsBefore = campaignRepo.count();
+ long arcsBefore = arcRepo.count();
+
+ ImportResult result = importService.importZip(new ByteArrayInputStream(zip));
+
+ // L'archive v1 s'importe sans exception ; son contenu est recréé et remappé.
+ assertEquals(campaignsBefore + 1, campaignRepo.count());
+ assertEquals(arcsBefore + 1, arcRepo.count());
+ assertEquals(1, result.created().get("campaigns"));
+ // Les 5 sections de jeu absentes → traitées comme vides (nullSafe), sans planter.
+ assertEquals(0, result.created().get("playthroughs"));
+ assertEquals(0, result.created().get("sessions"));
+ // La campagne v1 (sans playerCount → 0 par défaut) est bien présente.
+ assertTrue(campaignRepo.findAll().stream().anyMatch(c -> "V1 Camp".equals(c.getName())));
+ }
+
+ private static byte[] zipWithDataJson(String dataJson) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (ZipOutputStream zip = new ZipOutputStream(baos)) {
+ zip.putNextEntry(new ZipEntry("data.json"));
+ zip.write(dataJson.getBytes(StandardCharsets.UTF_8));
+ zip.closeEntry();
+ }
+ return baos.toByteArray();
+ }
+}
diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java
new file mode 100644
index 0000000..e5cf881
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/transfer/SelectiveExportTest.java
@@ -0,0 +1,162 @@
+package com.loremind.infrastructure.transfer;
+
+import com.loremind.domain.campaigncontext.ArcType;
+import com.loremind.domain.campaigncontext.Room;
+import com.loremind.infrastructure.persistence.entity.*;
+import com.loremind.infrastructure.persistence.jpa.*;
+import com.loremind.infrastructure.transfer.dto.ContentExport;
+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.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Export SÉLECTIF par campagne ({@link ExportService#buildExport(String, ExportRequest)}) :
+ * la clôture ne contient QUE la campagne ciblée, et les options lore/jeu/images en règlent
+ * le périmètre. Vérifie l'isolation vis-à-vis des autres campagnes et le débrayage des
+ * sections optionnelles.
+ */
+@SpringBootTest
+@Transactional
+class SelectiveExportTest {
+
+ @Autowired private ExportService exportService;
+ @Autowired private LoreJpaRepository loreRepo;
+ @Autowired private LoreNodeJpaRepository loreNodeRepo;
+ @Autowired private PageJpaRepository pageRepo;
+ @Autowired private CampaignJpaRepository campaignRepo;
+ @Autowired private ArcJpaRepository arcRepo;
+ @Autowired private ChapterJpaRepository chapterRepo;
+ @Autowired private SceneJpaRepository sceneRepo;
+ @Autowired private NpcJpaRepository npcRepo;
+ @Autowired private ImageJpaRepository imageRepo;
+ @Autowired private PlaythroughJpaRepository playthroughRepo;
+ @Autowired private CharacterJpaRepository characterRepo;
+
+ private Long lastLoreId;
+
+ @Test
+ void targetCampaign_scopesToCampaign_andExcludesPlayWhenOff() {
+ Long a = seedCampaign("Camp A", true, true);
+ seedCampaign("Camp B", true, true); // doit être absente de l'export de A
+
+ ContentExport ex = exportService.buildExport("t", new ExportRequest(a, true, false, true));
+
+ assertEquals("Camp A", ex.manifest().scope());
+ // Une seule campagne exportée : A.
+ assertEquals(List.of(a), ex.campaigns().stream().map(ContentExport.CampaignDto::id).toList());
+ // Arcs/PNJ : uniquement ceux de A.
+ assertFalse(ex.arcs().isEmpty());
+ assertTrue(ex.arcs().stream().allMatch(arc -> a.equals(arc.campaignId())));
+ assertTrue(ex.npcs().stream().allMatch(n -> a.equals(n.campaignId())));
+ // Jeu débrayé → ni parties ni feuilles de perso.
+ assertTrue(ex.playthroughs().isEmpty());
+ assertTrue(ex.characters().isEmpty());
+ // Lore inclus → lien conservé.
+ assertFalse(ex.lores().isEmpty());
+ assertEquals(String.valueOf(lastLoreIdOf(a)), ex.campaigns().get(0).loreId());
+ }
+
+ @Test
+ void withPlayOn_includesTargetCampaignPlayOnly() {
+ Long a = seedCampaign("Camp A", false, true);
+ Long b = seedCampaign("Camp B", false, true);
+
+ ContentExport ex = exportService.buildExport("t", new ExportRequest(a, true, true, true));
+
+ assertFalse(ex.playthroughs().isEmpty());
+ assertTrue(ex.playthroughs().stream().allMatch(p -> a.equals(p.campaignId())));
+ assertTrue(ex.playthroughs().stream().noneMatch(p -> b.equals(p.campaignId())));
+ assertFalse(ex.characters().isEmpty());
+ }
+
+ @Test
+ void withoutLore_nullifiesLinkAndOmitsLore() {
+ Long a = seedCampaign("Camp A", true, false);
+
+ ContentExport ex = exportService.buildExport("t", new ExportRequest(a, false, true, true));
+
+ assertTrue(ex.lores().isEmpty());
+ assertTrue(ex.pages().isEmpty());
+ // Lien lore neutralisé pour éviter une référence pendante.
+ assertNull(ex.campaigns().get(0).loreId());
+ }
+
+ @Test
+ void roomImages_areCollectedInClosure() {
+ // Image référencée UNIQUEMENT par une salle (Room) d'une scène (cas oublié initialement).
+ ImageJpaEntity img = imageRepo.save(ImageJpaEntity.builder()
+ .filename("plan.png").contentType("image/png").sizeBytes(5).storageKey("images/room-plan.png").build());
+ CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder().name("Camp R").arcsCount(1).build());
+ ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder()
+ .name("Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR).build());
+ ChapterJpaEntity chap = chapterRepo.save(ChapterJpaEntity.builder()
+ .name("Chap").arcId(arc.getId()).order(0).build());
+ Room room = Room.builder().id("r1").name("Salle")
+ .illustrationImageIds(new ArrayList<>(List.of(String.valueOf(img.getId())))).build();
+ sceneRepo.save(SceneJpaEntity.builder().name("Scene").chapterId(chap.getId()).order(0)
+ .rooms(new ArrayList<>(List.of(room))).build());
+
+ ContentExport ex = exportService.buildExport("t", new ExportRequest(camp.getId(), true, false, true));
+
+ assertTrue(ex.images().stream().anyMatch(i -> img.getId().equals(i.id())),
+ "l'image référencée par une room doit être collectée dans l'export ciblé");
+ }
+
+ @Test
+ void withoutImages_omitsImageMetadataAndBinaries() {
+ ImageJpaEntity img = imageRepo.save(ImageJpaEntity.builder()
+ .filename("i.png").contentType("image/png").sizeBytes(5).storageKey("images/no-img.png").build());
+ CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder().name("Camp NI").arcsCount(1).build());
+ arcRepo.save(ArcJpaEntity.builder().name("Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR)
+ .illustrationImageIds(new ArrayList<>(List.of(String.valueOf(img.getId())))).build());
+
+ ContentExport ex = exportService.buildExport("t", new ExportRequest(camp.getId(), true, true, false));
+
+ assertTrue(ex.images().isEmpty(), "includeImages=false → aucune métadonnée d'image exportée");
+ assertTrue(ex.storedFiles().isEmpty());
+ }
+
+ // --- Helpers de seed ------------------------------------------------------
+
+ /** Crée une campagne (+ arc + PNJ, + lore optionnel, + partie/perso optionnels) et renvoie son id. */
+ private Long seedCampaign(String name, boolean withLore, boolean withPlay) {
+ String loreLink = null;
+ if (withLore) {
+ LoreJpaEntity lore = loreRepo.save(LoreJpaEntity.builder().name(name + " Lore").build());
+ lastLoreId = lore.getId();
+ LoreNodeJpaEntity node = loreNodeRepo.save(LoreNodeJpaEntity.builder()
+ .name("N").loreId(lore.getId()).order(0).build());
+ pageRepo.save(PageJpaEntity.builder()
+ .loreId(lore.getId()).nodeId(node.getId()).title(name + " Page").build());
+ loreLink = String.valueOf(lore.getId());
+ }
+ CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
+ .name(name).loreId(loreLink).arcsCount(1).build());
+ camp.setLoreId(loreLink); // au cas où le builder ignorerait null
+ campaignRepo.save(camp);
+ loreByCampaign.put(camp.getId(), lastLoreId);
+ arcRepo.save(ArcJpaEntity.builder()
+ .name(name + " Arc").campaignId(camp.getId()).order(0).type(ArcType.LINEAR).build());
+ npcRepo.save(NpcJpaEntity.builder()
+ .name(name + " Npc").campaignId(camp.getId()).order(0).build());
+ if (withPlay) {
+ PlaythroughJpaEntity pt = playthroughRepo.save(PlaythroughJpaEntity.builder()
+ .campaignId(camp.getId()).name(name + " PT").build());
+ characterRepo.save(CharacterJpaEntity.builder()
+ .name(name + " Hero").campaignId(camp.getId()).playthroughId(pt.getId()).order(0).build());
+ }
+ return camp.getId();
+ }
+
+ private final java.util.Map loreByCampaign = new java.util.HashMap<>();
+
+ private Long lastLoreIdOf(Long campaignId) {
+ return loreByCampaign.get(campaignId);
+ }
+}
diff --git a/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java
new file mode 100644
index 0000000..8bf2051
--- /dev/null
+++ b/core/src/test/java/com/loremind/infrastructure/web/controller/DataTransferControllerTest.java
@@ -0,0 +1,95 @@
+package com.loremind.infrastructure.web.controller;
+
+import com.loremind.infrastructure.transfer.ExportRequest;
+import com.loremind.infrastructure.transfer.ExportService;
+import com.loremind.infrastructure.transfer.ImportService;
+import com.loremind.infrastructure.transfer.dto.ContentExport;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test UNITAIRE (sans Spring) de {@link DataTransferController} : on vérifie que les
+ * paramètres de requête sont bien traduits en {@link ExportRequest} (sauvegarde complète
+ * vs ciblée, passage des trois drapeaux) et le mapping des codes HTTP (404 campagne
+ * inconnue, 403 mode démo). Pattern « controller instancié à la main + services mockés »
+ * (cf. CampaignImportControllerUnitTest).
+ */
+class DataTransferControllerTest {
+
+ private final ExportService exportService = mock(ExportService.class);
+ private final ImportService importService = mock(ImportService.class);
+ private final DataTransferController controller =
+ new DataTransferController(exportService, importService, false);
+
+ private static ContentExport emptyExport(String scope) {
+ return new ContentExport(
+ new ContentExport.Manifest(2, "dev", "t", scope),
+ List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
+ List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of(),
+ List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), List.of());
+ }
+
+ private ArgumentCaptor captureExportAfter(Runnable call) {
+ when(exportService.buildExport(anyString(), any(ExportRequest.class)))
+ .thenReturn(emptyExport("complète"));
+ call.run();
+ ArgumentCaptor cap = ArgumentCaptor.forClass(ExportRequest.class);
+ verify(exportService).buildExport(anyString(), cap.capture());
+ return cap;
+ }
+
+ @Test
+ void export_noCampaignId_buildsFullRequest() {
+ ExportRequest req = captureExportAfter(() -> controller.export(null, true, true, true)).getValue();
+ assertTrue(req.isFull());
+ assertNull(req.campaignId());
+ }
+
+ @Test
+ void export_withParams_mapsCampaignAndAllFlags() {
+ ExportRequest req = captureExportAfter(() -> controller.export(5L, false, false, false)).getValue();
+ assertFalse(req.isFull());
+ assertEquals(5L, req.campaignId());
+ assertFalse(req.includeLore());
+ assertFalse(req.includePlay());
+ assertFalse(req.includeImages());
+ }
+
+ @Test
+ void export_targetedWithDefaults_keepsFlagsTrue() {
+ ExportRequest req = captureExportAfter(() -> controller.export(7L, true, true, true)).getValue();
+ assertEquals(7L, req.campaignId());
+ assertTrue(req.includeLore());
+ assertTrue(req.includePlay());
+ assertTrue(req.includeImages());
+ }
+
+ @Test
+ void export_unknownCampaign_maps404() {
+ when(exportService.buildExport(anyString(), any(ExportRequest.class)))
+ .thenThrow(new NoSuchElementException("Campagne introuvable : 999"));
+ ResponseStatusException ex = assertThrows(ResponseStatusException.class,
+ () -> controller.export(999L, true, true, true));
+ assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode());
+ }
+
+ @Test
+ void export_demoMode_forbidden() {
+ DataTransferController demo = new DataTransferController(exportService, importService, true);
+ ResponseStatusException ex = assertThrows(ResponseStatusException.class,
+ () -> demo.export(null, true, true, true));
+ assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode());
+ }
+}
diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
index d58d279..1750b55 100644
--- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
+++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html
@@ -5,7 +5,14 @@