Export/import par campagne + espace de jeu (v2), fix du nombre de joueurs, et ergonomie de jeu

Export / import de contenu (format v2)
- Export SÉLECTIF : on peut exporter une seule campagne (sa clôture : arcs →
  chapitres → scènes, PNJ, ennemis, catalogues, tables, système de jeu lié) au
  lieu de tout exporter. Options par export : inclure le lore, l'espace de jeu,
  les images. L'export global devient « Sauvegarde complète ».
- ESPACE DE JEU désormais exporté/importé : parties (Playthrough), séances,
  journal, flags, progression de quêtes, et feuilles de perso (Character.playthroughId
  préservé au lieu d'être annulé). Remapping complet des références à l'import.
- Format v2 rétro-compatible : un export v1 (sans section jeu) s'importe toujours
  (champs absents → null → traités comme vides).
- Correction d'un bug latent : collectReferencedStorageKeys traitait les références
  d'images comme des clés de stockage alors que ce sont des IDs → les binaires
  d'images n'étaient en réalité pas embarqués dans le zip. Résolution ID → clé.
- Images des Rooms d'une scène (illustration + plan) désormais collectées (elles
  étaient oubliées des deux collecteurs).
- Endpoint : GET /api/admin/data/export?campaignId=&includeLore=&includePlay=&includeImages=
  ; écran « Sauvegarde des données » avec choix campagne + cases à cocher.
- Tests : round-trip étendu au jeu + binaire image réel, export sélectif par
  campagne, import d'archive v1, et DataTransferController.

Correction : nombre de joueurs non persisté
- playerCount était saisi à la création mais jeté par le backend (absent du
  domaine/entité/DTO/mapper) → affichage « 0 joueurs ». Ajouté sur toute la stack
  + migration V7 (défaut 4 pour les campagnes existantes) + intégré à l'export.

Ergonomie de l'espace de jeu et des listes
- Description de campagne repliable (clamp 3 lignes + « Voir plus / moins »).
- Dossiers repliables (chevron + compteur) dans les listes PNJ et Ennemis, sur la
  page campagne ET les pages dédiées.
- Renommage en place des parties (« Mes parties ») via un crayon.
- Panneau de session : onglets sur 2 rangs (« Scènes » n'est plus rogné) ; scènes
  triées par ordre manuel ; arcs repliables (repliés par défaut) ; PJ/PNJ repliables
  et PNJ regroupés par dossier.
This commit is contained in:
2026-06-26 14:38:39 +02:00
parent c1e3a20e5a
commit f670735100
37 changed files with 1487 additions and 258 deletions

View File

@@ -48,7 +48,7 @@ public class CampaignService {
this.playthroughService = playthroughService; 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) {} public record DeletionImpact(int arcs, int chapters, int scenes, int playthroughs) {}
@@ -59,6 +59,7 @@ public class CampaignService {
.loreId(normalizeId(data.loreId())) .loreId(normalizeId(data.loreId()))
.gameSystemId(normalizeId(data.gameSystemId())) .gameSystemId(normalizeId(data.gameSystemId()))
.arcsCount(0) .arcsCount(0)
.playerCount(data.playerCount())
.build(); .build();
Campaign saved = campaignRepository.save(campaign); Campaign saved = campaignRepository.save(campaign);
@@ -87,6 +88,7 @@ public class CampaignService {
campaign.setDescription(data.description()); campaign.setDescription(data.description());
campaign.setLoreId(normalizeId(data.loreId())); campaign.setLoreId(normalizeId(data.loreId()));
campaign.setGameSystemId(normalizeId(data.gameSystemId())); campaign.setGameSystemId(normalizeId(data.gameSystemId()));
campaign.setPlayerCount(data.playerCount());
return campaignRepository.save(campaign); return campaignRepository.save(campaign);
} }

View File

@@ -11,11 +11,9 @@ import java.util.Optional;
/** /**
* Service d'application du contexte Conversation. * Service d'application du contexte Conversation.
*
* Regroupe les cas d'usage CRUD + append message + rename. Un seul * Regroupe les cas d'usage CRUD + append message + rename. Un seul
* service suffit — le contexte est simple et les operations fortement * service suffit — le contexte est simple et les operations fortement
* liees (meme aggregat). * liees (meme aggregat).
*
* Regles metier : * Regles metier :
* - exactement un ancrage parent (loreId XOR campaignId) ; * - exactement un ancrage parent (loreId XOR campaignId) ;
* - entityType et entityId vont ensemble (tous deux null = niveau racine, * - entityType et entityId vont ensemble (tous deux null = niveau racine,

View File

@@ -24,6 +24,9 @@ public class Campaign {
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private int arcsCount; 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. * Référence faible vers un Lore. Nullable.
* Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context. * Ce n'est qu'un ID : le Campaign Context ne dépend PAS du Lore Context.

View File

@@ -37,6 +37,9 @@ public class CampaignJpaEntity {
@Column(name = "arcs_count", nullable = false) @Column(name = "arcs_count", nullable = false)
private int arcsCount; private int arcsCount;
@Column(name = "player_count", nullable = false)
private int playerCount;
/** /**
* ID du Lore associé (nullable). * ID du Lore associé (nullable).
* Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte. * Pas de @ManyToOne / pas de FK : c'est une weak reference inter-contexte.

View File

@@ -73,6 +73,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.arcsCount(jpaEntity.getArcsCount()) .arcsCount(jpaEntity.getArcsCount())
.playerCount(jpaEntity.getPlayerCount())
.loreId(jpaEntity.getLoreId()) .loreId(jpaEntity.getLoreId())
.gameSystemId(jpaEntity.getGameSystemId()) .gameSystemId(jpaEntity.getGameSystemId())
.build(); .build();
@@ -87,6 +88,7 @@ public class PostgresCampaignRepository implements CampaignRepository {
.createdAt(campaign.getCreatedAt()) .createdAt(campaign.getCreatedAt())
.updatedAt(campaign.getUpdatedAt()) .updatedAt(campaign.getUpdatedAt())
.arcsCount(campaign.getArcsCount()) .arcsCount(campaign.getArcsCount())
.playerCount(campaign.getPlayerCount())
.loreId(campaign.getLoreId()) .loreId(campaign.getLoreId())
.gameSystemId(campaign.getGameSystemId()) .gameSystemId(campaign.getGameSystemId())
.build(); .build();

View File

@@ -0,0 +1,31 @@
package com.loremind.infrastructure.transfer;
/**
* Périmètre d'un export de contenu.
* <p>
* - {@code campaignId == null} → <b>Sauvegarde complète</b> : tout le contenu de la base.<br>
* - {@code campaignId != null} → <b>export ciblé</b> : 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.
* <p>
* 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;
}
}

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
@@ -39,7 +40,7 @@ import java.util.zip.ZipOutputStream;
@Service @Service
public class ExportService { 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 // 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. // format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
@@ -63,6 +64,11 @@ public class ExportService {
private final ImageStorage imageStorage; private final ImageStorage imageStorage;
private final StoredFileJpaRepository storedFileRepo; private final StoredFileJpaRepository storedFileRepo;
private final FileStorage fileStorage; 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 ObjectMapper objectMapper;
private final String appVersion; private final String appVersion;
@@ -84,6 +90,11 @@ public class ExportService {
ImageStorage imageStorage, ImageStorage imageStorage,
StoredFileJpaRepository storedFileRepo, StoredFileJpaRepository storedFileRepo,
FileStorage fileStorage, FileStorage fileStorage,
PlaythroughJpaRepository playthroughRepo,
SessionJpaRepository sessionRepo,
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
QuestProgressionJpaRepository questProgressionRepo,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo; this.gameSystemRepo = gameSystemRepo;
@@ -104,55 +115,183 @@ public class ExportService {
this.imageStorage = imageStorage; this.imageStorage = imageStorage;
this.storedFileRepo = storedFileRepo; this.storedFileRepo = storedFileRepo;
this.fileStorage = fileStorage; this.fileStorage = fileStorage;
this.playthroughRepo = playthroughRepo;
this.sessionRepo = sessionRepo;
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.questProgressionRepo = questProgressionRepo;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; 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) * @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 = 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<ContentExport.GameSystemDto> gameSystems = gameSystemRepo.findAll().stream() /**
.map(this::toGameSystemDto).toList(); * Export CIBLÉ : la campagne et sa clôture (arcs → chapitres → scènes, PNJ, ennemis,
List<ContentExport.LoreDto> lores = loreRepo.findAll().stream() * catalogues, tables, système de jeu lié), plus — selon les options — son univers (lore)
.map(this::toLoreDto).toList(); * et son espace de jeu (parties → sessions/journal/flags/quêtes + feuilles de perso).
List<ContentExport.LoreNodeDto> loreNodes = loreNodeRepo.findAll().stream() * Les images/fichiers exportés sont uniquement ceux RÉFÉRENCÉS par la clôture.
.map(this::toLoreNodeDto).toList(); */
List<ContentExport.TemplateDto> templates = templateRepo.findAll().stream() private ContentExport buildCampaignExport(String exportedAt, ExportRequest req) {
.map(this::toTemplateDto).toList(); Long cid = req.campaignId();
List<ContentExport.PageDto> pages = pageRepo.findAll().stream() CampaignJpaEntity campaign = campaignRepo.findById(cid)
.map(this::toPageDto).toList(); .orElseThrow(() -> new java.util.NoSuchElementException("Campagne introuvable : " + cid));
List<ContentExport.CampaignDto> campaigns = campaignRepo.findAll().stream()
.map(this::toCampaignDto).toList();
List<ContentExport.ArcDto> arcs = arcRepo.findAll().stream()
.map(this::toArcDto).toList();
List<ContentExport.ChapterDto> chapters = chapterRepo.findAll().stream()
.map(this::toChapterDto).toList();
List<ContentExport.SceneDto> scenes = sceneRepo.findAll().stream()
.map(this::toSceneDto).toList();
List<ContentExport.CharacterDto> characters = characterRepo.findAll().stream()
.map(this::toCharacterDto).toList();
List<ContentExport.NpcDto> npcs = npcRepo.findAll().stream()
.map(this::toNpcDto).toList();
List<ContentExport.EnemyDto> enemies = enemyRepo.findAll().stream()
.map(this::toEnemyDto).toList();
List<ContentExport.ItemCatalogDto> itemCatalogs = itemCatalogRepo.findAll().stream()
.map(this::toItemCatalogDto).toList();
List<ContentExport.RandomTableDto> randomTables = randomTableRepo.findAll().stream()
.map(this::toRandomTableDto).toList();
List<ContentExport.ImageDto> images = imageRepo.findAll().stream()
.map(this::toImageDto).toList();
List<ContentExport.StoredFileDto> storedFiles = storedFileRepo.findAll().stream()
.map(this::toStoredFileDto).toList();
return new ContentExport(manifest, gameSystems, lores, loreNodes, templates, // Prep : clôture structurelle de la campagne.
pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies, List<ArcJpaEntity> arcEntities = arcRepo.findByCampaignId(cid);
itemCatalogs, randomTables, images, storedFiles); List<ChapterJpaEntity> chapterEntities = arcEntities.stream()
.flatMap(a -> chapterRepo.findByArcId(a.getId()).stream()).toList();
List<SceneJpaEntity> sceneEntities = chapterEntities.stream()
.flatMap(c -> sceneRepo.findByChapterId(c.getId()).stream()).toList();
List<NpcJpaEntity> npcEntities = npcRepo.findByCampaignIdOrderByOrderAsc(cid);
List<EnemyJpaEntity> enemyEntities = enemyRepo.findByCampaignIdOrderByOrderAsc(cid);
List<ItemCatalogJpaEntity> catalogEntities = itemCatalogRepo.findByCampaignIdOrderByOrderAsc(cid);
List<RandomTableJpaEntity> tableEntities = randomTableRepo.findByCampaignIdOrderByOrderAsc(cid);
// Système de jeu lié : TOUJOURS inclus (templates/PDF en dépendent).
List<GameSystemJpaEntity> gsEntities = singleton(gameSystemRepo, parseLongOrNull(campaign.getGameSystemId()));
// Univers (lore) lié : optionnel.
Long lid = req.includeLore() ? parseLongOrNull(campaign.getLoreId()) : null;
List<LoreJpaEntity> loreEntities = lid != null ? singleton(loreRepo, lid) : List.of();
List<LoreNodeJpaEntity> loreNodeEntities = lid != null ? loreNodeRepo.findByLoreId(lid) : List.of();
List<TemplateJpaEntity> templateEntities = lid != null ? templateRepo.findByLoreId(lid) : List.of();
List<PageJpaEntity> pageEntities = lid != null ? pageRepo.findByLoreId(lid) : List.of();
// Espace de jeu : optionnel. Les feuilles de perso appartiennent à une Partie,
// donc « sans jeu » = sans feuilles de perso.
List<PlaythroughJpaEntity> ptEntities = req.includePlay() ? playthroughRepo.findByCampaignId(cid) : List.of();
List<SessionJpaEntity> sessionEntities = ptEntities.stream()
.flatMap(p -> sessionRepo.findByPlaythroughIdOrderByStartedAtDesc(p.getId()).stream()).toList();
List<SessionEntryJpaEntity> entryEntities = sessionEntities.stream()
.flatMap(s -> sessionEntryRepo.findBySessionIdOrderByOccurredAtAsc(String.valueOf(s.getId())).stream()).toList();
List<PlaythroughFlagJpaEntity> flagEntities = ptEntities.stream()
.flatMap(p -> playthroughFlagRepo.findByPlaythroughId(p.getId()).stream()).toList();
List<QuestProgressionJpaEntity> questEntities = ptEntities.stream()
.flatMap(p -> questProgressionRepo.findByPlaythroughId(p.getId()).stream()).toList();
List<CharacterJpaEntity> 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<ImageJpaEntity> imageEntities = List.of();
List<StoredFileJpaEntity> fileEntities = List.of();
if (req.includeImages()) {
Set<String> imageRefs = new LinkedHashSet<>();
arcEntities.forEach(a -> addAll(imageRefs, a.getIllustrationImageIds()));
chapterEntities.forEach(c -> addAll(imageRefs, c.getIllustrationImageIds()));
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<Long> 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 <E, D> List<D> map(List<E> in, java.util.function.Function<E, D> f) {
return in.stream().map(f).toList();
}
/** Liste 0/1 élément : l'entité d'id donné si présente. */
private static <T, R extends org.springframework.data.repository.CrudRepository<T, Long>>
List<T> 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<Long> 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). * (portrait + header + imageValues), Page (imageValues).
*/ */
private Set<String> collectReferencedStorageKeys(ContentExport export) { private Set<String> collectReferencedStorageKeys(ContentExport export) {
Set<String> keys = new LinkedHashSet<>(); // Les entités référencent les images par ID (cf. Image.getId() renvoyé à l'upload),
for (ContentExport.ArcDto a : export.arcs()) { // PAS par clé de stockage. On résout donc ID -> storageKey via l'index des images
addAll(keys, a.illustrationImageIds()); // exportées — même logique que collectReferencedFileStorageKeys pour les fichiers.
} java.util.Map<String, String> keyByImageId = new java.util.HashMap<>();
for (ContentExport.ChapterDto c : export.chapters()) { for (ContentExport.ImageDto img : export.images()) {
addAll(keys, c.illustrationImageIds()); if (img.id() != null) keyByImageId.put(img.id().toString(), img.storageKey());
}
for (ContentExport.SceneDto s : export.scenes()) {
addAll(keys, s.illustrationImageIds());
} }
Set<String> refs = new LinkedHashSet<>();
for (ContentExport.ArcDto a : export.arcs()) addAll(refs, a.illustrationImageIds());
for (ContentExport.ChapterDto c : export.chapters()) addAll(refs, c.illustrationImageIds());
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()) { for (ContentExport.CharacterDto c : export.characters()) {
add(keys, c.portraitImageId()); add(refs, c.portraitImageId());
add(keys, c.headerImageId()); add(refs, c.headerImageId());
addImageValues(keys, c.imageValues()); addImageValues(refs, c.imageValues());
} }
for (ContentExport.NpcDto n : export.npcs()) { for (ContentExport.NpcDto n : export.npcs()) {
add(keys, n.portraitImageId()); add(refs, n.portraitImageId());
add(keys, n.headerImageId()); add(refs, n.headerImageId());
addImageValues(keys, n.imageValues()); addImageValues(refs, n.imageValues());
} }
for (ContentExport.EnemyDto e : export.enemies()) { for (ContentExport.EnemyDto e : export.enemies()) {
add(keys, e.portraitImageId()); add(refs, e.portraitImageId());
add(keys, e.headerImageId()); add(refs, e.headerImageId());
addImageValues(keys, e.imageValues()); addImageValues(refs, e.imageValues());
} }
for (ContentExport.PageDto p : export.pages()) { for (ContentExport.PageDto p : export.pages()) addImageValues(refs, p.imageValues());
addImageValues(keys, p.imageValues());
Set<String> keys = new LinkedHashSet<>();
for (String ref : refs) {
String key = keyByImageId.get(ref);
if (key != null && !key.isBlank()) keys.add(key);
} }
return keys; return keys;
} }
@@ -288,6 +433,15 @@ public class ExportService {
if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l)); 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<String> keys, List<Room> rooms) {
if (rooms == null) return;
for (Room r : rooms) {
addAll(keys, r.getIllustrationImageIds());
add(keys, r.getMapImageId());
}
}
// ----- Mappers entite -> DTO ----- // ----- Mappers entite -> DTO -----
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) { private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
@@ -320,7 +474,7 @@ public class ExportService {
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) { private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(), 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) { private ContentExport.ArcDto toArcDto(ArcJpaEntity e) {
@@ -400,4 +554,31 @@ public class ExportService {
return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(), return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey()); 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);
}
} }

View File

@@ -1,6 +1,8 @@
package com.loremind.infrastructure.transfer; package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper; 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.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.persistence.jpa.*;
@@ -61,6 +63,11 @@ public class ImportService {
private final EnemyJpaRepository enemyRepo; private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo; private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo; 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 ImageImporter imageImporter;
private final StoredFileImporter storedFileImporter; private final StoredFileImporter storedFileImporter;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
@@ -79,6 +86,11 @@ public class ImportService {
EnemyJpaRepository enemyRepo, EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo, ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo, RandomTableJpaRepository randomTableRepo,
PlaythroughJpaRepository playthroughRepo,
SessionJpaRepository sessionRepo,
SessionEntryJpaRepository sessionEntryRepo,
PlaythroughFlagJpaRepository playthroughFlagRepo,
QuestProgressionJpaRepository questProgressionRepo,
ImageImporter imageImporter, ImageImporter imageImporter,
StoredFileImporter storedFileImporter, StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
@@ -96,6 +108,11 @@ public class ImportService {
this.enemyRepo = enemyRepo; this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo; this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo; this.randomTableRepo = randomTableRepo;
this.playthroughRepo = playthroughRepo;
this.sessionRepo = sessionRepo;
this.sessionEntryRepo = sessionEntryRepo;
this.playthroughFlagRepo = playthroughFlagRepo;
this.questProgressionRepo = questProgressionRepo;
this.imageImporter = imageImporter; this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter; this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
@@ -126,6 +143,8 @@ public class ImportService {
Map<Long, Long> enemyMap = new HashMap<>(); Map<Long, Long> enemyMap = new HashMap<>();
Map<Long, Long> characterMap = new HashMap<>(); Map<Long, Long> characterMap = new HashMap<>();
Map<Long, Long> sceneMap = new HashMap<>(); Map<Long, Long> sceneMap = new HashMap<>();
Map<Long, Long> playthroughMap = new HashMap<>();
Map<Long, Long> sessionMap = new HashMap<>();
// -- GameSystem // -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
@@ -206,12 +225,61 @@ public class ImportService {
e.setName(d.name()); e.setName(d.name());
e.setDescription(d.description()); e.setDescription(d.description());
e.setArcsCount(d.arcsCount()); e.setArcsCount(d.arcsCount());
e.setPlayerCount(d.playerCount());
e.setLoreId(d.loreId()); // remappe plus bas e.setLoreId(d.loreId()); // remappe plus bas
e.setGameSystemId(d.gameSystemId()); // remappe plus bas e.setGameSystemId(d.gameSystemId()); // remappe plus bas
campaignMap.put(d.id(), campaignRepo.save(e).getId()); campaignMap.put(d.id(), campaignRepo.save(e).getId());
} }
result.count("campaigns", campaignMap.size()); 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) // -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) { for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity(); ArcJpaEntity e = new ArcJpaEntity();
@@ -307,6 +375,19 @@ public class ImportService {
} }
result.count("chapters", chapterMap.size()); 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) // -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) { for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity(); NpcJpaEntity e = new NpcJpaEntity();
@@ -353,7 +434,9 @@ public class ImportService {
e.setImageValues(d.imageValues()); e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues()); e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); 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()); e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId()); characterMap.put(d.id(), characterRepo.save(e).getId());
} }
@@ -518,6 +601,36 @@ public class ImportService {
return list != null ? list : List.of(); 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 { private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer); in.transferTo(buffer);

View File

@@ -36,18 +36,27 @@ public record ContentExport(
List<ItemCatalogDto> itemCatalogs, List<ItemCatalogDto> itemCatalogs,
List<RandomTableDto> randomTables, List<RandomTableDto> randomTables,
List<ImageDto> images, List<ImageDto> images,
List<StoredFileDto> storedFiles List<StoredFileDto> storedFiles,
// --- Espace de jeu (format v2). Absent des exports v1 -> listes null a la
// relecture (Jackson) -> traitees comme vides cote import (nullSafe). ---
List<PlaythroughDto> playthroughs,
List<SessionDto> sessions,
List<SessionEntryDto> sessionEntries,
List<PlaythroughFlagDto> playthroughFlags,
List<QuestProgressionDto> questProgressions
) { ) {
/** /**
* Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis * Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis
* la couche requete (PAS Instant.now() ici, pour rester deterministe et * 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( public record Manifest(
int formatVersion, int formatVersion,
String appVersion, String appVersion,
String exportedAt String exportedAt,
String scope
) {} ) {}
public record GameSystemDto( public record GameSystemDto(
@@ -108,6 +117,7 @@ public record ContentExport(
String name, String name,
String description, String description,
int arcsCount, int arcsCount,
int playerCount,
String loreId, String loreId,
String gameSystemId String gameSystemId
) {} ) {}
@@ -278,4 +288,49 @@ public record ContentExport(
long sizeBytes, long sizeBytes,
String storageKey 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
) {}
} }

View File

@@ -31,7 +31,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) { public ResponseEntity<CampaignDTO> createCampaign(@RequestBody CampaignDTO campaignDTO) {
Campaign campaign = campaignMapper.toDomain(campaignDTO); Campaign campaign = campaignMapper.toDomain(campaignDTO);
Campaign createdCampaign = campaignService.createCampaign( 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)); return ResponseEntity.ok(campaignMapper.toDTO(createdCampaign));
} }
@@ -64,7 +64,7 @@ public class CampaignController {
public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) { public ResponseEntity<CampaignDTO> updateCampaign(@PathVariable String id, @RequestBody CampaignDTO campaignDTO) {
Campaign updatedCampaign = campaignService.updateCampaign( Campaign updatedCampaign = campaignService.updateCampaign(
id, 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)); return ResponseEntity.ok(campaignMapper.toDTO(updatedCampaign));
} }

View File

@@ -1,5 +1,6 @@
package com.loremind.infrastructure.web.controller; package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.transfer.ExportRequest;
import com.loremind.infrastructure.transfer.ExportService; import com.loremind.infrastructure.transfer.ExportService;
import com.loremind.infrastructure.transfer.ImportResult; import com.loremind.infrastructure.transfer.ImportResult;
import com.loremind.infrastructure.transfer.ImportService; import com.loremind.infrastructure.transfer.ImportService;
@@ -47,22 +48,52 @@ public class DataTransferController {
this.demoMode = demoMode; this.demoMode = demoMode;
} }
/**
* Export portable. Sans {@code campaignId} : <b>sauvegarde complète</b> (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") @GetMapping(value = "/export", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> export() { public ResponseEntity<StreamingResponseBody> export(
@RequestParam(required = false) Long campaignId,
@RequestParam(defaultValue = "true") boolean includeLore,
@RequestParam(defaultValue = "true") boolean includePlay,
@RequestParam(defaultValue = "true") boolean includeImages) {
guardDemoMode(); guardDemoMode();
// Stamp de l'horodatage cote requete (PAS dans un init statique). // Stamp de l'horodatage cote requete (PAS dans un init statique).
String exportedAt = Instant.now().toString(); 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); StreamingResponseBody body = out -> exportService.writeZip(content, out);
String filename = "loremind-" + slug(content.manifest().scope()) + ".zip";
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
"attachment; filename=\"loremind-export.zip\"")
.contentType(MediaType.parseMediaType("application/zip")) .contentType(MediaType.parseMediaType("application/zip"))
.body(body); .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) @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportResult> importData(@RequestParam("file") MultipartFile file) { public ResponseEntity<ImportResult> importData(@RequestParam("file") MultipartFile file) {
guardDemoMode(); guardDemoMode();

View File

@@ -13,6 +13,8 @@ public class CampaignDTO {
private String name; private String name;
private String description; private String description;
private int arcsCount; private int arcsCount;
/** Nombre de joueurs attendus à la table. */
private int playerCount;
/** Nullable : campagne sans univers associé. */ /** Nullable : campagne sans univers associé. */
private String loreId; private String loreId;
/** Nullable : campagne sans système de JDR associé (générique). */ /** Nullable : campagne sans système de JDR associé (générique). */

View File

@@ -20,6 +20,7 @@ public class CampaignMapper {
dto.setName(campaign.getName()); dto.setName(campaign.getName());
dto.setDescription(campaign.getDescription()); dto.setDescription(campaign.getDescription());
dto.setArcsCount(campaign.getArcsCount()); dto.setArcsCount(campaign.getArcsCount());
dto.setPlayerCount(campaign.getPlayerCount());
dto.setLoreId(campaign.getLoreId()); dto.setLoreId(campaign.getLoreId());
dto.setGameSystemId(campaign.getGameSystemId()); dto.setGameSystemId(campaign.getGameSystemId());
return dto; return dto;
@@ -35,6 +36,7 @@ public class CampaignMapper {
.name(dto.getName()) .name(dto.getName())
.description(dto.getDescription()) .description(dto.getDescription())
.arcsCount(dto.getArcsCount()) .arcsCount(dto.getArcsCount())
.playerCount(dto.getPlayerCount())
.loreId(dto.getLoreId()) .loreId(dto.getLoreId())
.gameSystemId(dto.getGameSystemId()) .gameSystemId(dto.getGameSystemId())
.build(); .build();

View File

@@ -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;

View File

@@ -70,7 +70,8 @@ public class CampaignServiceTest {
"New Campaign", "New Campaign",
"Description", "Description",
"lore-123", "lore-123",
null null,
4
); );
// Le repo renvoie la Campaign telle que passée — on teste la normalisation // Le repo renvoie la Campaign telle que passée — on teste la normalisation
// du loreId dans le service, pas le comportement du repo. // du loreId dans le service, pas le comportement du repo.
@@ -93,7 +94,8 @@ public class CampaignServiceTest {
"New Campaign", "New Campaign",
"Description", "Description",
null, null,
null null,
4
); );
when(campaignRepository.save(any(Campaign.class))) when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
@@ -114,7 +116,8 @@ public class CampaignServiceTest {
"New Campaign", "New Campaign",
"Description", "Description",
" ", " ",
null null,
4
); );
when(campaignRepository.save(any(Campaign.class))) when(campaignRepository.save(any(Campaign.class)))
.thenAnswer(invocation -> invocation.getArgument(0)); .thenAnswer(invocation -> invocation.getArgument(0));
@@ -179,7 +182,8 @@ public class CampaignServiceTest {
"Updated Campaign", "Updated Campaign",
"Updated Description", "Updated Description",
"lore-456", "lore-456",
null null,
4
); );
when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign)); when(campaignRepository.findById("campaign-1")).thenReturn(Optional.of(testCampaign));
when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign); when(campaignRepository.save(any(Campaign.class))).thenReturn(testCampaign);
@@ -200,7 +204,8 @@ public class CampaignServiceTest {
"Updated Campaign", "Updated Campaign",
"Updated Description", "Updated Description",
"lore-456", "lore-456",
null null,
4
); );
when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty()); when(campaignRepository.findById("invalid-id")).thenReturn(Optional.empty());

View File

@@ -2,9 +2,11 @@ package com.loremind.infrastructure.transfer;
import com.loremind.domain.campaigncontext.ArcType; import com.loremind.domain.campaigncontext.ArcType;
import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.ProgressionStatus;
import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.domain.playcontext.EntryType;
import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport; import com.loremind.infrastructure.transfer.dto.ContentExport;
@@ -17,6 +19,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
@@ -28,23 +31,19 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when; 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.
* <p> * <p>
* On sème un graphe complet (système de jeu, lore/dossiers/template/page, campagne, * On sème un graphe complet — prep (système, lore/dossiers/template/page, campagne, arc,
* arc, chapitres avec prérequis, scène avec branches/ennemis/battlemap, PNJ, ennemi, * chapitres avec prérequis, scène, PNJ, ennemi) ET espace de jeu (partie, séance, journal,
* personnage, catalogue d'objets, table aléatoire, image + fichier), on l'exporte, * 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 : * on le réimporte, puis on vérifie :
* <ul> * <ul>
* <li>le zip embarque {@code manifest.json}, {@code data.json} et les binaires * <li>le zip embarque manifest/data + les binaires d'images (référencées par <b>ID</b>,
* d'images/fichiers RÉFÉRENCÉS ;</li> * résolu en clé de stockage) et de fichiers RÉFÉRENCÉS ;</li>
* <li>l'import DOUBLE chaque type (l'export étant global) ;</li> * <li>l'import DOUBLE chaque type, jeu compris ;</li>
* <li>les clés d'images (uniques) sont RÉUTILISÉES, pas dupliquées ;</li> * <li>les références sont remappées : l'arc importé pointe la page importée, et la feuille
* <li>les références sont remappées en 2e passe (l'arc importé pointe la page * de perso / séance / progression importées pointent la Partie / le chapitre importés.</li>
* importée, pas l'originale) ;</li>
* <li>le {@code playthroughId} d'un personnage est remis à null à l'import.</li>
* </ul> * </ul>
* 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 @SpringBootTest
@Transactional @Transactional
@@ -69,6 +68,11 @@ class ContentExportImportRoundTripTest {
@Autowired private RandomTableJpaRepository randomTableRepo; @Autowired private RandomTableJpaRepository randomTableRepo;
@Autowired private ImageJpaRepository imageRepo; @Autowired private ImageJpaRepository imageRepo;
@Autowired private StoredFileJpaRepository storedFileRepo; @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 ImageStorage imageStorage;
@MockitoBean private FileStorage fileStorage; @MockitoBean private FileStorage fileStorage;
@@ -77,8 +81,8 @@ class ContentExportImportRoundTripTest {
private static final String FILE_KEY = "files/round-trip-map.json"; private static final String FILE_KEY = "files/round-trip-map.json";
@Test @Test
void roundTrip_duplicatesEveryContentTypeAndRemapsReferences() throws IOException { void roundTrip_duplicatesEveryContentTypeIncludingPlayAndRemapsReferences() throws IOException {
// ----- 1. Graphe de contenu riche ----- // ----- 1. Prep -----
GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder() GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder()
.name("RT System").description("d").foundryActorType("npc").isPublic(true).build()); .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()); .name("RT Child").loreId(lore.getId()).parentId(rootNode.getId()).order(1).build());
TemplateJpaEntity template = templateRepo.save(TemplateJpaEntity.builder() TemplateJpaEntity template = templateRepo.save(TemplateJpaEntity.builder()
.loreId(lore.getId()).name("RT Template").defaultNodeId(rootNode.getId()).build()); .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()); .filename("abc.png").contentType("image/png").sizeBytes(7).storageKey(IMG_KEY).build());
String imageRef = String.valueOf(image.getId());
StoredFileJpaEntity storedFile = storedFileRepo.save(StoredFileJpaEntity.builder() StoredFileJpaEntity storedFile = storedFileRepo.save(StoredFileJpaEntity.builder()
.filename("map.json").contentType("application/json").sizeBytes(6).storageKey(FILE_KEY).build()); .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() CampaignJpaEntity campaign = campaignRepo.save(CampaignJpaEntity.builder()
.name("RT Campaign").description("d").arcsCount(1) .name("RT Campaign").description("d").arcsCount(1)
.loreId(String.valueOf(lore.getId())).gameSystemId(String.valueOf(gs.getId())).build()); .loreId(String.valueOf(lore.getId())).gameSystemId(String.valueOf(gs.getId())).build());
@@ -108,7 +114,7 @@ class ContentExportImportRoundTripTest {
ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder()
.name("RT Arc").campaignId(campaign.getId()).order(0).type(ArcType.HUB) .name("RT Arc").campaignId(campaign.getId()).order(0).type(ArcType.HUB)
.relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.getId())))) .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() ChapterJpaEntity chapterA = chapterRepo.save(ChapterJpaEntity.builder()
.name("RT Chapter A").arcId(arc.getId()).order(0).build()); .name("RT Chapter A").arcId(arc.getId()).order(0).build());
@@ -127,17 +133,14 @@ class ContentExportImportRoundTripTest {
.name("RT Scene").chapterId(chapterB.getId()).order(0) .name("RT Scene").chapterId(chapterB.getId()).order(0)
.enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId())))) .enemyIds(new ArrayList<>(List.of(String.valueOf(enemy.getId()))))
.relatedPageIds(new ArrayList<>(List.of(String.valueOf(page.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())) .battlemapMediaFileId(String.valueOf(storedFile.getId()))
.branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build()); .branches(new ArrayList<>(List.of(new SceneBranch("Si fuite", null, "cond")))).build());
npcRepo.save(NpcJpaEntity.builder() 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()); .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() ItemCatalogJpaEntity catalog = ItemCatalogJpaEntity.builder()
.name("RT Catalog").campaignId(campaign.getId()).order(0).build(); .name("RT Catalog").campaignId(campaign.getId()).order(0).build();
CatalogItemJpaEntity item = CatalogItemJpaEntity.builder() CatalogItemJpaEntity item = CatalogItemJpaEntity.builder()
@@ -152,77 +155,114 @@ class ContentExportImportRoundTripTest {
table.setEntries(new ArrayList<>(List.of(entry))); table.setEntries(new ArrayList<>(List.of(entry)));
randomTableRepo.save(table); 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 campaignsBefore = campaignRepo.count();
long arcsBefore = arcRepo.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 pagesBefore = pageRepo.count();
long gsBefore = gameSystemRepo.count();
long imagesBefore = imageRepo.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 campaignId0 = campaign.getId();
Long pageId0 = page.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(imageStorage.download(IMG_KEY)).thenAnswer(inv -> new ByteArrayInputStream("PNGDATA".getBytes()));
when(fileStorage.download(FILE_KEY)).thenAnswer(inv -> new ByteArrayInputStream("{\"x\":1}".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"); ContentExport export = exportService.buildExport("2026-01-02T03:04:05Z");
assertEquals(1, export.manifest().formatVersion()); assertEquals(2, export.manifest().formatVersion());
assertEquals("2026-01-02T03:04:05Z", export.manifest().exportedAt()); assertEquals("complète", export.manifest().scope());
assertNotNull(export.manifest().appVersion()); assertFalse(export.playthroughs().isEmpty());
assertFalse(export.campaigns().isEmpty());
ByteArrayOutputStream baos = new ByteArrayOutputStream(); ByteArrayOutputStream baos = new ByteArrayOutputStream();
exportService.writeZip(export, baos); exportService.writeZip(export, baos);
Map<String, byte[]> zip = readZip(baos.toByteArray()); Map<String, byte[]> zip = readZip(baos.toByteArray());
assertTrue(zip.containsKey("manifest.json")); assertTrue(zip.containsKey("manifest.json"));
assertTrue(zip.containsKey("data.json")); assertTrue(zip.containsKey("data.json"));
// Binaire image référencé (portrait/illustration) embarqué + fichier de battlemap. // 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érencé attendu dans le zip"); 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"); 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())); 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 * campaignsBefore, campaignRepo.count());
assertEquals(2 * arcsBefore, arcRepo.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 * pagesBefore, pageRepo.count());
assertEquals(2 * gsBefore, gameSystemRepo.count()); assertEquals(imagesBefore, imageRepo.count()); // clé unique réutilisée
// Clé image UNIQUE déjà présente → réutilisée, pas de doublon. assertTrue(result.imagesReused() >= 1);
assertEquals(imagesBefore, imageRepo.count()); // Espace de jeu également dupliqué.
assertTrue(result.imagesReused() >= 1, "l'image référencée doit être réutilisée"); assertEquals(2 * playthroughsBefore, playthroughRepo.count());
assertEquals((int) campaignsBefore, result.created().get("campaigns")); 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) ----- // ----- 4. Remapping -----
// 4a. L'arc importé référence la PAGE importée (remappée), pas l'originale. Long importedCampaignId = onlyOther(idsByName(campaignRepo.findAll(), CampaignJpaEntity::getName,
Long importedCampaignId = onlyOther(campaignRepo.findAll().stream() "RT Campaign", CampaignJpaEntity::getId), campaignId0);
.filter(c -> "RT Campaign".equals(c.getName())).map(CampaignJpaEntity::getId).toList(), campaignId0); Long importedPageId = onlyOther(idsByName(pageRepo.findAll(), PageJpaEntity::getTitle,
Long importedPageId = onlyOther(pageRepo.findAll().stream() "RT Page", PageJpaEntity::getId), pageId0);
.filter(p -> "RT Page".equals(p.getTitle())).map(PageJpaEntity::getId).toList(), 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() ArcJpaEntity importedArc = arcRepo.findAll().stream()
.filter(a -> "RT Arc".equals(a.getName()) && importedCampaignId.equals(a.getCampaignId())) .filter(a -> "RT Arc".equals(a.getName()) && importedCampaignId.equals(a.getCampaignId()))
.findFirst().orElseThrow(); .findFirst().orElseThrow();
assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)), assertTrue(importedArc.getRelatedPageIds().contains(String.valueOf(importedPageId)));
"l'arc importé doit pointer la page importée (remap), pas l'originale");
assertFalse(importedArc.getRelatedPageIds().contains(String.valueOf(pageId0))); assertFalse(importedArc.getRelatedPageIds().contains(String.valueOf(pageId0)));
// 4b. Le personnage importé a son playthroughId remis à null (hors périmètre). // 4b. Jeu : la feuille de perso importée pointe la Partie importée (playthroughId préservé, pas null).
List<CharacterJpaEntity> heroes = characterRepo.findAll().stream() CharacterJpaEntity importedHero = characterRepo.findAll().stream()
.filter(c -> "RT Hero".equals(c.getName())).toList(); .filter(c -> "RT Hero".equals(c.getName()) && importedPtId.equals(c.getPlaythroughId()))
assertEquals(2, heroes.size()); .findFirst().orElseThrow();
assertTrue(heroes.stream().anyMatch(c -> c.getPlaythroughId() == null)); assertNotNull(importedHero);
assertTrue(heroes.stream().anyMatch(c -> Long.valueOf(999L).equals(c.getPlaythroughId())));
// 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<String, byte[]> readZip(byte[] bytes) throws IOException { private static Map<String, byte[]> readZip(byte[] bytes) throws IOException {
Map<String, byte[]> out = new LinkedHashMap<>(); Map<String, byte[]> out = new LinkedHashMap<>();
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) { try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes))) {
@@ -239,7 +279,13 @@ class ContentExportImportRoundTripTest {
return out; 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 <E> List<Long> idsByName(List<E> all, java.util.function.Function<E, String> nameOf,
String name, java.util.function.Function<E, Long> 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<Long> ids, Long original) { private static Long onlyOther(List<Long> ids, Long original) {
return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow(); return ids.stream().filter(id -> !id.equals(original)).findFirst().orElseThrow();
} }

View File

@@ -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();
}
}

View File

@@ -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<Long, Long> loreByCampaign = new java.util.HashMap<>();
private Long lastLoreIdOf(Long campaignId) {
return loreByCampaign.get(campaignId);
}
}

View File

@@ -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<ExportRequest> captureExportAfter(Runnable call) {
when(exportService.buildExport(anyString(), any(ExportRequest.class)))
.thenReturn(emptyExport("complète"));
call.run();
ArgumentCaptor<ExportRequest> 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());
}
}

View File

@@ -5,7 +5,14 @@
<div class="detail-header"> <div class="detail-header">
<div class="header-texts"> <div class="header-texts">
<h1>{{ campaign.name }}</h1> <h1>{{ campaign.name }}</h1>
<p class="description">{{ campaign.description }}</p> @if (campaign.description) {
<p class="description" [class.description--clamped]="!descExpanded">{{ campaign.description }}</p>
@if (campaign.description.length > 180) {
<button type="button" class="desc-toggle" (click)="descExpanded = !descExpanded">
{{ (descExpanded ? 'campaignDetail.showLess' : 'campaignDetail.showMore') | translate }}
</button>
}
}
<div class="meta"> <div class="meta">
<span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span> <span class="badge">{{ 'campaignDetail.players' | translate:{ n: campaign.playerCount || 0 } }}</span>
<!-- Badge "Univers" : lien vers le Lore associé si présent --> <!-- Badge "Univers" : lien vers le Lore associé si présent -->
@@ -141,22 +148,26 @@
</div> </div>
@if (npcs.length > 0) { @if (npcs.length > 0) {
@for (g of npcGroups; track g.folder) { @for (g of npcGroups; track g.folder) {
@if (g.folder) { @if (g.folder || npcGroups.length > 1) {
<h4 class="persona-folder">{{ g.folder.split('/').join(' / ') }}</h4> <button type="button" class="persona-folder" [class.persona-folder--none]="!g.folder" (click)="toggleNpcFolder(g.folder)">
} @else if (npcGroups.length > 1) { <lucide-icon [img]="isNpcFolderCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
<h4 class="persona-folder persona-folder--none">{{ 'campaignTree.unclassified' | translate }}</h4> {{ g.folder ? g.folder.split('/').join(' / ') : ('campaignTree.unclassified' | translate) }}
<span class="persona-folder-count">{{ g.items.length }}</span>
</button>
} }
<div class="characters-grid"> @if (!isNpcFolderCollapsed(g.folder)) {
@for (npc of g.items; track npc.id) { <div class="characters-grid">
<div class="character-card" (click)="viewNpc(npc)"> @for (npc of g.items; track npc.id) {
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon> <div class="character-card" (click)="viewNpc(npc)">
<div class="character-info"> <lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
<span class="character-name">{{ npc.name }}</span> <div class="character-info">
<span class="character-snippet">{{ personaSnippet(npc) }}</span> <span class="character-name">{{ npc.name }}</span>
<span class="character-snippet">{{ personaSnippet(npc) }}</span>
</div>
</div> </div>
</div> }
} </div>
</div> }
} }
} }
@if (npcs.length === 0) { @if (npcs.length === 0) {
@@ -222,15 +233,34 @@
@if (playthroughs.length > 0) { @if (playthroughs.length > 0) {
<div class="playthroughs-grid"> <div class="playthroughs-grid">
@for (p of playthroughs; track p) { @for (p of playthroughs; track p) {
<div class="playthrough-card" <div class="playthrough-card" (click)="openPlaythrough(p)">
(click)="openPlaythrough(p)">
<lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon> <lucide-icon [img]="Dices" [size]="22" class="playthrough-icon"></lucide-icon>
<div class="playthrough-info"> <div class="playthrough-info">
<span class="playthrough-name">{{ p.name }}</span> @if (editingPlaythroughId === p.id) {
@if (p.description) { <input class="playthrough-rename-input"
<span class="playthrough-meta">{{ p.description }}</span> [(ngModel)]="editPlaythroughName"
(click)="$event.stopPropagation()"
(keydown.enter)="savePlaythroughRename(p)"
(keydown.escape)="cancelPlaythroughRename()" />
} @else {
<span class="playthrough-name">{{ p.name }}</span>
@if (p.description) {
<span class="playthrough-meta">{{ p.description }}</span>
}
} }
</div> </div>
@if (editingPlaythroughId === p.id) {
<button type="button" class="btn-icon" (click)="savePlaythroughRename(p); $event.stopPropagation()" [title]="'common.validate' | translate">
<lucide-icon [img]="Check" [size]="15"></lucide-icon>
</button>
<button type="button" class="btn-icon" (click)="cancelPlaythroughRename(); $event.stopPropagation()" [title]="'common.cancel' | translate">
<lucide-icon [img]="X" [size]="15"></lucide-icon>
</button>
} @else {
<button type="button" class="btn-icon playthrough-edit" (click)="startRenamePlaythrough(p, $event)" [title]="'common.rename' | translate">
<lucide-icon [img]="Pencil" [size]="15"></lucide-icon>
</button>
}
</div> </div>
} }
</div> </div>

View File

@@ -38,6 +38,18 @@
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.15rem;
min-width: 0; min-width: 0;
flex: 1;
}
.playthrough-rename-input {
width: 100%;
font-weight: 600;
font-size: 0.95rem;
padding: 0.15rem 0.4rem;
border: 1px solid var(--color-primary, #2c6cd6);
border-radius: 6px;
background: var(--color-surface, #fff);
color: inherit;
} }
.playthrough-name { .playthrough-name {
@@ -85,7 +97,29 @@
color: #6b7280; color: #6b7280;
font-size: 0.95rem; font-size: 0.95rem;
line-height: 1.6; line-height: 1.6;
margin-bottom: 0.4rem;
white-space: pre-wrap;
// Replié : on borne à 3 lignes avec ellipse ; déplié = texte intégral.
&--clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.desc-toggle {
background: none;
border: none;
padding: 0;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #8a7bc8;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
&:hover { text-decoration: underline; }
} }
.meta { .meta {
@@ -508,12 +542,25 @@
// En-tête de dossier dans la grille des PNJ (regroupement par dossier). // En-tête de dossier dans la grille des PNJ (regroupement par dossier).
.persona-folder { .persona-folder {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
color: #8a7bc8; color: #8a7bc8;
margin: 0.9rem 0 0.4rem; margin: 0.9rem 0 0.4rem;
// En-tête de dossier cliquable (repli) : reset du <button>.
background: none; border: none; padding: 0; cursor: pointer; text-align: left;
&:hover { color: #a594e0; }
.persona-folder-count {
font-weight: 400; text-transform: none; letter-spacing: 0;
color: #6b7280;
}
&--none { color: #6b7280; } &--none { color: #6b7280; }
} }

View File

@@ -6,7 +6,7 @@ import { CampaignSidebarService } from '../../../services/campaign-sidebar.servi
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText } from 'lucide-angular'; import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download, FileText, ChevronDown, ChevronRight, X } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -51,6 +51,9 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Download = Download; readonly Download = Download;
readonly FileText = FileText; readonly FileText = FileText;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly X = X;
/** Export Foundry en cours (anti double-clic). */ /** Export Foundry en cours (anti double-clic). */
exportingFoundry = false; exportingFoundry = false;
@@ -73,6 +76,16 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
npcs: Npc[] = []; npcs: Npc[] = [];
/** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */ /** PNJ groupés par dossier (dossiers alpha, ordre manuel dans chacun) — comme la sidebar. */
npcGroups: FolderGroup<Npc>[] = []; npcGroups: FolderGroup<Npc>[] = [];
/** En-tête : description repliée par défaut (clamp), dépliable via « Voir plus ». */
descExpanded = false;
/** Dossiers de PNJ repliés (section Personnages) — par nom ('' = « Sans dossier »). */
collapsedNpcFolders = new Set<string>();
/** Renommage inline d'une partie : id en cours d'édition + valeur saisie. */
editingPlaythroughId: string | null = null;
editPlaythroughName = '';
/** Sessions de jeu (passées et en cours) liées à cette campagne. */ /** Sessions de jeu (passées et en cours) liées à cette campagne. */
sessions: Session[] = []; sessions: Session[] = [];
/** /**
@@ -234,6 +247,36 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]); this.router.navigate(['/campaigns', this.campaign?.id, 'playthroughs', p.id]);
} }
/** Démarre le renommage inline d'une partie (sans déclencher l'ouverture de la carte). */
startRenamePlaythrough(p: Playthrough, event: Event): void {
event.stopPropagation();
this.editingPlaythroughId = p.id ?? null;
this.editPlaythroughName = p.name;
}
savePlaythroughRename(p: Playthrough): void {
const name = this.editPlaythroughName.trim();
if (!name || !p.id) { this.editingPlaythroughId = null; return; }
this.playthroughService.update(p.id, { ...p, name }).subscribe({
next: updated => { p.name = updated.name; this.editingPlaythroughId = null; },
error: () => { this.editingPlaythroughId = null; }
});
}
cancelPlaythroughRename(): void {
this.editingPlaythroughId = null;
}
/** Replie / déplie un dossier de PNJ (section Personnages). */
toggleNpcFolder(folder: string): void {
if (this.collapsedNpcFolders.has(folder)) this.collapsedNpcFolders.delete(folder);
else this.collapsedNpcFolders.add(folder);
}
isNpcFolderCollapsed(folder: string): boolean {
return this.collapsedNpcFolders.has(folder);
}
/** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */ /** Crée une nouvelle Partie avec un nom par défaut, puis y navigue. */
newPlaythroughInFlight = false; newPlaythroughInFlight = false;
createPlaythrough(): void { createPlaythrough(): void {

View File

@@ -26,30 +26,37 @@
} }
@for (g of groups; track g.folder; let gi = $index) { @for (g of groups; track g.folder; let gi = $index) {
@if (g.folder) { @if (g.folder) {
<h2 class="sbl-folder"> <button type="button" class="sbl-folder" (click)="toggleFolder(g.folder)">
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
<lucide-icon [img]="Folder" [size]="14"></lucide-icon> <lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder.split('/').join(' / ') }} {{ g.folder.split('/').join(' / ') }}
<span class="sbl-folder-count">{{ g.items.length }}</span> <span class="sbl-folder-count">{{ g.items.length }}</span>
</h2> </button>
} @else if (groups.length > 1) { } @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">{{ 'enemyList.unclassified' | translate }}</h2> <button type="button" class="sbl-folder sbl-folder--none" (click)="toggleFolder(g.folder)">
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
{{ 'enemyList.unclassified' | translate }}
<span class="sbl-folder-count">{{ g.items.length }}</span>
</button>
}
@if (!isCollapsed(g.folder)) {
<div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
(cdkDropListDropped)="drop(g, $event)">
@for (e of g.items; track e.id) {
<button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ e.name }}</span>
@if (e.level) {
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
}
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
<div class="sbl-group" cdkDropList [id]="'enemy-group-' + gi"
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
(cdkDropListDropped)="drop(g, $event)">
@for (e of g.items; track e.id) {
<button class="sbl-item" cdkDrag [cdkDragData]="e" (click)="open(e)">
<lucide-icon [img]="Skull" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ e.name }}</span>
@if (e.level) {
<span class="sbl-item-level">{{ 'enemyList.levelShort' | translate:{ level: e.level } }}</span>
}
<span class="sbl-del" (click)="remove(e, $event)" [title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
</div> </div>
</div> </div>

View File

@@ -29,9 +29,15 @@
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. // En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
.sbl-folder { .sbl-folder {
display: flex; align-items: center; gap: 0.4rem; display: flex; align-items: center; gap: 0.4rem;
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em; text-transform: uppercase; letter-spacing: 0.06em;
color: #a5b4fc; color: #a5b4fc;
// En-tête cliquable (repli) : on neutralise le style natif du <button>.
background: none; border: none; padding: 0; cursor: pointer; font-family: inherit;
&:hover { color: #c7d2fe; }
.sbl-folder-chevron { opacity: 0.65; flex-shrink: 0; }
.sbl-folder-count { .sbl-folder-count {
font-weight: 400; color: var(--color-text-muted, #9aa0aa); font-weight: 400; color: var(--color-text-muted, #9aa0aa);

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { DestroyRef } from '@angular/core'; import { DestroyRef } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../../services/data-sync.service'; import { DataSyncService } from '../../../services/data-sync.service';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Skull, Folder, Upload, ChevronDown, ChevronRight } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { EnemyService } from '../../../services/enemy.service'; import { EnemyService } from '../../../services/enemy.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
@@ -30,6 +30,11 @@ export class EnemyListComponent implements OnInit {
readonly Skull = Skull; readonly Skull = Skull;
readonly Folder = Folder; readonly Folder = Folder;
readonly Upload = Upload; readonly Upload = Upload;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
/** Dossiers repliés (par nom ; '' = « Sans dossier ») — persiste entre rechargements. */
collapsedFolders = new Set<string>();
/** Import de monstres Foundry en cours (anti double-clic). */ /** Import de monstres Foundry en cours (anti double-clic). */
importing = false; importing = false;
@@ -74,6 +79,16 @@ export class EnemyListComponent implements OnInit {
this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']); this.router.navigate(['/campaigns', this.campaignId, 'enemies', 'create']);
} }
/** Replie / déplie un dossier. */
toggleFolder(folder: string): void {
if (this.collapsedFolders.has(folder)) this.collapsedFolders.delete(folder);
else this.collapsedFolders.add(folder);
}
isCollapsed(folder: string): boolean {
return this.collapsedFolders.has(folder);
}
// --- Glisser-déposer (réordonner + déplacer entre dossiers) -------------- // --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
/** Ids des zones de dépôt (une par groupe/dossier) pour les connecter entre elles. */ /** Ids des zones de dépôt (une par groupe/dossier) pour les connecter entre elles. */

View File

@@ -22,27 +22,34 @@
} }
@for (g of groups; track g.folder; let gi = $index) { @for (g of groups; track g.folder; let gi = $index) {
@if (g.folder) { @if (g.folder) {
<h2 class="sbl-folder"> <button type="button" class="sbl-folder" (click)="toggleFolder(g.folder)">
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
<lucide-icon [img]="Folder" [size]="14"></lucide-icon> <lucide-icon [img]="Folder" [size]="14"></lucide-icon>
{{ g.folder }} {{ g.folder }}
<span class="sbl-folder-count">{{ g.items.length }}</span> <span class="sbl-folder-count">{{ g.items.length }}</span>
</h2> </button>
} @else if (groups.length > 1) { } @else if (groups.length > 1) {
<h2 class="sbl-folder sbl-folder--none">{{ 'npcList.unclassified' | translate }}</h2> <button type="button" class="sbl-folder sbl-folder--none" (click)="toggleFolder(g.folder)">
<lucide-icon class="sbl-folder-chevron" [img]="isCollapsed(g.folder) ? ChevronRight : ChevronDown" [size]="14"></lucide-icon>
{{ 'npcList.unclassified' | translate }}
<span class="sbl-folder-count">{{ g.items.length }}</span>
</button>
}
@if (!isCollapsed(g.folder)) {
<div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
(cdkDropListDropped)="drop(g, $event)">
@for (n of g.items; track n.id) {
<button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ n.name }}</span>
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
<div class="sbl-group" cdkDropList [id]="'npc-group-' + gi"
[cdkDropListData]="g.items" [cdkDropListConnectedTo]="groupListIds"
(cdkDropListDropped)="drop(g, $event)">
@for (n of g.items; track n.id) {
<button class="sbl-item" cdkDrag [cdkDragData]="n" (click)="open(n)">
<lucide-icon [img]="Drama" [size]="16"></lucide-icon>
<span class="sbl-item-name">{{ n.name }}</span>
<span class="sbl-del" (click)="remove(n, $event)" [title]="'common.delete' | translate">
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
</span>
</button>
}
</div>
} }
</div> </div>
</div> </div>

View File

@@ -26,9 +26,15 @@
// En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes. // En-tête de dossier (Démons, Humanoïdes…) — sépare visuellement les groupes.
.sbl-folder { .sbl-folder {
display: flex; align-items: center; gap: 0.4rem; display: flex; align-items: center; gap: 0.4rem;
margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600; width: 100%; margin: 0.9rem 0 0.15rem; font-size: 0.78rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em; text-transform: uppercase; letter-spacing: 0.06em;
color: #a5b4fc; color: #a5b4fc;
// En-tête cliquable (repli) : on neutralise le style natif du <button>.
background: none; border: none; padding: 0; cursor: pointer; font-family: inherit;
&:hover { color: #c7d2fe; }
.sbl-folder-chevron { opacity: 0.65; flex-shrink: 0; }
.sbl-folder-count { .sbl-folder-count {
font-weight: 400; color: var(--color-text-muted, #9aa0aa); font-weight: 400; color: var(--color-text-muted, #9aa0aa);

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { DestroyRef } from '@angular/core'; import { DestroyRef } from '@angular/core';
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { DataSyncService } from '../../../services/data-sync.service'; import { DataSyncService } from '../../../services/data-sync.service';
import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, Plus, Trash2, Drama, Folder, ChevronDown, ChevronRight } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service'; import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
@@ -29,6 +29,11 @@ export class NpcListComponent implements OnInit {
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Drama = Drama; readonly Drama = Drama;
readonly Folder = Folder; readonly Folder = Folder;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
/** Dossiers repliés (par nom ; '' = « Sans dossier ») — persiste entre rechargements. */
collapsedFolders = new Set<string>();
campaignId = ''; campaignId = '';
/** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */ /** Groupes triés par nom de dossier ; les non-classés en dernier (folder = ''). */
@@ -70,6 +75,16 @@ export class NpcListComponent implements OnInit {
this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']); this.router.navigate(['/campaigns', this.campaignId, 'npcs', 'create']);
} }
/** Replie / déplie un dossier. */
toggleFolder(folder: string): void {
if (this.collapsedFolders.has(folder)) this.collapsedFolders.delete(folder);
else this.collapsedFolders.add(folder);
}
isCollapsed(folder: string): boolean {
return this.collapsedFolders.has(folder);
}
// --- Glisser-déposer (réordonner + déplacer entre dossiers) -------------- // --- Glisser-déposer (réordonner + déplacer entre dossiers) --------------
get groupListIds(): string[] { get groupListIds(): string[] {

View File

@@ -1,5 +1,5 @@
import { Injectable, NgZone } from '@angular/core'; import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
/** /**
@@ -94,10 +94,22 @@ export class SettingsService {
// --- Export / Import des donnees (sauvegarde & transfert d'instance) ------- // --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
/** Telecharge l'export complet du contenu (zip : data.json + images). */ /**
exportData(): Observable<Blob> { * Télécharge un export (zip : data.json + images). Sans options : sauvegarde complète.
* Avec `campaignId` : export ciblé d'une campagne, les options réglant l'univers (lore),
* l'espace de jeu et les binaires d'images embarqués.
*/
exportData(opts?: { campaignId?: string; includeLore?: boolean; includePlay?: boolean; includeImages?: boolean }): Observable<Blob> {
let params = new HttpParams();
if (opts?.campaignId) {
params = params
.set('campaignId', opts.campaignId)
.set('includeLore', String(opts.includeLore ?? true))
.set('includePlay', String(opts.includePlay ?? true))
.set('includeImages', String(opts.includeImages ?? true));
}
return this.http.get('/api/admin/data/export', return this.http.get('/api/admin/data/export',
{ withCredentials: true, responseType: 'blob' }); { params, withCredentials: true, responseType: 'blob' });
} }
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */ /** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */

View File

@@ -93,35 +93,54 @@
<div> <div>
@if (characters.length > 0) { @if (characters.length > 0) {
<div class="ref-group"> <div class="ref-group">
<h4> <button type="button" class="ref-group-head" (click)="pjCollapsed = !pjCollapsed">
<lucide-icon class="ref-group-chevron" [img]="pjCollapsed ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
<lucide-icon [img]="User" [size]="13"></lucide-icon> <lucide-icon [img]="User" [size]="13"></lucide-icon>
{{ 'sessionReferencePanel.playerCharacters' | translate }} {{ 'sessionReferencePanel.playerCharacters' | translate }}
</h4> <span class="ref-group-count">{{ characters.length }}</span>
@for (c of characters; track c) { </button>
<button @if (!pjCollapsed) {
type="button" @for (c of characters; track c) {
class="ref-item" <button
(click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])"> type="button"
<span class="ref-item-name">{{ c.name }}</span> class="ref-item"
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> (click)="openInNewTab(['campaigns', campaignId, 'playthroughs', playthroughId!, 'characters', c.id!])">
</button> <span class="ref-item-name">{{ c.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
}
} }
</div> </div>
} }
@if (npcs.length > 0) { @if (npcs.length > 0) {
<div class="ref-group"> <div class="ref-group">
<h4> <button type="button" class="ref-group-head" (click)="pnjCollapsed = !pnjCollapsed">
<lucide-icon class="ref-group-chevron" [img]="pnjCollapsed ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
<lucide-icon [img]="Drama" [size]="13"></lucide-icon> <lucide-icon [img]="Drama" [size]="13"></lucide-icon>
{{ 'sessionReferencePanel.nonPlayerCharacters' | translate }} {{ 'sessionReferencePanel.nonPlayerCharacters' | translate }}
</h4> <span class="ref-group-count">{{ npcs.length }}</span>
@for (n of npcs; track n) { </button>
<button @if (!pnjCollapsed) {
type="button" @for (fg of npcGroups; track fg.folder) {
class="ref-item" <div class="ref-subgroup">
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])"> <button type="button" class="ref-subgroup-head" (click)="toggleNpcFolder(fg.folder)">
<span class="ref-item-name">{{ n.name }}</span> <lucide-icon class="ref-group-chevron" [img]="isNpcFolderCollapsed(fg.folder) ? ChevronRight : ChevronDown" [size]="11"></lucide-icon>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> {{ fg.folder ? fg.folder.split('/').join(' / ') : ('campaignTree.unclassified' | translate) }}
</button> <span class="ref-group-count">{{ fg.items.length }}</span>
</button>
@if (!isNpcFolderCollapsed(fg.folder)) {
@for (n of fg.items; track n) {
<button
type="button"
class="ref-item ref-item--nested"
(click)="openInNewTab(['campaigns', campaignId, 'npcs', n.id!])">
<span class="ref-item-name">{{ n.name }}</span>
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
</button>
}
}
</div>
}
} }
</div> </div>
} }
@@ -147,25 +166,28 @@
{{ 'sessionReferencePanel.emptyScenes' | translate }} {{ 'sessionReferencePanel.emptyScenes' | translate }}
</p> </p>
} }
@for (arc of treeData.arcs; track arc) { @for (arc of sortedArcs; track arc) {
<div class="ref-group"> <div class="ref-group">
<h4> <button type="button" class="ref-group-head" (click)="toggleArc(arc.id!)">
<lucide-icon class="ref-group-chevron" [img]="isArcCollapsed(arc.id!) ? ChevronRight : ChevronDown" [size]="12"></lucide-icon>
<lucide-icon [img]="Swords" [size]="13"></lucide-icon> <lucide-icon [img]="Swords" [size]="13"></lucide-icon>
{{ arc.name }} {{ arc.name }}
</h4> </button>
@for (chapter of chaptersOf(arc); track chapter) { @if (!isArcCollapsed(arc.id!)) {
<div class="ref-subgroup"> @for (chapter of chaptersOf(arc); track chapter) {
<span class="ref-subgroup-title">{{ chapter.name }}</span> <div class="ref-subgroup">
@for (scene of scenesOf(chapter); track scene) { <span class="ref-subgroup-title">{{ chapter.name }}</span>
<button @for (scene of scenesOf(chapter); track scene) {
type="button" <button
class="ref-item ref-item--nested" type="button"
(click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])"> class="ref-item ref-item--nested"
<span class="ref-item-name">{{ scene.name }}</span> (click)="openInNewTab(['campaigns', campaignId, 'arcs', arc.id!, 'chapters', chapter.id!, 'scenes', scene.id!])">
<lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon> <span class="ref-item-name">{{ scene.name }}</span>
</button> <lucide-icon [img]="ExternalLink" [size]="12" class="ref-item-icon"></lucide-icon>
} </button>
</div> }
</div>
}
} }
</div> </div>
} }

View File

@@ -12,21 +12,24 @@
// ─────────────── Tabs ─────────────── // ─────────────── Tabs ───────────────
.ref-tabs { .ref-tabs {
display: flex; display: flex;
flex-wrap: wrap; // 6 onglets : on passe sur 2 rangs plutôt que de rogner « Scènes ».
background: #111827; background: #111827;
border-bottom: 1px solid #1f2937; border-bottom: 1px solid #1f2937;
} }
.ref-tab { .ref-tab {
flex: 1; flex: 1 1 33.333%;
min-width: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.35rem; gap: 0.3rem;
background: transparent; background: transparent;
color: #9ca3af; color: #9ca3af;
border: none; border: none;
padding: 0.7rem 0.5rem; padding: 0.55rem 0.4rem;
font-size: 0.8rem; font-size: 0.78rem;
white-space: nowrap;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@@ -88,6 +91,37 @@
} }
} }
// En-tête de section repliable (PJ / PNJ / arc), stylé comme un <h4> mais cliquable.
.ref-group-head {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
margin: 0 0 0.25rem 0;
padding: 0;
background: none;
border: none;
cursor: pointer;
color: #9ca3af;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
text-align: left;
&:hover { color: #d1d5db; }
.ref-group-chevron { opacity: 0.6; flex-shrink: 0; }
.ref-group-count {
margin-left: auto;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: #6b7280;
}
}
.ref-subgroup { .ref-subgroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -100,6 +134,27 @@
font-size: 0.72rem; font-size: 0.72rem;
margin-bottom: 0.15rem; margin-bottom: 0.15rem;
} }
// En-tête de dossier de PNJ (repliable) dans l'onglet Personnages.
.ref-subgroup-head {
display: inline-flex;
align-items: center;
gap: 0.35rem;
width: 100%;
margin-bottom: 0.15rem;
padding: 0;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: #8a93a3;
font-size: 0.72rem;
&:hover { color: #c0c7d1; }
.ref-group-chevron { opacity: 0.55; flex-shrink: 0; }
.ref-group-count { margin-left: auto; color: #6b7280; }
}
} }
.ref-item { .ref-item {

View File

@@ -1,7 +1,7 @@
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package } from 'lucide-angular'; import { LucideAngularModule, User, Drama, Swords, Dices, ExternalLink, Sparkles, Table2, Package, ChevronDown, ChevronRight } from 'lucide-angular';
import { catchError, of } from 'rxjs'; import { catchError, of } from 'rxjs';
import { CampaignService } from '../../services/campaign.service'; import { CampaignService } from '../../services/campaign.service';
import { CharacterService } from '../../services/character.service'; import { CharacterService } from '../../services/character.service';
@@ -11,6 +11,7 @@ import { Character } from '../../services/character.model';
import { Npc } from '../../services/npc.model'; import { Npc } from '../../services/npc.model';
import { Arc, Chapter, Scene } from '../../services/campaign.model'; import { Arc, Chapter, Scene } from '../../services/campaign.model';
import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper'; import { loadCampaignTreeData, CampaignTreeData } from '../../campaigns/campaign-tree.helper';
import { byOrder, groupByFolder, FolderGroup } from '../../shared/folder-grouping.util';
import { import {
SessionDicePanelComponent, DiceRollResult SessionDicePanelComponent, DiceRollResult
} from '../session-dice-panel/session-dice-panel.component'; } from '../session-dice-panel/session-dice-panel.component';
@@ -45,6 +46,8 @@ export class SessionReferencePanelComponent implements OnChanges {
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Table2 = Table2; readonly Table2 = Table2;
readonly Package = Package; readonly Package = Package;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
@Input() campaignId!: string; @Input() campaignId!: string;
/** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */ /** Partie active — nécessaire pour charger les PJ (refonte Playthrough). */
@@ -63,6 +66,14 @@ export class SessionReferencePanelComponent implements OnChanges {
npcs: Npc[] = []; npcs: Npc[] = [];
treeData: CampaignTreeData | null = null; treeData: CampaignTreeData | null = null;
/** Sections PJ / PNJ repliables (onglet Personnages). */
pjCollapsed = false;
pnjCollapsed = false;
/** Dossiers de PNJ repliés (onglet Personnages) — par nom ('' = « Sans dossier »). */
collapsedNpcFolders = new Set<string>();
/** Arcs repliés dans l'onglet Scènes — repliés par défaut pour une vue d'ensemble. */
collapsedArcs = new Set<string>();
loadingChars = false; loadingChars = false;
loadingTree = false; loadingTree = false;
/** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */ /** True dès qu'un tab "lourd" a été chargé pour éviter de rappeler l'API en boucle. */
@@ -120,11 +131,42 @@ export class SessionReferencePanelComponent implements OnChanges {
catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData)) catchError(() => of({ arcs: [], chaptersByArc: {}, scenesByChapter: {}, npcs: [], randomTables: [], enemies: [] } as CampaignTreeData))
).subscribe(data => { ).subscribe(data => {
this.treeData = data; this.treeData = data;
// Arcs repliés par défaut : on présente une vue d'ensemble compacte, le MJ
// déplie l'arc qui l'intéresse.
this.collapsedArcs = new Set(data.arcs.map(a => a.id!).filter(Boolean) as string[]);
this.loadingTree = false; this.loadingTree = false;
this.treeLoaded = true; this.treeLoaded = true;
}); });
} }
/** Arcs triés par ordre manuel (cohérent avec l'arbre et les cartes). */
get sortedArcs(): Arc[] {
return [...(this.treeData?.arcs ?? [])].sort(byOrder);
}
toggleArc(id: string): void {
if (this.collapsedArcs.has(id)) this.collapsedArcs.delete(id);
else this.collapsedArcs.add(id);
}
isArcCollapsed(id: string): boolean {
return this.collapsedArcs.has(id);
}
/** PNJ groupés par dossier (même classement que les vues cartes/sidebar). */
get npcGroups(): FolderGroup<Npc>[] {
return groupByFolder(this.npcs);
}
toggleNpcFolder(folder: string): void {
if (this.collapsedNpcFolders.has(folder)) this.collapsedNpcFolders.delete(folder);
else this.collapsedNpcFolders.add(folder);
}
isNpcFolderCollapsed(folder: string): boolean {
return this.collapsedNpcFolders.has(folder);
}
/** /**
* Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session. * Ouvre une fiche dans un nouvel onglet pour préserver l'écran de session.
* Le MJ peut consulter sans perdre son journal ni son historique de dés. * Le MJ peut consulter sans perdre son journal ni son historique de dés.
@@ -134,12 +176,12 @@ export class SessionReferencePanelComponent implements OnChanges {
window.open('/' + url, '_blank', 'noopener'); window.open('/' + url, '_blank', 'noopener');
} }
/** Helpers de typage pour le template (Angular n'infère pas bien sans). */ /** Chapitres/scènes triés par ordre manuel (l'arbre brut n'est pas ordonné). */
chaptersOf(arc: Arc): Chapter[] { chaptersOf(arc: Arc): Chapter[] {
return this.treeData?.chaptersByArc[arc.id!] ?? []; return [...(this.treeData?.chaptersByArc[arc.id!] ?? [])].sort(byOrder);
} }
scenesOf(chapter: Chapter): Scene[] { scenesOf(chapter: Chapter): Scene[] {
return this.treeData?.scenesByChapter[chapter.id!] ?? []; return [...(this.treeData?.scenesByChapter[chapter.id!] ?? [])].sort(byOrder);
} }
onDiceRolled(result: DiceRollResult): void { onDiceRolled(result: DiceRollResult): void {

View File

@@ -376,6 +376,27 @@
<section class="card"> <section class="card">
<h2>{{ 'settings.data.title' | translate }}</h2> <h2>{{ 'settings.data.title' | translate }}</h2>
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p> <p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
<div class="export-scope">
<label class="export-scope-row">
<span>{{ 'settings.data.scopeLabel' | translate }}</span>
<select [(ngModel)]="exportScope" [disabled]="exporting || importing">
<option value="full">{{ 'settings.data.scopeFull' | translate }}</option>
<optgroup [label]="'settings.data.scopeCampaignGroup' | translate">
@for (c of campaigns; track c.id) {
<option [value]="c.id">{{ c.name }}</option>
}
</optgroup>
</select>
</label>
@if (exportScope !== 'full') {
<div class="export-options">
<span class="export-options-label">{{ 'settings.data.optionsLabel' | translate }}</span>
<label><input type="checkbox" [(ngModel)]="exportIncludeLore"> {{ 'settings.data.optLore' | translate }}</label>
<label><input type="checkbox" [(ngModel)]="exportIncludePlay"> {{ 'settings.data.optPlay' | translate }}</label>
<label><input type="checkbox" [(ngModel)]="exportIncludeImages"> {{ 'settings.data.optImages' | translate }}</label>
</div>
}
</div>
<div class="actions" appFileDrop [dropDisabled]="exporting || importing" (filesDropped)="onBackupDropped($event)"> <div class="actions" appFileDrop [dropDisabled]="exporting || importing" (filesDropped)="onBackupDropped($event)">
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing"> <button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span> <span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>

View File

@@ -286,6 +286,53 @@
&:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); } &:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); }
} }
.export-scope {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 4px;
}
.export-scope-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
font-weight: 500;
select {
flex: 1;
max-width: 340px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: inherit;
font-size: 0.9rem;
}
}
.export-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 18px;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.75);
.export-options-label {
font-weight: 600;
color: rgba(255, 255, 255, 0.5);
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
}
.actions { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -6,6 +6,8 @@ import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Pl
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service';
import { LayoutService } from '../services/layout.service'; import { LayoutService } from '../services/layout.service';
import { CampaignService } from '../services/campaign.service';
import { Campaign } from '../services/campaign.model';
import { UpdatesSectionComponent } from './updates-section/updates-section.component'; import { UpdatesSectionComponent } from './updates-section/updates-section.component';
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component'; import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component'; import { LanguageSwitcherComponent } from '../shared/language-switcher/language-switcher.component';
@@ -97,7 +99,8 @@ export class SettingsComponent implements OnInit {
private settingsService: SettingsService, private settingsService: SettingsService,
private router: Router, private router: Router,
private layoutService: LayoutService, private layoutService: LayoutService,
private translate: TranslateService private translate: TranslateService,
private campaignService: CampaignService
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
@@ -105,6 +108,11 @@ export class SettingsComponent implements OnInit {
// section precedente (cf. fix CampaignsComponent / LoreComponent). // section precedente (cf. fix CampaignsComponent / LoreComponent).
this.layoutService.hide(); this.layoutService.hide();
this.loadSettings(); this.loadSettings();
// Campagnes proposées pour un export ciblé (silencieux en cas d'échec).
this.campaignService.getAllCampaigns().subscribe({
next: (list) => this.campaigns = list,
error: () => this.campaigns = []
});
} }
loadSettings(): void { loadSettings(): void {
@@ -340,17 +348,33 @@ export class SettingsComponent implements OnInit {
dataMessage = ''; dataMessage = '';
dataError = ''; dataError = '';
/** Telecharge l'export complet (zip) via un lien temporaire. */ /** Campagnes proposées pour un export ciblé. */
campaigns: Campaign[] = [];
/** Périmètre d'export : 'full' (sauvegarde complète) ou l'id d'une campagne. */
exportScope = 'full';
/** Options de l'export ciblé (ignorées en sauvegarde complète). */
exportIncludeLore = true;
exportIncludePlay = true;
exportIncludeImages = true;
/** Télécharge l'export (complet ou ciblé selon le périmètre choisi) via un lien temporaire. */
exportData(): void { exportData(): void {
this.exporting = true; this.exporting = true;
this.dataMessage = ''; this.dataMessage = '';
this.dataError = ''; this.dataError = '';
this.settingsService.exportData().subscribe({ const targeted = this.exportScope !== 'full';
const opts = targeted ? {
campaignId: this.exportScope,
includeLore: this.exportIncludeLore,
includePlay: this.exportIncludePlay,
includeImages: this.exportIncludeImages
} : undefined;
this.settingsService.exportData(opts).subscribe({
next: (blob) => { next: (blob) => {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = 'loremind-export.zip'; a.download = targeted ? 'loremind-campagne.zip' : 'loremind-export.zip';
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.exporting = false; this.exporting = false;

View File

@@ -53,6 +53,13 @@
"data": { "data": {
"title": "Data backup", "title": "Data backup",
"hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.", "hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.",
"scopeLabel": "Scope",
"scopeFull": "Full backup (everything)",
"scopeCampaignGroup": "Campaign",
"optionsLabel": "Include",
"optLore": "Lore (world)",
"optPlay": "Play space (playthroughs, sessions, characters)",
"optImages": "Images",
"exportBtn": "Export data", "exportBtn": "Export data",
"exporting": "Exporting…", "exporting": "Exporting…",
"importBtn": "Import a file", "importBtn": "Import a file",
@@ -568,6 +575,8 @@
}, },
"campaignDetail": { "campaignDetail": {
"players": "{{n}} players", "players": "{{n}} players",
"showMore": "Show more",
"showLess": "Show less",
"openLinkedLore": "Open the linked universe", "openLinkedLore": "Open the linked universe",
"loreMissingTitle": "The linked universe cannot be found", "loreMissingTitle": "The linked universe cannot be found",
"loreMissing": "Universe not found", "loreMissing": "Universe not found",

View File

@@ -53,6 +53,13 @@
"data": { "data": {
"title": "Sauvegarde des données", "title": "Sauvegarde des données",
"hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.", "hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.",
"scopeLabel": "Périmètre",
"scopeFull": "Sauvegarde complète (tout)",
"scopeCampaignGroup": "Campagne",
"optionsLabel": "À inclure",
"optLore": "Univers (lore)",
"optPlay": "Espace de jeu (parties, sessions, persos)",
"optImages": "Images",
"exportBtn": "Exporter les données", "exportBtn": "Exporter les données",
"exporting": "Export en cours…", "exporting": "Export en cours…",
"importBtn": "Importer un fichier", "importBtn": "Importer un fichier",
@@ -568,6 +575,8 @@
}, },
"campaignDetail": { "campaignDetail": {
"players": "{{n}} joueurs", "players": "{{n}} joueurs",
"showMore": "Voir plus",
"showLess": "Voir moins",
"openLinkedLore": "Ouvrir l'univers associé", "openLinkedLore": "Ouvrir l'univers associé",
"loreMissingTitle": "L'univers associé est introuvable", "loreMissingTitle": "L'univers associé est introuvable",
"loreMissing": "Univers introuvable", "loreMissing": "Univers introuvable",