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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user