From beafbc2fa9f2c5ad8a32e3767053272b2021a3ea Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Sat, 4 Jul 2026 11:15:16 +0200 Subject: [PATCH] =?UTF-8?q?Livret=20PDF=20v2,=20graphe=20des=20liens=20c?= =?UTF-8?q?=C3=B4t=C3=A9=20campagne,=20et=20refactor=20de=20l'import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Livret PDF (export campagne) : - refonte lisibilité façon livre de JdR : sommaire paginé, libellés de champs en tête de ligne, encadrés « À lire aux joueurs » / secrets MJ / combat, ligne de contexte des scènes, en-tête courant, tables zébrées - reflète la fusion quête/conteneur : arc SYSTEM masqué, quêtes de hub fusionnées dans leur chapitre, partie « Quêtes » sans doublon - contenu complété : sorties de scènes, ennemis liés au bestiaire, pièces explorables, partie « Tables aléatoires » - polices DejaVu embarquées (couverture Unicode, licence incluse) et harness visuel PdfExportPreviewTest (Mockito pur, tourne sans Postgres) Graphe des liens : - déplacé du Lore vers la campagne (les PNJ sont des entités de campagne) ; endpoint /api/npcs/lore/{id} supprimé - layout retravaillé (anti-chevauchement des libellés, éventail des feuilles autour des hubs), scènes et quêtes ajoutées, libellés sur 2 lignes, filtres de légende mémorisés, focus au survol - positions drag & drop persistées sur la campagne (migration V23, PUT /api/campaigns/{id}/graph-positions, bouton « Disposition auto ») Divers : - ImportService découpé en classes dédiées (parser d'archive, remapper d'ids, inserters campagne/lore/état de jeu, conversion legacy des quêtes) - fuites d'abonnements corrigées (takeUntilDestroyed sur paramMap) sur ~15 écrans - garde-fou i18n fragments/consolidés branché sur npm run build (check-i18n.mjs), budget SCSS anyComponentStyle relevé à 12 ko --- .../campaigncontext/CampaignService.java | 16 + .../campaigncontext/NpcService.java | 21 +- .../domain/campaigncontext/Campaign.java | 6 + .../persistence/entity/CampaignJpaEntity.java | 7 + .../postgres/PostgresCampaignRepository.java | 2 + .../transfer/CampaignContentInserter.java | 294 +++++ .../transfer/ImportArchiveParser.java | 77 ++ .../infrastructure/transfer/ImportIdMaps.java | 42 + .../transfer/ImportReferenceRemapper.java | 157 +++ .../transfer/ImportService.java | 794 +------------ .../transfer/LegacyQuestConverter.java | 84 ++ .../transfer/LoreContentInserter.java | 122 ++ .../transfer/PlayStateInserter.java | 217 ++++ .../transfer/pdf/PdfExportService.java | 1023 ++++++++++++++--- .../web/controller/CampaignController.java | 13 + .../web/controller/NpcController.java | 9 - .../web/dto/campaigncontext/CampaignDTO.java | 2 + .../web/mapper/CampaignMapper.java | 1 + .../V23__campaign_graph_positions.sql | 4 + .../main/resources/fonts/DejaVuSans-Bold.ttf | Bin 0 -> 705684 bytes .../fonts/DejaVuSans-BoldOblique.ttf | Bin 0 -> 643292 bytes .../resources/fonts/DejaVuSans-Oblique.ttf | Bin 0 -> 635416 bytes core/src/main/resources/fonts/DejaVuSans.ttf | Bin 0 -> 757076 bytes .../main/resources/fonts/DejaVuSerif-Bold.ttf | Bin 0 -> 356088 bytes .../fonts/DejaVuSerif-BoldItalic.ttf | Bin 0 -> 347460 bytes .../resources/fonts/DejaVuSerif-Italic.ttf | Bin 0 -> 345996 bytes core/src/main/resources/fonts/DejaVuSerif.ttf | Bin 0 -> 380132 bytes .../main/resources/fonts/LICENSE-DejaVu.txt | 187 +++ .../campaigncontext/CampaignServiceTest.java | 41 + .../transfer/pdf/PdfExportPreviewTest.java | 405 +++++++ .../web/controller/NpcControllerTest.java | 7 - web/angular.json | 2 +- web/package.json | 3 +- web/scripts/check-i18n.mjs | 127 ++ web/src/app/app.routes.ts | 2 +- .../arc/arc-edit/arc-edit.component.ts | 8 +- .../campaign-graph.component.html | 101 ++ .../campaign-graph.component.scss} | 75 +- .../campaign-graph.component.ts | 688 +++++++++++ .../campaign-create.component.ts | 8 +- .../campaign-detail.component.html | 5 + .../campaign-detail.component.ts | 10 +- .../chapter-edit/chapter-edit.component.ts | 8 +- .../chapter-graph/chapter-graph.component.ts | 10 +- .../chapter-view/chapter-view.component.ts | 8 +- .../playthrough-detail.component.ts | 8 +- .../playthrough-flags-page.component.ts | 8 +- .../quest/quest-edit/quest-edit.component.ts | 8 +- .../quest/quest-view/quest-view.component.ts | 8 +- .../random-table-view.component.ts | 8 +- .../scene/scene-edit/scene-edit.component.ts | 11 +- .../scene/scene-view/scene-view.component.ts | 8 +- .../lore-detail/lore-detail.component.html | 5 - .../lore/lore-detail/lore-detail.component.ts | 18 +- .../lore/lore-graph/lore-graph.component.html | 69 -- .../lore/lore-graph/lore-graph.component.ts | 352 ------ .../lore-node-edit.component.ts | 8 +- .../lore/page-create/page-create.component.ts | 8 +- .../app/lore/page-edit/page-edit.component.ts | 8 +- .../app/lore/page-view/page-view.component.ts | 8 +- web/src/app/services/campaign.model.ts | 2 + web/src/app/services/campaign.service.ts | 5 + web/src/app/services/npc.service.ts | 5 - web/src/assets/i18n/en.json | 19 +- web/src/assets/i18n/fr.json | 19 +- .../i18n/fragments/campaigns-top.en.json | 17 + .../i18n/fragments/campaigns-top.fr.json | 17 + .../assets/i18n/fragments/lore-core.en.json | 12 - .../assets/i18n/fragments/lore-core.fr.json | 12 - 69 files changed, 3720 insertions(+), 1509 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/CampaignContentInserter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ImportArchiveParser.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ImportIdMaps.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ImportReferenceRemapper.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/LegacyQuestConverter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/LoreContentInserter.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/PlayStateInserter.java create mode 100644 core/src/main/resources/db/migration/V23__campaign_graph_positions.sql create mode 100644 core/src/main/resources/fonts/DejaVuSans-Bold.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSans-BoldOblique.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSans-Oblique.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSans.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSerif-Bold.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSerif-BoldItalic.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSerif-Italic.ttf create mode 100644 core/src/main/resources/fonts/DejaVuSerif.ttf create mode 100644 core/src/main/resources/fonts/LICENSE-DejaVu.txt create mode 100644 core/src/test/java/com/loremind/infrastructure/transfer/pdf/PdfExportPreviewTest.java create mode 100644 web/scripts/check-i18n.mjs create mode 100644 web/src/app/campaigns/campaign-graph/campaign-graph.component.html rename web/src/app/{lore/lore-graph/lore-graph.component.scss => campaigns/campaign-graph/campaign-graph.component.scss} (62%) create mode 100644 web/src/app/campaigns/campaign-graph/campaign-graph.component.ts delete mode 100644 web/src/app/lore/lore-graph/lore-graph.component.html delete mode 100644 web/src/app/lore/lore-graph/lore-graph.component.ts diff --git a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java index ccb57de..860cedf 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/CampaignService.java @@ -92,6 +92,22 @@ public class CampaignService { return campaignRepository.save(campaign); } + /** + * Sauvegarde les positions du graphe de campagne (JSON opaque, état de + * présentation possédé par le front). Null/vide = retour à la disposition + * automatique. Taille bornée : ce champ ne doit pas devenir un fourre-tout. + */ + public void updateGraphPositions(String id, String positionsJson) { + Campaign campaign = campaignRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Campaign non trouvé avec l'ID: " + id)); + String value = (positionsJson == null || positionsJson.isBlank()) ? null : positionsJson; + if (value != null && value.length() > 200_000) { + throw new IllegalArgumentException("Positions de graphe trop volumineuses"); + } + campaign.setGraphPositions(value); + campaignRepository.save(campaign); + } + private String normalizeId(String id) { return (id == null || id.isBlank()) ? null : id; } diff --git a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java index 140b5ee..4c1f492 100644 --- a/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java +++ b/core/src/main/java/com/loremind/application/campaigncontext/NpcService.java @@ -1,9 +1,7 @@ package com.loremind.application.campaigncontext; -import com.loremind.domain.campaigncontext.Campaign; import com.loremind.domain.campaigncontext.Npc; import com.loremind.domain.shared.ReorderSupport; -import com.loremind.domain.campaigncontext.ports.CampaignRepository; import com.loremind.domain.campaigncontext.ports.NpcRepository; import org.springframework.stereotype.Service; @@ -20,11 +18,9 @@ import java.util.Optional; public class NpcService { private final NpcRepository npcRepository; - private final CampaignRepository campaignRepository; - public NpcService(NpcRepository npcRepository, CampaignRepository campaignRepository) { + public NpcService(NpcRepository npcRepository) { this.npcRepository = npcRepository; - this.campaignRepository = campaignRepository; } public record NpcData( @@ -67,21 +63,6 @@ public class NpcService { return npcRepository.findByCampaignId(campaignId); } - /** - * PNJ de TOUTES les campagnes liées au Lore donné (via {@code campaign.loreId}). - * Sert au graphe du Lore : relier les PNJ aux pages qu'ils référencent. - * Volume faible (usage mono-utilisateur) → filtrage en mémoire assumé. - */ - public List getNpcsByLoreId(String loreId) { - List out = new ArrayList<>(); - for (Campaign campaign : campaignRepository.findAll()) { - if (campaign.isLinkedToLore() && campaign.getLoreId().equals(loreId)) { - out.addAll(npcRepository.findByCampaignId(campaign.getId())); - } - } - return out; - } - public Npc updateNpc(String id, NpcData data) { Npc existing = npcRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id)); diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java index feec046..27f0370 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Campaign.java @@ -38,6 +38,12 @@ public class Campaign { */ private String gameSystemId; + /** + * Positions des nœuds du graphe de campagne (JSON opaque, état de + * présentation possédé par le front). Null = disposition automatique. + */ + private String graphPositions; + public boolean isLinkedToLore() { return this.loreId != null && !this.loreId.isBlank(); } diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java index 1c33c15..a85a739 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/CampaignJpaEntity.java @@ -55,6 +55,13 @@ public class CampaignJpaEntity { @Column(name = "game_system_id") private String gameSystemId; + /** + * Positions personnalisées des nœuds du graphe de campagne (JSON opaque, + * possédé par le front). Null = disposition automatique. + */ + @Column(name = "graph_positions", columnDefinition = "TEXT") + private String graphPositions; + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java index cdc036e..028d3b6 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresCampaignRepository.java @@ -76,6 +76,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .playerCount(jpaEntity.getPlayerCount()) .loreId(jpaEntity.getLoreId()) .gameSystemId(jpaEntity.getGameSystemId()) + .graphPositions(jpaEntity.getGraphPositions()) .build(); } @@ -91,6 +92,7 @@ public class PostgresCampaignRepository implements CampaignRepository { .playerCount(campaign.getPlayerCount()) .loreId(campaign.getLoreId()) .gameSystemId(campaign.getGameSystemId()) + .graphPositions(campaign.getGraphPositions()) .build(); } } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/CampaignContentInserter.java b/core/src/main/java/com/loremind/infrastructure/transfer/CampaignContentInserter.java new file mode 100644 index 0000000..d8fca08 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/CampaignContentInserter.java @@ -0,0 +1,294 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.SceneBattlemap; +import com.loremind.domain.campaigncontext.SceneType; +import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; +import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter; +import com.loremind.infrastructure.persistence.entity.ArcJpaEntity; +import com.loremind.infrastructure.persistence.entity.CampaignJpaEntity; +import com.loremind.infrastructure.persistence.entity.CatalogItemJpaEntity; +import com.loremind.infrastructure.persistence.entity.ChapterJpaEntity; +import com.loremind.infrastructure.persistence.entity.EnemyJpaEntity; +import com.loremind.infrastructure.persistence.entity.ItemCatalogJpaEntity; +import com.loremind.infrastructure.persistence.entity.NpcJpaEntity; +import com.loremind.infrastructure.persistence.entity.QuestJpaEntity; +import com.loremind.infrastructure.persistence.entity.RandomTableEntryJpaEntity; +import com.loremind.infrastructure.persistence.entity.RandomTableJpaEntity; +import com.loremind.infrastructure.persistence.entity.SceneJpaEntity; +import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository; +import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository; +import com.loremind.infrastructure.persistence.jpa.EnemyJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ItemCatalogJpaRepository; +import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; +import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository; +import com.loremind.infrastructure.persistence.jpa.RandomTableJpaRepository; +import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 1re passe d'import du contenu de campagne (cf. {@link ImportService}) : Campaign, + * Arc, ItemCatalog, RandomTable, Chapter, Quest (v2 ou conversion legacy via + * {@link LegacyQuestConverter}), Npc, Enemy, Scene. Alimente les maps de remapping + * correspondantes de {@link ImportIdMaps}. + */ +@Component +class CampaignContentInserter { + + // (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite + // est scellé, non sérialisable en polymorphe par l'ObjectMapper standard). + private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter(); + private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter(); + + private final CampaignJpaRepository campaignRepo; + private final ArcJpaRepository arcRepo; + private final ItemCatalogJpaRepository itemCatalogRepo; + private final RandomTableJpaRepository randomTableRepo; + private final ChapterJpaRepository chapterRepo; + private final QuestJpaRepository questRepo; + private final NpcJpaRepository npcRepo; + private final EnemyJpaRepository enemyRepo; + private final SceneJpaRepository sceneRepo; + private final LegacyQuestConverter legacyQuestConverter; + + CampaignContentInserter(CampaignJpaRepository campaignRepo, + ArcJpaRepository arcRepo, + ItemCatalogJpaRepository itemCatalogRepo, + RandomTableJpaRepository randomTableRepo, + ChapterJpaRepository chapterRepo, + QuestJpaRepository questRepo, + NpcJpaRepository npcRepo, + EnemyJpaRepository enemyRepo, + SceneJpaRepository sceneRepo, + LegacyQuestConverter legacyQuestConverter) { + this.campaignRepo = campaignRepo; + this.arcRepo = arcRepo; + this.itemCatalogRepo = itemCatalogRepo; + this.randomTableRepo = randomTableRepo; + this.chapterRepo = chapterRepo; + this.questRepo = questRepo; + this.npcRepo = npcRepo; + this.enemyRepo = enemyRepo; + this.sceneRepo = sceneRepo; + this.legacyQuestConverter = legacyQuestConverter; + } + + void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) { + // -- Campaign (loreId/gameSystemId String remappes en 2e passe) + for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) { + CampaignJpaEntity e = new CampaignJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setArcsCount(d.arcsCount()); + e.setPlayerCount(d.playerCount()); + e.setLoreId(d.loreId()); // remappe en 2e passe + e.setGameSystemId(d.gameSystemId()); // remappe en 2e passe + maps.campaignMap.put(d.id(), campaignRepo.save(e).getId()); + } + result.count("campaigns", maps.campaignMap.size()); + + // -- Arc (relatedPageIds remappe en 2e passe) + for (ContentExport.ArcDto d : nullSafe(export.arcs())) { + ArcJpaEntity e = new ArcJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setOrder(d.order()); + e.setType(IdRemapper.parseArcType(d.type())); + e.setIcon(d.icon()); + e.setThemes(d.themes()); + e.setStakes(d.stakes()); + e.setGmNotes(d.gmNotes()); + e.setRewards(d.rewards()); + e.setResolution(d.resolution()); + e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe + e.setIllustrationImageIds(d.illustrationImageIds()); + maps.arcMap.put(d.id(), arcRepo.save(e).getId()); + } + result.count("arcs", maps.arcMap.size()); + + // -- ItemCatalog (+ items en cascade) + int catalogCount = 0, itemCount = 0; + for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) { + ItemCatalogJpaEntity e = new ItemCatalogJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setIcon(d.icon()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setOrder(d.order()); + List items = new ArrayList<>(); + for (ContentExport.CatalogItemDto i : nullSafe(d.items())) { + CatalogItemJpaEntity item = new CatalogItemJpaEntity(); + item.setName(i.name()); + item.setPrice(i.price()); + item.setCategory(i.category()); + item.setDescription(i.description()); + item.setPosition(i.position()); + item.setCatalog(e); // lien parent requis pour la cascade + items.add(item); + itemCount++; + } + e.setItems(items); + itemCatalogRepo.save(e); + catalogCount++; + } + result.count("itemCatalogs", catalogCount); + result.count("catalogItems", itemCount); + + // -- RandomTable (+ entries en cascade) + int tableCount = 0, entryCount = 0; + for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) { + RandomTableJpaEntity e = new RandomTableJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setDiceFormula(d.diceFormula()); + e.setIcon(d.icon()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setOrder(d.order()); + List entries = new ArrayList<>(); + for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) { + RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity(); + entryE.setMinRoll(en.minRoll()); + entryE.setMaxRoll(en.maxRoll()); + entryE.setLabel(en.label()); + entryE.setDetail(en.detail()); + entryE.setPosition(en.position()); + entryE.setRandomTable(e); + entries.add(entryE); + entryCount++; + } + e.setEntries(entries); + randomTableRepo.save(e); + tableCount++; + } + result.count("randomTables", tableCount); + result.count("randomTableEntries", entryCount); + + // -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de + // prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy. + for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { + ChapterJpaEntity e = new ChapterJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setArcId(IdRemapper.remapId(maps.arcMap, d.arcId())); + e.setOrder(d.order()); + e.setIcon(d.icon()); + e.setGmNotes(d.gmNotes()); + e.setPlayerObjectives(d.playerObjectives()); + e.setNarrativeStakes(d.narrativeStakes()); + e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe + e.setIllustrationImageIds(d.illustrationImageIds()); + maps.chapterMap.put(d.id(), chapterRepo.save(e).getId()); + } + result.count("chapters", maps.chapterMap.size()); + + // -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ; + // prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt). + for (ContentExport.QuestDto d : nullSafe(export.quests())) { + QuestJpaEntity e = new QuestJpaEntity(); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setArcId(IdRemapper.remapId(maps.arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null + e.setName(d.name()); + e.setDescription(d.description()); + e.setIcon(d.icon()); + e.setOrder(d.order()); + e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe + e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe + e.setGmNotes(d.gmNotes()); + e.setPlayerObjectives(d.playerObjectives()); + e.setNarrativeStakes(d.narrativeStakes()); + e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe + e.setIllustrationImageIds(d.illustrationImageIds()); + maps.questMap.put(d.id(), questRepo.save(e).getId()); + } + + // -- Quest legacy (bundle SANS champ quests) : conversion des chapitres qui jouaient + // le rôle de quête. Ici questMap est clé par ANCIEN CHAPTER id (pas de collision : + // on compare chapter-id à chapter-id). + if (export.quests() == null) { + legacyQuestConverter.convertLegacyChaptersToQuests(export, maps); + } + result.count("quests", maps.questMap.size()); + + // -- Npc (relatedPageIds remappe en 2e passe) + for (ContentExport.NpcDto d : nullSafe(export.npcs())) { + NpcJpaEntity e = new NpcJpaEntity(); + e.setName(d.name()); + e.setPortraitImageId(d.portraitImageId()); + e.setHeaderImageId(d.headerImageId()); + e.setValues(d.values()); + e.setImageValues(d.imageValues()); + e.setKeyValueValues(d.keyValueValues()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe + e.setFolder(d.folder()); + e.setOrder(d.order()); + maps.npcMap.put(d.id(), npcRepo.save(e).getId()); + } + result.count("npcs", maps.npcMap.size()); + + // -- Enemy + for (ContentExport.EnemyDto d : nullSafe(export.enemies())) { + EnemyJpaEntity e = new EnemyJpaEntity(); + e.setName(d.name()); + e.setLevel(d.level()); + e.setFolder(d.folder()); + e.setPortraitImageId(d.portraitImageId()); + e.setHeaderImageId(d.headerImageId()); + e.setValues(d.values()); + e.setImageValues(d.imageValues()); + e.setKeyValueValues(d.keyValueValues()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle + e.setFoundryStats(d.foundryStats()); + e.setOrder(d.order()); + maps.enemyMap.put(d.id(), enemyRepo.save(e).getId()); + } + result.count("enemies", maps.enemyMap.size()); + + // -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe) + for (ContentExport.SceneDto d : nullSafe(export.scenes())) { + SceneJpaEntity e = new SceneJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setChapterId(IdRemapper.remapId(maps.chapterMap, d.chapterId())); + e.setOrder(d.order()); + e.setIcon(d.icon()); + e.setType(d.type() != null ? d.type() : SceneType.GENERIC); + e.setLocation(d.location()); + e.setTiming(d.timing()); + e.setAtmosphere(d.atmosphere()); + e.setPlayerNarration(d.playerNarration()); + e.setGmSecretNotes(d.gmSecretNotes()); + e.setChoicesConsequences(d.choicesConsequences()); + e.setCombatDifficulty(d.combatDifficulty()); + e.setEnemies(d.enemies()); + e.setEnemyIds(d.enemyIds()); // remappe en 2e passe + e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe + e.setIllustrationImageIds(d.illustrationImageIds()); + // Battlemaps : ids StoredFile passes tels quels (meme logique que les refs + // d'images illustration, non remappees). Les exports anterieurs a V22 + // portaient une paire unique -> reconstituee en premiere entree de liste. + List battlemaps = d.battlemaps(); + if ((battlemaps == null || battlemaps.isEmpty()) + && (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) { + battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId())); + } + e.setBattlemaps(battlemaps != null ? battlemaps : List.of()); + e.setGraphX(d.graphX()); + e.setGraphY(d.graphY()); + e.setBranches(d.branches()); // remappe en 2e passe + e.setRooms(d.rooms()); // Rooms: UUID, non remappes + maps.sceneMap.put(d.id(), sceneRepo.save(e).getId()); + } + result.count("scenes", maps.sceneMap.size()); + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportArchiveParser.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportArchiveParser.java new file mode 100644 index 0000000..5ba3ae7 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportArchiveParser.java @@ -0,0 +1,77 @@ +package com.loremind.infrastructure.transfer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Déballage d'un zip d'import (cf. {@link ImportService}) : {@code data.json} + * désérialisé en {@link ContentExport}, binaires images/fichiers gardés en mémoire + * sous leur clé de stockage d'origine. + */ +@Component +class ImportArchiveParser { + + /** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */ + record ParsedArchive(ContentExport export, + Map imageBinaries, + Map fileBinaries) { + } + + private final ObjectMapper objectMapper; + + ImportArchiveParser(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Déballe le zip : {@code data.json → ContentExport} et {@code images/ → binaire} + * (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque. + */ + ParsedArchive parse(InputStream zipStream) { + ContentExport export = null; + Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire + Map fileBinaries = new LinkedHashMap<>(); // storageKey -> binaire + try (ZipInputStream zip = new ZipInputStream(zipStream)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + String name = entry.getName(); + if ("data.json".equals(name)) { + export = objectMapper.readValue(readAll(zip), ContentExport.class); + } else if (name.startsWith("images/") && !entry.isDirectory()) { + // La cle de stockage est le chemin sans le prefixe "images/" du zip, + // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext"). + String storageKey = name.substring("images/".length()); + imageBinaries.put(storageKey, readAll(zip)); + } else if (name.startsWith("files/") && !entry.isDirectory()) { + // Le prefixe zip "files/" enrobe le storageKey, lui-meme "files/UUID.ext" : + // on retire UNE fois le prefixe pour retrouver la cle d'origine. + String storageKey = name.substring("files/".length()); + fileBinaries.put(storageKey, readAll(zip)); + } + zip.closeEntry(); + } + } catch (IOException e) { + throw new UncheckedIOException("Echec de lecture du zip d'import", e); + } + if (export == null) { + throw new IllegalArgumentException("Archive invalide : data.json introuvable"); + } + return new ParsedArchive(export, imageBinaries, fileBinaries); + } + + private static byte[] readAll(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + in.transferTo(buffer); + return buffer.toByteArray(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportIdMaps.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportIdMaps.java new file mode 100644 index 0000000..f53842e --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportIdMaps.java @@ -0,0 +1,42 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity; +import com.loremind.infrastructure.transfer.dto.ContentExport; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * État partagé entre les phases d'un import (cf. {@link ImportService}) : maps de + * remapping {@code oldId → newId} par type, alimentées par les inserters (1re passe) + * et consommées par {@link ImportReferenceRemapper} (2e passe), plus les entités dont + * une référence n'a pas pu être résolue à l'insertion. + */ +final class ImportIdMaps { + + final Map gameSystemMap = new HashMap<>(); + final Map loreMap = new HashMap<>(); + final Map loreNodeMap = new HashMap<>(); + final Map templateMap = new HashMap<>(); + final Map pageMap = new HashMap<>(); + final Map campaignMap = new HashMap<>(); + final Map arcMap = new HashMap<>(); + final Map chapterMap = new HashMap<>(); + final Map npcMap = new HashMap<>(); + final Map enemyMap = new HashMap<>(); + final Map characterMap = new HashMap<>(); + final Map sceneMap = new HashMap<>(); + final Map questMap = new HashMap<>(); + final Map playthroughMap = new HashMap<>(); + final Map sessionMap = new HashMap<>(); + final Map clockMap = new HashMap<>(); + final Map frontMap = new HashMap<>(); + + /** LoreNodes sauvés avec leur parentId d'origine, à remapper en 2e passe. */ + final List loreNodesToFix = new ArrayList<>(); + + /** Templates du bundle portant un defaultNodeId, à remapper en 2e passe. */ + final List templatesWithDefaultNode = new ArrayList<>(); +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportReferenceRemapper.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportReferenceRemapper.java new file mode 100644 index 0000000..facf058 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportReferenceRemapper.java @@ -0,0 +1,157 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity; +import com.loremind.infrastructure.persistence.jpa.ArcJpaRepository; +import com.loremind.infrastructure.persistence.jpa.CampaignJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ChapterJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository; +import com.loremind.infrastructure.persistence.jpa.LoreNodeJpaRepository; +import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository; +import com.loremind.infrastructure.persistence.jpa.PageJpaRepository; +import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository; +import com.loremind.infrastructure.persistence.jpa.SceneJpaRepository; +import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +/** + * 2e passe d'un import (cf. {@link ImportService}) : remappe les références qui + * pointent vers des types insérés plus tard (parentId, defaultNodeId, refs faibles + * String) puis re-save. Les références vers un id absent des maps (ex. relatedPageId + * hors export) sont CONSERVÉES telles quelles. + */ +@Component +class ImportReferenceRemapper { + + private final LoreNodeJpaRepository loreNodeRepo; + private final TemplateJpaRepository templateRepo; + private final PageJpaRepository pageRepo; + private final CampaignJpaRepository campaignRepo; + private final ArcJpaRepository arcRepo; + private final ChapterJpaRepository chapterRepo; + private final NpcJpaRepository npcRepo; + private final SceneJpaRepository sceneRepo; + private final QuestJpaRepository questRepo; + private final ClockJpaRepository clockRepo; + + ImportReferenceRemapper(LoreNodeJpaRepository loreNodeRepo, + TemplateJpaRepository templateRepo, + PageJpaRepository pageRepo, + CampaignJpaRepository campaignRepo, + ArcJpaRepository arcRepo, + ChapterJpaRepository chapterRepo, + NpcJpaRepository npcRepo, + SceneJpaRepository sceneRepo, + QuestJpaRepository questRepo, + ClockJpaRepository clockRepo) { + this.loreNodeRepo = loreNodeRepo; + this.templateRepo = templateRepo; + this.pageRepo = pageRepo; + this.campaignRepo = campaignRepo; + this.arcRepo = arcRepo; + this.chapterRepo = chapterRepo; + this.npcRepo = npcRepo; + this.sceneRepo = sceneRepo; + this.questRepo = questRepo; + this.clockRepo = clockRepo; + } + + void remap(ImportIdMaps maps) { + // LoreNode.parentId + for (LoreNodeJpaEntity e : maps.loreNodesToFix) { + Long newParent = maps.loreNodeMap.get(e.getParentId()); + if (newParent != null) { + e.setParentId(newParent); + loreNodeRepo.save(e); + } + } + + // Template.defaultNodeId + for (ContentExport.TemplateDto d : maps.templatesWithDefaultNode) { + Long newTemplateId = maps.templateMap.get(d.id()); + Long newNode = maps.loreNodeMap.get(d.defaultNodeId()); + if (newTemplateId != null && newNode != null) { + templateRepo.findById(newTemplateId).ifPresent(t -> { + t.setDefaultNodeId(newNode); + templateRepo.save(t); + }); + } + } + + // Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). + for (Long newCampaignId : maps.campaignMap.values()) { + campaignRepo.findById(newCampaignId).ifPresent(c -> { + String newLore = IdRemapper.remapStringId(maps.loreMap, c.getLoreId()); + String newGs = IdRemapper.remapStringId(maps.gameSystemMap, c.getGameSystemId()); + c.setLoreId(newLore); + c.setGameSystemId(newGs); + campaignRepo.save(c); + }); + } + + // Page.relatedPageIds + for (Long newPageId : maps.pageMap.values()) { + pageRepo.findById(newPageId).ifPresent(p -> { + p.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, p.getRelatedPageIds())); + pageRepo.save(p); + }); + } + + // Arc.relatedPageIds + for (Long newArcId : maps.arcMap.values()) { + arcRepo.findById(newArcId).ifPresent(a -> { + a.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, a.getRelatedPageIds())); + arcRepo.save(a); + }); + } + + // Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1) + for (Long newChapterId : maps.chapterMap.values()) { + chapterRepo.findById(newChapterId).ifPresent(c -> { + c.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, c.getRelatedPageIds())); + chapterRepo.save(c); + }); + } + + // Npc.relatedPageIds + for (Long newNpcId : maps.npcMap.values()) { + npcRepo.findById(newNpcId).ifPresent(n -> { + n.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, n.getRelatedPageIds())); + npcRepo.save(n); + }); + } + + // Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene) + for (Long newSceneId : maps.sceneMap.values()) { + sceneRepo.findById(newSceneId).ifPresent(s -> { + s.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, s.getRelatedPageIds())); + s.setEnemyIds(IdRemapper.remapStringList(maps.enemyMap, s.getEnemyIds())); + s.setBranches(IdRemapper.remapBranches(maps.sceneMap, s.getBranches())); + sceneRepo.save(s); + }); + } + + // Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap), + // relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici. + for (Long newQuestId : maps.questMap.values()) { + questRepo.findById(newQuestId).ifPresent(q -> { + q.setPrerequisites(IdRemapper.remapPrerequisites(maps.questMap, q.getPrerequisites())); + q.setNodes(IdRemapper.remapQuestNodes(maps.chapterMap, maps.sceneMap, q.getNodes())); + q.setRelatedPageIds(IdRemapper.remapStringList(maps.pageMap, q.getRelatedPageIds())); + questRepo.save(q); + }); + } + + // Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée + // (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper). + for (Long newClockId : maps.clockMap.values()) { + clockRepo.findById(newClockId).ifPresent(c -> { + if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) { + c.setTriggerRef(IdRemapper.remapStringId(maps.questMap, c.getTriggerRef())); + clockRepo.save(c); + } + }); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java index 8b6de0b..8334392 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -1,52 +1,32 @@ package com.loremind.infrastructure.transfer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.loremind.domain.campaigncontext.NodeType; -import com.loremind.domain.campaigncontext.Prerequisite; -import com.loremind.domain.campaigncontext.ProgressionStatus; -import com.loremind.domain.campaigncontext.QuestNodeRef; -import com.loremind.domain.campaigncontext.SceneBattlemap; -import com.loremind.domain.campaigncontext.SceneType; -import com.loremind.domain.playcontext.ClockTrigger; -import com.loremind.domain.playcontext.EntryType; -import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; -import com.loremind.infrastructure.persistence.converter.QuestNodeListJsonConverter; -import com.loremind.infrastructure.persistence.entity.*; -import com.loremind.infrastructure.persistence.jpa.*; import com.loremind.infrastructure.transfer.dto.ContentExport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.ByteArrayOutputStream; -import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; /** - * Service d'IMPORT de contenu en mode FUSION. + * Façade d'IMPORT de contenu en mode FUSION. *

* On NE remplace PAS l'existant : chaque entite importee est reinseree avec un * NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles * String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports * dans une meme base sans collision, entre Postgres et H2. *

- * Algorithme : + * Algorithme, chaque phase etant portee par un composant dedie : *

    - *
  1. Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.
  2. - *
  3. Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; - * skip si une ImageJpaEntity avec cette cle existe deja.
  4. - *
  5. Insere top-down en construisant les maps de remapping par type.
  6. - *
  7. 2e passe : remappe les references qui pointent vers des types inserees - * plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.
  8. + *
  9. {@link ImportArchiveParser} : {@code data.json} -> {@link ContentExport}, + * binaires gardes en memoire.
  10. + *
  11. {@link ImageImporter} / {@link StoredFileImporter} : reecrit les binaires + * sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; skip si la cle existe deja.
  12. + *
  13. Insertion top-down en construisant les maps de remapping par type + * ({@link ImportIdMaps}) : {@link LoreContentInserter} (referentiel lore + systemes), + * {@link CampaignContentInserter} (contenu de campagne, quetes legacy comprises), + * {@link PlayStateInserter} (etat de jeu).
  14. + *
  15. {@link ImportReferenceRemapper} : 2e passe, remappe les references qui pointent + * vers des types inserees plus tard (parentId, defaultNodeId, refs faibles String) + * puis re-save.
  16. *
* Les references vers un id absent des maps (ex. relatedPageId hors export) sont * CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter). @@ -54,93 +34,34 @@ import java.util.zip.ZipInputStream; @Service public class ImportService { - // (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite - // est scellé, non sérialisable en polymorphe par l'ObjectMapper standard). - private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter(); - private static final QuestNodeListJsonConverter NODE_CONVERTER = new QuestNodeListJsonConverter(); - - private final GameSystemJpaRepository gameSystemRepo; - private final LoreJpaRepository loreRepo; - private final LoreNodeJpaRepository loreNodeRepo; - private final TemplateJpaRepository templateRepo; - private final PageJpaRepository pageRepo; - private final CampaignJpaRepository campaignRepo; - private final ArcJpaRepository arcRepo; - private final ChapterJpaRepository chapterRepo; - private final SceneJpaRepository sceneRepo; - private final CharacterJpaRepository characterRepo; - private final NpcJpaRepository npcRepo; - private final EnemyJpaRepository enemyRepo; - private final ItemCatalogJpaRepository itemCatalogRepo; - private final RandomTableJpaRepository randomTableRepo; - private final PlaythroughJpaRepository playthroughRepo; - private final SessionJpaRepository sessionRepo; - private final SessionEntryJpaRepository sessionEntryRepo; - private final PlaythroughFlagJpaRepository playthroughFlagRepo; - private final QuestProgressionJpaRepository questProgressionRepo; - private final QuestJpaRepository questRepo; - private final ClockJpaRepository clockRepo; - private final FrontJpaRepository frontRepo; + private final ImportArchiveParser archiveParser; private final ImageImporter imageImporter; private final StoredFileImporter storedFileImporter; - private final ObjectMapper objectMapper; + private final LoreContentInserter loreContentInserter; + private final CampaignContentInserter campaignContentInserter; + private final PlayStateInserter playStateInserter; + private final ImportReferenceRemapper referenceRemapper; - public ImportService(GameSystemJpaRepository gameSystemRepo, - LoreJpaRepository loreRepo, - LoreNodeJpaRepository loreNodeRepo, - TemplateJpaRepository templateRepo, - PageJpaRepository pageRepo, - CampaignJpaRepository campaignRepo, - ArcJpaRepository arcRepo, - ChapterJpaRepository chapterRepo, - SceneJpaRepository sceneRepo, - CharacterJpaRepository characterRepo, - NpcJpaRepository npcRepo, - EnemyJpaRepository enemyRepo, - ItemCatalogJpaRepository itemCatalogRepo, - RandomTableJpaRepository randomTableRepo, - PlaythroughJpaRepository playthroughRepo, - SessionJpaRepository sessionRepo, - SessionEntryJpaRepository sessionEntryRepo, - PlaythroughFlagJpaRepository playthroughFlagRepo, - QuestProgressionJpaRepository questProgressionRepo, - QuestJpaRepository questRepo, - ClockJpaRepository clockRepo, - FrontJpaRepository frontRepo, + public ImportService(ImportArchiveParser archiveParser, ImageImporter imageImporter, StoredFileImporter storedFileImporter, - ObjectMapper objectMapper) { - this.gameSystemRepo = gameSystemRepo; - this.loreRepo = loreRepo; - this.loreNodeRepo = loreNodeRepo; - this.templateRepo = templateRepo; - this.pageRepo = pageRepo; - this.campaignRepo = campaignRepo; - this.arcRepo = arcRepo; - this.chapterRepo = chapterRepo; - this.sceneRepo = sceneRepo; - this.characterRepo = characterRepo; - this.npcRepo = npcRepo; - this.enemyRepo = enemyRepo; - this.itemCatalogRepo = itemCatalogRepo; - this.randomTableRepo = randomTableRepo; - this.playthroughRepo = playthroughRepo; - this.sessionRepo = sessionRepo; - this.sessionEntryRepo = sessionEntryRepo; - this.playthroughFlagRepo = playthroughFlagRepo; - this.questProgressionRepo = questProgressionRepo; - this.questRepo = questRepo; - this.clockRepo = clockRepo; - this.frontRepo = frontRepo; + LoreContentInserter loreContentInserter, + CampaignContentInserter campaignContentInserter, + PlayStateInserter playStateInserter, + ImportReferenceRemapper referenceRemapper) { + this.archiveParser = archiveParser; this.imageImporter = imageImporter; this.storedFileImporter = storedFileImporter; - this.objectMapper = objectMapper; + this.loreContentInserter = loreContentInserter; + this.campaignContentInserter = campaignContentInserter; + this.playStateInserter = playStateInserter; + this.referenceRemapper = referenceRemapper; } @Transactional public ImportResult importZip(InputStream zipStream) { // 1. Parse du zip. - ParsedArchive archive = parseArchive(zipStream); + ImportArchiveParser.ParsedArchive archive = archiveParser.parse(zipStream); ContentExport export = archive.export(); ImportResult.Builder result = new ImportResult.Builder(); @@ -150,659 +71,14 @@ public class ImportService { storedFileImporter.importFiles(export, archive.fileBinaries(), result); // 3. Insertion top-down + maps de remapping. - Map gameSystemMap = new HashMap<>(); - Map loreMap = new HashMap<>(); - Map loreNodeMap = new HashMap<>(); - Map templateMap = new HashMap<>(); - Map pageMap = new HashMap<>(); - Map campaignMap = new HashMap<>(); - Map arcMap = new HashMap<>(); - Map chapterMap = new HashMap<>(); - Map npcMap = new HashMap<>(); - Map enemyMap = new HashMap<>(); - Map characterMap = new HashMap<>(); - Map sceneMap = new HashMap<>(); - Map questMap = new HashMap<>(); - Map playthroughMap = new HashMap<>(); - Map sessionMap = new HashMap<>(); - Map clockMap = new HashMap<>(); - Map frontMap = new HashMap<>(); + ImportIdMaps maps = new ImportIdMaps(); + loreContentInserter.insert(export, maps, result); + campaignContentInserter.insert(export, maps, result); + playStateInserter.insert(export, maps, result); - // -- GameSystem - for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { - GameSystemJpaEntity e = new GameSystemJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setRulesMarkdown(d.rulesMarkdown()); - e.setCharacterTemplate(d.characterTemplate()); - e.setNpcTemplate(d.npcTemplate()); - e.setEnemyTemplate(d.enemyTemplate()); - e.setFoundryActorType(d.foundryActorType()); - e.setAuthor(d.author()); - e.setPublic(d.isPublic()); - gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId()); - } - result.count("gameSystems", gameSystemMap.size()); - - // -- Lore - for (ContentExport.LoreDto d : nullSafe(export.lores())) { - LoreJpaEntity e = new LoreJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setNodeCount(d.nodeCount()); - e.setPageCount(d.pageCount()); - loreMap.put(d.id(), loreRepo.save(e).getId()); - } - result.count("lores", loreMap.size()); - - // -- LoreNode (parentId remappe en 2e passe) - List loreNodesToFix = new ArrayList<>(); - for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) { - LoreNodeJpaEntity e = new LoreNodeJpaEntity(); - e.setName(d.name()); - e.setIcon(d.icon()); - e.setParentId(d.parentId()); // remappe plus bas - e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); - LoreNodeJpaEntity saved = loreNodeRepo.save(e); - loreNodeMap.put(d.id(), saved.getId()); - if (d.parentId() != null) loreNodesToFix.add(saved); - } - result.count("loreNodes", loreNodeMap.size()); - - // -- Template (defaultNodeId remappe en 2e passe) - List templatesWithDefaultNode = new ArrayList<>(); - for (ContentExport.TemplateDto d : nullSafe(export.templates())) { - TemplateJpaEntity e = new TemplateJpaEntity(); - e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); - e.setName(d.name()); - e.setDescription(d.description()); - e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas - e.setFields(d.fields()); - templateMap.put(d.id(), templateRepo.save(e).getId()); - if (d.defaultNodeId() != null) templatesWithDefaultNode.add(d); - } - result.count("templates", templateMap.size()); - - // -- Page (relatedPageIds remappe en 2e passe) - for (ContentExport.PageDto d : nullSafe(export.pages())) { - PageJpaEntity e = new PageJpaEntity(); - e.setLoreId(IdRemapper.remapId(loreMap, d.loreId())); - e.setNodeId(IdRemapper.remapId(loreNodeMap, d.nodeId())); - e.setTemplateId(IdRemapper.remapId(templateMap, d.templateId())); - e.setTitle(d.title()); - e.setValues(d.values()); - e.setImageValues(d.imageValues()); - e.setImageFraming(d.imageFraming()); - e.setKeyValueValues(d.keyValueValues()); - e.setTableValues(d.tableValues()); - e.setNotes(d.notes()); - e.setTags(d.tags()); - e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas - pageMap.put(d.id(), pageRepo.save(e).getId()); - } - result.count("pages", pageMap.size()); - - // -- Campaign (loreId/gameSystemId String remappes en 2e passe) - for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) { - CampaignJpaEntity e = new CampaignJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setArcsCount(d.arcsCount()); - e.setPlayerCount(d.playerCount()); - e.setLoreId(d.loreId()); // remappe plus bas - e.setGameSystemId(d.gameSystemId()); // remappe plus bas - campaignMap.put(d.id(), campaignRepo.save(e).getId()); - } - result.count("campaigns", campaignMap.size()); - - // -- Playthrough (Partie) : campaignId remappe - for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) { - PlaythroughJpaEntity e = new PlaythroughJpaEntity(); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setName(d.name()); - e.setDescription(d.description()); - playthroughMap.put(d.id(), playthroughRepo.save(e).getId()); - } - result.count("playthroughs", playthroughMap.size()); - - // -- Session : campaignId (ref faible String) + playthroughId remappes - for (ContentExport.SessionDto d : nullSafe(export.sessions())) { - SessionJpaEntity e = new SessionJpaEntity(); - e.setName(d.name()); - e.setCampaignId(IdRemapper.remapStringId(campaignMap, d.campaignId())); - e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - e.setStartedAt(parseDateTime(d.startedAt())); - e.setEndedAt(parseDateTime(d.endedAt())); - sessionMap.put(d.id(), sessionRepo.save(e).getId()); - } - result.count("sessions", sessionMap.size()); - - // -- SessionEntry : sessionId (ref faible String) remappe - int sessionEntryCount = 0; - for (ContentExport.SessionEntryDto d : nullSafe(export.sessionEntries())) { - SessionEntryJpaEntity e = new SessionEntryJpaEntity(); - e.setSessionId(IdRemapper.remapStringId(sessionMap, d.sessionId())); - e.setType(parseEntryType(d.type())); - e.setContent(d.content()); - e.setOccurredAt(parseDateTime(d.occurredAt())); - sessionEntryRepo.save(e); - sessionEntryCount++; - } - result.count("sessionEntries", sessionEntryCount); - - // -- PlaythroughFlag : playthroughId remappe (la contrainte unique (playthroughId,name) - // ne saute pas, le playthroughId etant neuf). - int flagCount = 0; - for (ContentExport.PlaythroughFlagDto d : nullSafe(export.playthroughFlags())) { - PlaythroughFlagJpaEntity e = new PlaythroughFlagJpaEntity(); - e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - e.setName(d.name()); - e.setValue(d.value()); - playthroughFlagRepo.save(e); - flagCount++; - } - result.count("playthroughFlags", flagCount); - - // -- Front (menaces regroupant des horloges) : playthroughId remappé. - for (ContentExport.FrontDto d : nullSafe(export.fronts())) { - FrontJpaEntity e = new FrontJpaEntity(); - e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - e.setName(d.name()); - e.setDescription(d.description()); - e.setOrder(d.order()); - frontMap.put(d.id(), frontRepo.save(e).getId()); - } - result.count("fronts", frontMap.size()); - - // -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe. - for (ContentExport.ClockDto d : nullSafe(export.clocks())) { - ClockJpaEntity e = new ClockJpaEntity(); - e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - e.setName(d.name()); - e.setDescription(d.description()); - e.setSegments(d.segments()); - e.setFilled(d.filled()); - e.setOrder(d.order()); - e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE); - e.setTriggerRef(d.triggerRef()); - e.setFrontId(IdRemapper.remapId(frontMap, d.frontId())); - clockMap.put(d.id(), clockRepo.save(e).getId()); - } - result.count("clocks", clockMap.size()); - - // -- Arc (relatedPageIds remappe en 2e passe) - for (ContentExport.ArcDto d : nullSafe(export.arcs())) { - ArcJpaEntity e = new ArcJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setOrder(d.order()); - e.setType(IdRemapper.parseArcType(d.type())); - e.setIcon(d.icon()); - e.setThemes(d.themes()); - e.setStakes(d.stakes()); - e.setGmNotes(d.gmNotes()); - e.setRewards(d.rewards()); - e.setResolution(d.resolution()); - e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas - e.setIllustrationImageIds(d.illustrationImageIds()); - arcMap.put(d.id(), arcRepo.save(e).getId()); - } - result.count("arcs", arcMap.size()); - - // -- ItemCatalog (+ items en cascade) - int catalogCount = 0, itemCount = 0; - for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) { - ItemCatalogJpaEntity e = new ItemCatalogJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setIcon(d.icon()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setOrder(d.order()); - List items = new ArrayList<>(); - for (ContentExport.CatalogItemDto i : nullSafe(d.items())) { - CatalogItemJpaEntity item = new CatalogItemJpaEntity(); - item.setName(i.name()); - item.setPrice(i.price()); - item.setCategory(i.category()); - item.setDescription(i.description()); - item.setPosition(i.position()); - item.setCatalog(e); // lien parent requis pour la cascade - items.add(item); - itemCount++; - } - e.setItems(items); - itemCatalogRepo.save(e); - catalogCount++; - } - result.count("itemCatalogs", catalogCount); - result.count("catalogItems", itemCount); - - // -- RandomTable (+ entries en cascade) - int tableCount = 0, entryCount = 0; - for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) { - RandomTableJpaEntity e = new RandomTableJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setDiceFormula(d.diceFormula()); - e.setIcon(d.icon()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setOrder(d.order()); - List entries = new ArrayList<>(); - for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) { - RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity(); - entryE.setMinRoll(en.minRoll()); - entryE.setMaxRoll(en.maxRoll()); - entryE.setLabel(en.label()); - entryE.setDetail(en.detail()); - entryE.setPosition(en.position()); - entryE.setRandomTable(e); - entries.add(entryE); - entryCount++; - } - e.setEntries(entries); - randomTableRepo.save(e); - tableCount++; - } - result.count("randomTables", tableCount); - result.count("randomTableEntries", entryCount); - - // -- Chapter (relatedPageIds remappes en 2e passe). Les chapitres n'ont plus de - // prérequis (Niveau 1) : d.prerequisitesJson() reste lu par la conversion legacy. - for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { - ChapterJpaEntity e = new ChapterJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); - e.setOrder(d.order()); - e.setIcon(d.icon()); - e.setGmNotes(d.gmNotes()); - e.setPlayerObjectives(d.playerObjectives()); - e.setNarrativeStakes(d.narrativeStakes()); - e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas - e.setIllustrationImageIds(d.illustrationImageIds()); - chapterMap.put(d.id(), chapterRepo.save(e).getId()); - } - result.count("chapters", chapterMap.size()); - - boolean bundleHasQuests = export.quests() != null; - - // -- Quest v2 (le bundle porte un champ quests) : campaignId remappé tout de suite ; - // prereqs / nodes / relatedPageIds remappés en 2e passe (sceneMap pas encore prêt). - for (ContentExport.QuestDto d : nullSafe(export.quests())) { - QuestJpaEntity e = new QuestJpaEntity(); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setArcId(IdRemapper.remapId(arcMap, d.arcId())); // arcMap déjà prêt (arcs importés avant) ; null→null - e.setName(d.name()); - e.setDescription(d.description()); - e.setIcon(d.icon()); - e.setOrder(d.order()); - e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappé 2e passe - e.setNodes(NODE_CONVERTER.convertToEntityAttribute(d.nodesJson())); // remappé 2e passe - e.setGmNotes(d.gmNotes()); - e.setPlayerObjectives(d.playerObjectives()); - e.setNarrativeStakes(d.narrativeStakes()); - e.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe - e.setIllustrationImageIds(d.illustrationImageIds()); - questMap.put(d.id(), questRepo.save(e).getId()); - } - - // -- Quest legacy (bundle SANS champ quests) : on convertit les chapitres qui jouaient le - // rôle de quête (arc HUB OU porteurs de prérequis OU référencés par une progression) en - // vraies Quests, pour ne pas perdre les quêtes d'un vieux backup. Ici questMap est clé - // par ANCIEN CHAPTER id (pas de collision : on compare chapter-id à chapter-id). - if (!bundleHasQuests) { - convertLegacyChaptersToQuests(export, campaignMap, arcMap, questMap); - } - result.count("quests", questMap.size()); - - // -- QuestProgression : playthroughId + (quest id) remappés (entités déjà insérées ; - // contrainte unique (playthroughId, questId) préservée car playthroughId neuf). - int questProgCount = 0; - for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) { - QuestProgressionJpaEntity e = new QuestProgressionJpaEntity(); - e.setPlaythroughId(IdRemapper.remapId(playthroughMap, d.playthroughId())); - Long oldRef = d.chapterId(); - // v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été - // converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap. - Long newQuestId; - if (bundleHasQuests) { - newQuestId = IdRemapper.remapId(questMap, oldRef); - } else if (oldRef != null && questMap.containsKey(oldRef)) { - newQuestId = questMap.get(oldRef); - } else { - newQuestId = IdRemapper.remapId(chapterMap, oldRef); - } - e.setQuestId(newQuestId); - e.setStatus(parseProgressionStatus(d.status())); - questProgressionRepo.save(e); - questProgCount++; - } - result.count("questProgressions", questProgCount); - - // -- Npc (relatedPageIds remappe en 2e passe) - for (ContentExport.NpcDto d : nullSafe(export.npcs())) { - NpcJpaEntity e = new NpcJpaEntity(); - e.setName(d.name()); - e.setPortraitImageId(d.portraitImageId()); - e.setHeaderImageId(d.headerImageId()); - e.setValues(d.values()); - e.setImageValues(d.imageValues()); - e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas - e.setFolder(d.folder()); - e.setOrder(d.order()); - npcMap.put(d.id(), npcRepo.save(e).getId()); - } - result.count("npcs", npcMap.size()); - - // -- Enemy - for (ContentExport.EnemyDto d : nullSafe(export.enemies())) { - EnemyJpaEntity e = new EnemyJpaEntity(); - e.setName(d.name()); - e.setLevel(d.level()); - e.setFolder(d.folder()); - e.setPortraitImageId(d.portraitImageId()); - e.setHeaderImageId(d.headerImageId()); - e.setValues(d.values()); - e.setImageValues(d.imageValues()); - e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - e.setFoundryRef(d.foundryRef()); // ref externe Foundry : conservee telle quelle - e.setFoundryStats(d.foundryStats()); - e.setOrder(d.order()); - enemyMap.put(d.id(), enemyRepo.save(e).getId()); - } - result.count("enemies", enemyMap.size()); - - // -- Character (playthroughId mis a null : hors perimetre) - for (ContentExport.CharacterDto d : nullSafe(export.characters())) { - CharacterJpaEntity e = new CharacterJpaEntity(); - e.setName(d.name()); - e.setPortraitImageId(d.portraitImageId()); - e.setHeaderImageId(d.headerImageId()); - e.setValues(d.values()); - e.setImageValues(d.imageValues()); - e.setKeyValueValues(d.keyValueValues()); - e.setCampaignId(IdRemapper.remapId(campaignMap, d.campaignId())); - // playthroughId remappe vers la Partie importee (ou null si le jeu n'etait pas - // dans l'export -> la map est vide). Evite une reference pendante. - e.setPlaythroughId(playthroughMap.get(d.playthroughId())); - e.setOrder(d.order()); - characterMap.put(d.id(), characterRepo.save(e).getId()); - } - result.count("characters", characterMap.size()); - - // -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe) - for (ContentExport.SceneDto d : nullSafe(export.scenes())) { - SceneJpaEntity e = new SceneJpaEntity(); - e.setName(d.name()); - e.setDescription(d.description()); - e.setChapterId(IdRemapper.remapId(chapterMap, d.chapterId())); - e.setOrder(d.order()); - e.setIcon(d.icon()); - e.setType(d.type() != null ? d.type() : SceneType.GENERIC); - e.setLocation(d.location()); - e.setTiming(d.timing()); - e.setAtmosphere(d.atmosphere()); - e.setPlayerNarration(d.playerNarration()); - e.setGmSecretNotes(d.gmSecretNotes()); - e.setChoicesConsequences(d.choicesConsequences()); - e.setCombatDifficulty(d.combatDifficulty()); - e.setEnemies(d.enemies()); - e.setEnemyIds(d.enemyIds()); // remappe plus bas - e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas - e.setIllustrationImageIds(d.illustrationImageIds()); - // Battlemaps : ids StoredFile passes tels quels (meme logique que les refs - // d'images illustration, non remappees). Les exports anterieurs a V22 - // portaient une paire unique -> reconstituee en premiere entree de liste. - List battlemaps = d.battlemaps(); - if ((battlemaps == null || battlemaps.isEmpty()) - && (d.battlemapMediaFileId() != null || d.battlemapDataFileId() != null)) { - battlemaps = List.of(new SceneBattlemap("", d.battlemapMediaFileId(), d.battlemapDataFileId())); - } - e.setBattlemaps(battlemaps != null ? battlemaps : List.of()); - e.setGraphX(d.graphX()); - e.setGraphY(d.graphY()); - e.setBranches(d.branches()); // remappe plus bas - e.setRooms(d.rooms()); // Rooms: UUID, non remappes - sceneMap.put(d.id(), sceneRepo.save(e).getId()); - } - result.count("scenes", sceneMap.size()); - - // 4. 2e PASSE de remapping. - - // LoreNode.parentId - for (LoreNodeJpaEntity e : loreNodesToFix) { - Long newParent = loreNodeMap.get(e.getParentId()); - if (newParent != null) { - e.setParentId(newParent); - loreNodeRepo.save(e); - } - } - - // Template.defaultNodeId - for (ContentExport.TemplateDto d : templatesWithDefaultNode) { - Long newTemplateId = templateMap.get(d.id()); - Long newNode = loreNodeMap.get(d.defaultNodeId()); - if (newTemplateId != null && newNode != null) { - templateRepo.findById(newTemplateId).ifPresent(t -> { - t.setDefaultNodeId(newNode); - templateRepo.save(t); - }); - } - } - - // Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long). - for (Long newCampaignId : campaignMap.values()) { - campaignRepo.findById(newCampaignId).ifPresent(c -> { - String newLore = IdRemapper.remapStringId(loreMap, c.getLoreId()); - String newGs = IdRemapper.remapStringId(gameSystemMap, c.getGameSystemId()); - c.setLoreId(newLore); - c.setGameSystemId(newGs); - campaignRepo.save(c); - }); - } - - // Page.relatedPageIds - for (Long newPageId : pageMap.values()) { - pageRepo.findById(newPageId).ifPresent(p -> { - p.setRelatedPageIds(IdRemapper.remapStringList(pageMap, p.getRelatedPageIds())); - pageRepo.save(p); - }); - } - - // Arc.relatedPageIds - for (Long newArcId : arcMap.values()) { - arcRepo.findById(newArcId).ifPresent(a -> { - a.setRelatedPageIds(IdRemapper.remapStringList(pageMap, a.getRelatedPageIds())); - arcRepo.save(a); - }); - } - - // Chapter.relatedPageIds (les chapitres n'ont plus de prérequis depuis le Niveau 1) - for (Long newChapterId : chapterMap.values()) { - chapterRepo.findById(newChapterId).ifPresent(c -> { - c.setRelatedPageIds(IdRemapper.remapStringList(pageMap, c.getRelatedPageIds())); - chapterRepo.save(c); - }); - } - - // Npc.relatedPageIds - for (Long newNpcId : npcMap.values()) { - npcRepo.findById(newNpcId).ifPresent(n -> { - n.setRelatedPageIds(IdRemapper.remapStringList(pageMap, n.getRelatedPageIds())); - npcRepo.save(n); - }); - } - - // Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene) - for (Long newSceneId : sceneMap.values()) { - sceneRepo.findById(newSceneId).ifPresent(s -> { - s.setRelatedPageIds(IdRemapper.remapStringList(pageMap, s.getRelatedPageIds())); - s.setEnemyIds(IdRemapper.remapStringList(enemyMap, s.getEnemyIds())); - s.setBranches(IdRemapper.remapBranches(sceneMap, s.getBranches())); - sceneRepo.save(s); - }); - } - - // Quest : prereqs(QuestCompleted -> questMap), nodes(CHAPTER->chapterMap / SCENE->sceneMap), - // relatedPageIds(pageMap). En 2e passe car sceneMap n'est prêt qu'ici. - for (Long newQuestId : questMap.values()) { - questRepo.findById(newQuestId).ifPresent(q -> { - q.setPrerequisites(IdRemapper.remapPrerequisites(questMap, q.getPrerequisites())); - q.setNodes(IdRemapper.remapQuestNodes(chapterMap, sceneMap, q.getNodes())); - q.setRelatedPageIds(IdRemapper.remapStringList(pageMap, q.getRelatedPageIds())); - questRepo.save(q); - }); - } - - // Clock : triggerRef d'une horloge QUEST_COMPLETED remappé vers la quête importée - // (FLAG_SET = nom de fait, SESSION_ENDED = sans ref : rien à remapper). - for (Long newClockId : clockMap.values()) { - clockRepo.findById(newClockId).ifPresent(c -> { - if (c.getTriggerType() == ClockTrigger.QUEST_COMPLETED) { - c.setTriggerRef(IdRemapper.remapStringId(questMap, c.getTriggerRef())); - clockRepo.save(c); - } - }); - } + // 4. 2e passe de remapping. + referenceRemapper.remap(maps); return result.build(); } - - /** - * Conversion legacy : pour un bundle SANS champ {@code quests}, recrée des Quests à partir - * des chapitres qui jouaient le rôle de quête (arc HUB, OU porteurs de prérequis, OU - * référencés par une {@code quest_progression}). Alimente {@code questMap} (clé = ANCIEN - * chapter id du bundle -> nouvel id de quête). Les prérequis / nœuds / relatedPageIds sont - * remappés en 2e passe, comme pour les quêtes v2. - */ - private void convertLegacyChaptersToQuests(ContentExport export, - Map campaignMap, - Map arcMap, - Map questMap) { - Map arcById = new HashMap<>(); - for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a); - - Set progressedChapterIds = new HashSet<>(); - for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) { - if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId()); - } - - for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { - ContentExport.ArcDto arc = arcById.get(d.arcId()); - boolean isHub = arc != null && "HUB".equals(arc.type()); - List prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson()); - boolean hasPrereqs = prereqs != null && !prereqs.isEmpty(); - boolean inProgression = progressedChapterIds.contains(d.id()); - if (!isHub && !hasPrereqs && !inProgression) continue; - - QuestJpaEntity q = new QuestJpaEntity(); - q.setCampaignId(IdRemapper.remapId(campaignMap, arc != null ? arc.campaignId() : null)); - // Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ; - // les quêtes issues de prérequis/progression seules restent transverses. - q.setArcId(isHub ? IdRemapper.remapId(arcMap, d.arcId()) : null); - q.setName(d.name()); - q.setDescription(d.description()); - q.setIcon(d.icon()); - q.setOrder(d.order()); - q.setPrerequisites(prereqs); // remappé 2e passe (questMap) - q.setNodes(new ArrayList<>(List.of( - new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap) - q.setGmNotes(d.gmNotes()); - q.setPlayerObjectives(d.playerObjectives()); - q.setNarrativeStakes(d.narrativeStakes()); - q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap) - // illustrationImageIds : les chapitres legacy les conservent de leur côté. - questMap.put(d.id(), questRepo.save(q).getId()); - } - } - - // ----- Lecture de l'archive ----- - - /** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */ - private record ParsedArchive(ContentExport export, - Map imageBinaries, - Map fileBinaries) { - } - - /** - * Déballe le zip : {@code data.json → ContentExport} et {@code images/ → binaire} - * (le {@code manifest.json} est ignoré, info seulement). Lève si {@code data.json} manque. - */ - private ParsedArchive parseArchive(InputStream zipStream) { - ContentExport export = null; - Map imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire - Map fileBinaries = new LinkedHashMap<>(); // storageKey -> binaire - try (ZipInputStream zip = new ZipInputStream(zipStream)) { - ZipEntry entry; - while ((entry = zip.getNextEntry()) != null) { - String name = entry.getName(); - if ("data.json".equals(name)) { - export = objectMapper.readValue(readAll(zip), ContentExport.class); - } else if (name.startsWith("images/") && !entry.isDirectory()) { - // La cle de stockage est le chemin sans le prefixe "images/" du zip, - // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext"). - String storageKey = name.substring("images/".length()); - imageBinaries.put(storageKey, readAll(zip)); - } else if (name.startsWith("files/") && !entry.isDirectory()) { - // Le prefixe zip "files/" enrobe le storageKey, lui-meme "files/UUID.ext" : - // on retire UNE fois le prefixe pour retrouver la cle d'origine. - String storageKey = name.substring("files/".length()); - fileBinaries.put(storageKey, readAll(zip)); - } - zip.closeEntry(); - } - } catch (IOException e) { - throw new UncheckedIOException("Echec de lecture du zip d'import", e); - } - if (export == null) { - throw new IllegalArgumentException("Archive invalide : data.json introuvable"); - } - return new ParsedArchive(export, imageBinaries, fileBinaries); - } - - // ----- Helpers divers ----- - - private static List nullSafe(List list) { - return list != null ? list : List.of(); - } - - /** Parse un horodatage ISO LocalDateTime, ou null si absent/illisible. */ - private static java.time.LocalDateTime parseDateTime(String s) { - if (s == null || s.isBlank()) return null; - try { - return java.time.LocalDateTime.parse(s.trim()); - } catch (java.time.format.DateTimeParseException e) { - return null; - } - } - - /** Parse un EntryType, repli sur NOTE si inconnu/absent (jamais d'echec). */ - private static EntryType parseEntryType(String s) { - if (s == null) return EntryType.NOTE; - try { - return EntryType.valueOf(s); - } catch (IllegalArgumentException e) { - return EntryType.NOTE; - } - } - - /** Parse un ProgressionStatus, repli sur NOT_STARTED si inconnu/absent. */ - private static ProgressionStatus parseProgressionStatus(String s) { - if (s == null) return ProgressionStatus.NOT_STARTED; - try { - return ProgressionStatus.valueOf(s); - } catch (IllegalArgumentException e) { - return ProgressionStatus.NOT_STARTED; - } - } - - private static byte[] readAll(InputStream in) throws IOException { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - in.transferTo(buffer); - return buffer.toByteArray(); - } } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/LegacyQuestConverter.java b/core/src/main/java/com/loremind/infrastructure/transfer/LegacyQuestConverter.java new file mode 100644 index 0000000..016b764 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/LegacyQuestConverter.java @@ -0,0 +1,84 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.NodeType; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; +import com.loremind.infrastructure.persistence.entity.QuestJpaEntity; +import com.loremind.infrastructure.persistence.jpa.QuestJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Conversion legacy d'un import (cf. {@link ImportService}) : pour un bundle SANS champ + * {@code quests}, recrée des Quests à partir des chapitres qui jouaient le rôle de quête + * (arc HUB, OU porteurs de prérequis, OU référencés par une {@code quest_progression}). + * Permet de ne pas perdre les quêtes d'un vieux backup. + */ +@Component +class LegacyQuestConverter { + + // (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite + // est scellé, non sérialisable en polymorphe par l'ObjectMapper standard). + private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter(); + + private final QuestJpaRepository questRepo; + + LegacyQuestConverter(QuestJpaRepository questRepo) { + this.questRepo = questRepo; + } + + /** + * Alimente {@code maps.questMap} (clé = ANCIEN chapter id du bundle -> nouvel id de + * quête). Les prérequis / nœuds / relatedPageIds sont remappés en 2e passe, comme + * pour les quêtes v2. + */ + void convertLegacyChaptersToQuests(ContentExport export, ImportIdMaps maps) { + Map arcById = new HashMap<>(); + for (ContentExport.ArcDto a : nullSafe(export.arcs())) arcById.put(a.id(), a); + + Set progressedChapterIds = new HashSet<>(); + for (ContentExport.QuestProgressionDto qp : nullSafe(export.questProgressions())) { + if (qp.chapterId() != null) progressedChapterIds.add(qp.chapterId()); + } + + for (ContentExport.ChapterDto d : nullSafe(export.chapters())) { + ContentExport.ArcDto arc = arcById.get(d.arcId()); + boolean isHub = arc != null && "HUB".equals(arc.type()); + List prereqs = PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson()); + boolean hasPrereqs = prereqs != null && !prereqs.isEmpty(); + boolean inProgression = progressedChapterIds.contains(d.id()); + if (!isHub && !hasPrereqs && !inProgression) continue; + + QuestJpaEntity q = new QuestJpaEntity(); + q.setCampaignId(IdRemapper.remapId(maps.campaignMap, arc != null ? arc.campaignId() : null)); + // Quête legacy issue d'un arc HUB → rattachée à cet arc (préserve la structure HUB) ; + // les quêtes issues de prérequis/progression seules restent transverses. + q.setArcId(isHub ? IdRemapper.remapId(maps.arcMap, d.arcId()) : null); + q.setName(d.name()); + q.setDescription(d.description()); + q.setIcon(d.icon()); + q.setOrder(d.order()); + q.setPrerequisites(prereqs); // remappé 2e passe (questMap) + q.setNodes(new ArrayList<>(List.of( + new QuestNodeRef(NodeType.CHAPTER, String.valueOf(d.id()), 0)))); // remappé 2e passe (chapterMap) + q.setGmNotes(d.gmNotes()); + q.setPlayerObjectives(d.playerObjectives()); + q.setNarrativeStakes(d.narrativeStakes()); + q.setRelatedPageIds(d.relatedPageIds()); // remappé 2e passe (pageMap) + // illustrationImageIds : les chapitres legacy les conservent de leur côté. + maps.questMap.put(d.id(), questRepo.save(q).getId()); + } + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/LoreContentInserter.java b/core/src/main/java/com/loremind/infrastructure/transfer/LoreContentInserter.java new file mode 100644 index 0000000..4c1ee2c --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/LoreContentInserter.java @@ -0,0 +1,122 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.infrastructure.persistence.entity.GameSystemJpaEntity; +import com.loremind.infrastructure.persistence.entity.LoreJpaEntity; +import com.loremind.infrastructure.persistence.entity.LoreNodeJpaEntity; +import com.loremind.infrastructure.persistence.entity.PageJpaEntity; +import com.loremind.infrastructure.persistence.entity.TemplateJpaEntity; +import com.loremind.infrastructure.persistence.jpa.GameSystemJpaRepository; +import com.loremind.infrastructure.persistence.jpa.LoreJpaRepository; +import com.loremind.infrastructure.persistence.jpa.LoreNodeJpaRepository; +import com.loremind.infrastructure.persistence.jpa.PageJpaRepository; +import com.loremind.infrastructure.persistence.jpa.TemplateJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 1re passe d'import du référentiel (cf. {@link ImportService}) : GameSystem, Lore, + * LoreNode, Template, Page. Alimente les maps de remapping correspondantes de + * {@link ImportIdMaps} ; les références résolues plus tard (parentId, defaultNodeId, + * relatedPageIds) sont notées pour la 2e passe. + */ +@Component +class LoreContentInserter { + + private final GameSystemJpaRepository gameSystemRepo; + private final LoreJpaRepository loreRepo; + private final LoreNodeJpaRepository loreNodeRepo; + private final TemplateJpaRepository templateRepo; + private final PageJpaRepository pageRepo; + + LoreContentInserter(GameSystemJpaRepository gameSystemRepo, + LoreJpaRepository loreRepo, + LoreNodeJpaRepository loreNodeRepo, + TemplateJpaRepository templateRepo, + PageJpaRepository pageRepo) { + this.gameSystemRepo = gameSystemRepo; + this.loreRepo = loreRepo; + this.loreNodeRepo = loreNodeRepo; + this.templateRepo = templateRepo; + this.pageRepo = pageRepo; + } + + void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) { + // -- GameSystem + for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) { + GameSystemJpaEntity e = new GameSystemJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setRulesMarkdown(d.rulesMarkdown()); + e.setCharacterTemplate(d.characterTemplate()); + e.setNpcTemplate(d.npcTemplate()); + e.setEnemyTemplate(d.enemyTemplate()); + e.setFoundryActorType(d.foundryActorType()); + e.setAuthor(d.author()); + e.setPublic(d.isPublic()); + maps.gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId()); + } + result.count("gameSystems", maps.gameSystemMap.size()); + + // -- Lore + for (ContentExport.LoreDto d : nullSafe(export.lores())) { + LoreJpaEntity e = new LoreJpaEntity(); + e.setName(d.name()); + e.setDescription(d.description()); + e.setNodeCount(d.nodeCount()); + e.setPageCount(d.pageCount()); + maps.loreMap.put(d.id(), loreRepo.save(e).getId()); + } + result.count("lores", maps.loreMap.size()); + + // -- LoreNode (parentId remappe en 2e passe) + for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) { + LoreNodeJpaEntity e = new LoreNodeJpaEntity(); + e.setName(d.name()); + e.setIcon(d.icon()); + e.setParentId(d.parentId()); // remappe en 2e passe + e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId())); + LoreNodeJpaEntity saved = loreNodeRepo.save(e); + maps.loreNodeMap.put(d.id(), saved.getId()); + if (d.parentId() != null) maps.loreNodesToFix.add(saved); + } + result.count("loreNodes", maps.loreNodeMap.size()); + + // -- Template (defaultNodeId remappe en 2e passe) + for (ContentExport.TemplateDto d : nullSafe(export.templates())) { + TemplateJpaEntity e = new TemplateJpaEntity(); + e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId())); + e.setName(d.name()); + e.setDescription(d.description()); + e.setDefaultNodeId(d.defaultNodeId()); // remappe en 2e passe + e.setFields(d.fields()); + maps.templateMap.put(d.id(), templateRepo.save(e).getId()); + if (d.defaultNodeId() != null) maps.templatesWithDefaultNode.add(d); + } + result.count("templates", maps.templateMap.size()); + + // -- Page (relatedPageIds remappe en 2e passe) + for (ContentExport.PageDto d : nullSafe(export.pages())) { + PageJpaEntity e = new PageJpaEntity(); + e.setLoreId(IdRemapper.remapId(maps.loreMap, d.loreId())); + e.setNodeId(IdRemapper.remapId(maps.loreNodeMap, d.nodeId())); + e.setTemplateId(IdRemapper.remapId(maps.templateMap, d.templateId())); + e.setTitle(d.title()); + e.setValues(d.values()); + e.setImageValues(d.imageValues()); + e.setImageFraming(d.imageFraming()); + e.setKeyValueValues(d.keyValueValues()); + e.setTableValues(d.tableValues()); + e.setNotes(d.notes()); + e.setTags(d.tags()); + e.setRelatedPageIds(d.relatedPageIds()); // remappe en 2e passe + maps.pageMap.put(d.id(), pageRepo.save(e).getId()); + } + result.count("pages", maps.pageMap.size()); + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/PlayStateInserter.java b/core/src/main/java/com/loremind/infrastructure/transfer/PlayStateInserter.java new file mode 100644 index 0000000..d105dee --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/PlayStateInserter.java @@ -0,0 +1,217 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.campaigncontext.ProgressionStatus; +import com.loremind.domain.playcontext.ClockTrigger; +import com.loremind.domain.playcontext.EntryType; +import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity; +import com.loremind.infrastructure.persistence.entity.ClockJpaEntity; +import com.loremind.infrastructure.persistence.entity.FrontJpaEntity; +import com.loremind.infrastructure.persistence.entity.PlaythroughFlagJpaEntity; +import com.loremind.infrastructure.persistence.entity.PlaythroughJpaEntity; +import com.loremind.infrastructure.persistence.entity.QuestProgressionJpaEntity; +import com.loremind.infrastructure.persistence.entity.SessionEntryJpaEntity; +import com.loremind.infrastructure.persistence.entity.SessionJpaEntity; +import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository; +import com.loremind.infrastructure.persistence.jpa.ClockJpaRepository; +import com.loremind.infrastructure.persistence.jpa.FrontJpaRepository; +import com.loremind.infrastructure.persistence.jpa.PlaythroughFlagJpaRepository; +import com.loremind.infrastructure.persistence.jpa.PlaythroughJpaRepository; +import com.loremind.infrastructure.persistence.jpa.QuestProgressionJpaRepository; +import com.loremind.infrastructure.persistence.jpa.SessionEntryJpaRepository; +import com.loremind.infrastructure.persistence.jpa.SessionJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 1re passe d'import de l'état de jeu (cf. {@link ImportService}) : Playthrough, + * Session, SessionEntry, PlaythroughFlag, Front, Clock, QuestProgression, Character. + * Passe APRÈS {@link CampaignContentInserter} : les progressions référencent les + * quêtes/chapitres importés, et les personnages la Partie importée. + */ +@Component +class PlayStateInserter { + + private final PlaythroughJpaRepository playthroughRepo; + private final SessionJpaRepository sessionRepo; + private final SessionEntryJpaRepository sessionEntryRepo; + private final PlaythroughFlagJpaRepository playthroughFlagRepo; + private final FrontJpaRepository frontRepo; + private final ClockJpaRepository clockRepo; + private final QuestProgressionJpaRepository questProgressionRepo; + private final CharacterJpaRepository characterRepo; + + PlayStateInserter(PlaythroughJpaRepository playthroughRepo, + SessionJpaRepository sessionRepo, + SessionEntryJpaRepository sessionEntryRepo, + PlaythroughFlagJpaRepository playthroughFlagRepo, + FrontJpaRepository frontRepo, + ClockJpaRepository clockRepo, + QuestProgressionJpaRepository questProgressionRepo, + CharacterJpaRepository characterRepo) { + this.playthroughRepo = playthroughRepo; + this.sessionRepo = sessionRepo; + this.sessionEntryRepo = sessionEntryRepo; + this.playthroughFlagRepo = playthroughFlagRepo; + this.frontRepo = frontRepo; + this.clockRepo = clockRepo; + this.questProgressionRepo = questProgressionRepo; + this.characterRepo = characterRepo; + } + + void insert(ContentExport export, ImportIdMaps maps, ImportResult.Builder result) { + // -- Playthrough (Partie) : campaignId remappe + for (ContentExport.PlaythroughDto d : nullSafe(export.playthroughs())) { + PlaythroughJpaEntity e = new PlaythroughJpaEntity(); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + e.setName(d.name()); + e.setDescription(d.description()); + maps.playthroughMap.put(d.id(), playthroughRepo.save(e).getId()); + } + result.count("playthroughs", maps.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(maps.campaignMap, d.campaignId())); + e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId())); + e.setStartedAt(parseDateTime(d.startedAt())); + e.setEndedAt(parseDateTime(d.endedAt())); + maps.sessionMap.put(d.id(), sessionRepo.save(e).getId()); + } + result.count("sessions", maps.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(maps.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(maps.playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setValue(d.value()); + playthroughFlagRepo.save(e); + flagCount++; + } + result.count("playthroughFlags", flagCount); + + // -- Front (menaces regroupant des horloges) : playthroughId remappé. + for (ContentExport.FrontDto d : nullSafe(export.fronts())) { + FrontJpaEntity e = new FrontJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setDescription(d.description()); + e.setOrder(d.order()); + maps.frontMap.put(d.id(), frontRepo.save(e).getId()); + } + result.count("fronts", maps.frontMap.size()); + + // -- Clock (horloges de Partie) : playthroughId + frontId remappés ; triggerRef quête -> 2e passe. + for (ContentExport.ClockDto d : nullSafe(export.clocks())) { + ClockJpaEntity e = new ClockJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId())); + e.setName(d.name()); + e.setDescription(d.description()); + e.setSegments(d.segments()); + e.setFilled(d.filled()); + e.setOrder(d.order()); + e.setTriggerType(d.triggerType() != null ? d.triggerType() : ClockTrigger.NONE); + e.setTriggerRef(d.triggerRef()); + e.setFrontId(IdRemapper.remapId(maps.frontMap, d.frontId())); + maps.clockMap.put(d.id(), clockRepo.save(e).getId()); + } + result.count("clocks", maps.clockMap.size()); + + // -- QuestProgression : playthroughId + (quest id) remappés (quêtes/chapitres déjà + // insérés ; contrainte unique (playthroughId, questId) préservée car playthroughId neuf). + boolean bundleHasQuests = export.quests() != null; + int questProgCount = 0; + for (ContentExport.QuestProgressionDto d : nullSafe(export.questProgressions())) { + QuestProgressionJpaEntity e = new QuestProgressionJpaEntity(); + e.setPlaythroughId(IdRemapper.remapId(maps.playthroughMap, d.playthroughId())); + Long oldRef = d.chapterId(); + // v2 : oldRef est un quest id -> questMap. v1 : oldRef est un chapter id ; s'il a été + // converti en quête, questMap (clé chapter id) le résout, sinon fallback chapterMap. + Long newQuestId; + if (bundleHasQuests) { + newQuestId = IdRemapper.remapId(maps.questMap, oldRef); + } else if (oldRef != null && maps.questMap.containsKey(oldRef)) { + newQuestId = maps.questMap.get(oldRef); + } else { + newQuestId = IdRemapper.remapId(maps.chapterMap, oldRef); + } + e.setQuestId(newQuestId); + e.setStatus(parseProgressionStatus(d.status())); + questProgressionRepo.save(e); + questProgCount++; + } + result.count("questProgressions", questProgCount); + + // -- Character : inséré ici (et non avec le contenu de campagne) car il référence la + // Partie importée via playthroughId. + for (ContentExport.CharacterDto d : nullSafe(export.characters())) { + CharacterJpaEntity e = new CharacterJpaEntity(); + e.setName(d.name()); + e.setPortraitImageId(d.portraitImageId()); + e.setHeaderImageId(d.headerImageId()); + e.setValues(d.values()); + e.setImageValues(d.imageValues()); + e.setKeyValueValues(d.keyValueValues()); + e.setCampaignId(IdRemapper.remapId(maps.campaignMap, d.campaignId())); + // 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(maps.playthroughMap.get(d.playthroughId())); + e.setOrder(d.order()); + maps.characterMap.put(d.id(), characterRepo.save(e).getId()); + } + result.count("characters", maps.characterMap.size()); + } + + private static List nullSafe(List list) { + 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; + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java index 685fa36..3017321 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/pdf/PdfExportService.java @@ -1,14 +1,20 @@ package com.loremind.infrastructure.transfer.pdf; +import com.loremind.domain.campaigncontext.ArcType; +import com.loremind.domain.campaigncontext.LinkType; import com.loremind.domain.campaigncontext.NodeType; import com.loremind.domain.campaigncontext.Prerequisite; import com.loremind.domain.campaigncontext.QuestNodeRef; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.RoomBranch; +import com.loremind.domain.campaigncontext.SceneBranch; import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.shared.template.FieldType; import com.loremind.domain.shared.template.TemplateField; import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.jpa.*; +import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,11 +29,24 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.*; /** * Export d'UNE campagne en livret PDF (joli document imprimable) : structure narrative - * (arcs/quetes/scenes), PNJ & ennemis (bestiaire), lore, et battlemaps en illustration. + * (arcs/chapitres/scenes), quetes, PNJ & ennemis (bestiaire), lore, et battlemaps en + * illustration. + *

+ * Mise en page "livre de jeu de role" : couverture, sommaire imprime avec numeros de + * page (target-counter), en-tete courant, corps en serif avec libelles de champs en + * tete de ligne (style stat-block), caracteristiques en tableau horizontal compact. + *

+ * Le livret reflete la FUSION quete/conteneur de l'arbre (cf. QuestService) : l'arc + * technique SYSTEM (« Quetes libres ») est masque, un chapitre-conteneur est rendu + * comme « Quete » avec les champs de sa quete fusionnes, et la partie « Quetes » ne + * liste que les quetes SANS conteneur dans la narration (libres ou transversales) — + * pas de doublon avec les arcs/chapitres. *

* Rendu XHTML+CSS -> PDF via openhtmltopdf (100 % JVM). Les images sont rincees, * redimensionnees et re-encodees en JPEG (ImageIO + decodeur WebP TwelveMonkeys) puis @@ -42,6 +61,15 @@ public class PdfExportService { private static final int PORTRAIT_MAX = 700; private static final int ILLUSTRATION_MAX = 1500; + /** Largeur utile d'une page A4 (21cm - 2 x 1.7cm de marges). */ + private static final double CONTENT_WIDTH_CM = 17.6; + /** Hauteur max affichee d'une illustration : evite qu'une image portrait mange la page. */ + private static final double ILLUSTRATION_MAX_HEIGHT_CM = 11.0; + /** Hauteur max affichee d'un portrait de fiche (colonne de 3cm de large). */ + private static final double PORTRAIT_MAX_HEIGHT_CM = 4.5; + + private static final DateTimeFormatter FR_DATE = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.FRENCH); + private final CampaignJpaRepository campaignRepo; private final ArcJpaRepository arcRepo; private final ChapterJpaRepository chapterRepo; @@ -49,6 +77,7 @@ public class PdfExportService { private final QuestJpaRepository questRepo; private final NpcJpaRepository npcRepo; private final EnemyJpaRepository enemyRepo; + private final RandomTableJpaRepository randomTableRepo; private final GameSystemJpaRepository gameSystemRepo; private final ImageJpaRepository imageRepo; private final StoredFileJpaRepository storedFileRepo; @@ -62,6 +91,7 @@ public class PdfExportService { ChapterJpaRepository chapterRepo, SceneJpaRepository sceneRepo, QuestJpaRepository questRepo, NpcJpaRepository npcRepo, EnemyJpaRepository enemyRepo, + RandomTableJpaRepository randomTableRepo, GameSystemJpaRepository gameSystemRepo, ImageJpaRepository imageRepo, StoredFileJpaRepository storedFileRepo, LoreNodeJpaRepository loreNodeRepo, PageJpaRepository pageRepo, @@ -74,6 +104,7 @@ public class PdfExportService { this.questRepo = questRepo; this.npcRepo = npcRepo; this.enemyRepo = enemyRepo; + this.randomTableRepo = randomTableRepo; this.gameSystemRepo = gameSystemRepo; this.imageRepo = imageRepo; this.storedFileRepo = storedFileRepo; @@ -97,6 +128,7 @@ public class PdfExportService { ByteArrayOutputStream out = new ByteArrayOutputStream(); PdfRendererBuilder builder = new PdfRendererBuilder(); builder.useFastMode(); + registerFonts(builder); builder.withHtmlContent(xhtml, null); builder.toStream(out); builder.run(); @@ -111,64 +143,251 @@ public class PdfExportService { .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId)); } + // ====================================================================== Polices + + /** + * Enregistre les polices DejaVu embarquées (resources/fonts, licence libre incluse) : + * couverture Unicode tres large (fleches, ≈, cyrillique...) et vrais gras/italiques, + * la ou les polices de base PDF ne couvrent que WinAnsi. Sous-ensemble embarque + * (subset=true) : seuls les glyphes utilises entrent dans le PDF. + * Si une ressource manque, le CSS retombe sur serif/sans-serif de base. + */ + private void registerFonts(PdfRendererBuilder builder) { + font(builder, "/fonts/DejaVuSerif.ttf", "DejaVu Serif", 400, BaseRendererBuilder.FontStyle.NORMAL); + font(builder, "/fonts/DejaVuSerif-Bold.ttf", "DejaVu Serif", 700, BaseRendererBuilder.FontStyle.NORMAL); + font(builder, "/fonts/DejaVuSerif-Italic.ttf", "DejaVu Serif", 400, BaseRendererBuilder.FontStyle.ITALIC); + font(builder, "/fonts/DejaVuSerif-BoldItalic.ttf", "DejaVu Serif", 700, BaseRendererBuilder.FontStyle.ITALIC); + font(builder, "/fonts/DejaVuSans.ttf", "DejaVu Sans", 400, BaseRendererBuilder.FontStyle.NORMAL); + font(builder, "/fonts/DejaVuSans-Bold.ttf", "DejaVu Sans", 700, BaseRendererBuilder.FontStyle.NORMAL); + font(builder, "/fonts/DejaVuSans-Oblique.ttf", "DejaVu Sans", 400, BaseRendererBuilder.FontStyle.ITALIC); + font(builder, "/fonts/DejaVuSans-BoldOblique.ttf", "DejaVu Sans", 700, BaseRendererBuilder.FontStyle.ITALIC); + } + + private void font(PdfRendererBuilder builder, String resource, String family, + int weight, BaseRendererBuilder.FontStyle style) { + if (PdfExportService.class.getResource(resource) == null) { + log.warn("Police absente du classpath, repli polices de base : {}", resource); + return; + } + builder.useFont(() -> PdfExportService.class.getResourceAsStream(resource), family, weight, style, true); + } + + // ====================================================================== Structure + + /** + * Structure narrative de la campagne chargee UNE fois : arcs/chapitres/scenes/quetes + * tries, index de noms, et liens quete -> chapitre-conteneur (fusion de l'arbre). + */ + private static final class Structure { + List arcs = List.of(); + /** Arcs affiches en narration (l'arc technique SYSTEM est de la plomberie). */ + List visibleArcs = List.of(); + final Map> chaptersByArc = new LinkedHashMap<>(); + final Map> scenesByChapter = new LinkedHashMap<>(); + List quests = List.of(); + /** Chapitre-conteneur -> quete fusionnee dessus (jumeau hub ou conteneur SYSTEM). */ + final Map questByContainerChapter = new LinkedHashMap<>(); + /** Quetes SANS conteneur dans la narration visible : rendues dans la partie « Quetes ». */ + final List standaloneQuests = new ArrayList<>(); + final Map chapterNames = new HashMap<>(); + final Map sceneNames = new HashMap<>(); + final Map questNames = new HashMap<>(); + /** Noms des fiches du bestiaire — resout les {@code enemyIds} des scenes/pieces. */ + final Map enemyNames = new HashMap<>(); + + /** Ids (String) des chapitres-conteneurs de cette quete. */ + Set containerChapterIds(QuestJpaEntity q) { + Set out = new HashSet<>(); + for (Map.Entry e : questByContainerChapter.entrySet()) { + if (Objects.equals(e.getValue().getId(), q.getId())) out.add(String.valueOf(e.getKey())); + } + return out; + } + } + + private Structure loadStructure(CampaignJpaEntity campaign) { + Structure st = new Structure(); + st.arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); + List visible = new ArrayList<>(); + Set systemArcIds = new HashSet<>(); + for (ArcJpaEntity arc : st.arcs) { + if (arc.getType() == ArcType.SYSTEM) systemArcIds.add(arc.getId()); + else visible.add(arc); + } + st.visibleArcs = visible; + + Map arcOfChapter = new HashMap<>(); + for (ArcJpaEntity arc : st.arcs) { + List chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder); + st.chaptersByArc.put(arc.getId(), chapters); + for (ChapterJpaEntity ch : chapters) { + arcOfChapter.put(ch.getId(), arc.getId()); + st.chapterNames.put(String.valueOf(ch.getId()), ch.getName()); + List scenes = sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder); + st.scenesByChapter.put(ch.getId(), scenes); + for (SceneJpaEntity sc : scenes) st.sceneNames.put(String.valueOf(sc.getId()), sc.getName()); + } + } + + for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + st.enemyNames.put(String.valueOf(e.getId()), e.getName()); + } + + st.quests = sortByOrder(questRepo.findByCampaignId(campaign.getId()), QuestJpaEntity::getOrder); + for (QuestJpaEntity q : st.quests) st.questNames.put(String.valueOf(q.getId()), q.getName()); + // Meme regle que QuestService.isContainerOf : un chapitre reference est le CONTENEUR + // de la quete s'il vit dans l'arc de la quete (jumeau hub) ou dans l'arc SYSTEM. + for (QuestJpaEntity q : st.quests) { + boolean fusedInNarrative = false; + if (q.getNodes() != null) { + for (QuestNodeRef n : q.getNodes()) { + if (n.nodeType() != NodeType.CHAPTER) continue; + Long cid; + try { cid = Long.parseLong(n.nodeId()); } catch (NumberFormatException ex) { continue; } + Long arcId = arcOfChapter.get(cid); + if (arcId == null) continue; + boolean container = (q.getArcId() != null && q.getArcId().equals(arcId)) + || systemArcIds.contains(arcId); + if (!container) continue; + st.questByContainerChapter.putIfAbsent(cid, q); + if (st.questByContainerChapter.get(cid) == q && !systemArcIds.contains(arcId)) { + fusedInNarrative = true; + } + } + } + if (!fusedInNarrative) st.standaloneQuests.add(q); + } + return st; + } + // ====================================================================== XHTML + /** Entree du sommaire imprime (niveau 0 = partie, 1 = arc/dossier/quete, 2 = chapitre). */ + private record TocEntry(int level, String title, String anchor) {} + + /** + * Contexte de rendu : les sections ecrivent leur corps et alimentent les signets, + * le sommaire et les compteurs (repris ensuite sur la couverture). + */ + private static final class Ctx { + final StringBuilder body = new StringBuilder(); + final StringBuilder bookmarks = new StringBuilder(); + final List toc = new ArrayList<>(); + int scenes, quests, npcs, enemies, randomTables, lorePages; + + void toc(int level, String title, String anchor) { + toc.add(new TocEntry(level, title, anchor)); + } + } + private String buildXhtml(CampaignJpaEntity campaign) { List npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); List enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false); - StringBuilder body = new StringBuilder(); - StringBuilder bookmarks = new StringBuilder(); + // Les sections d'abord (elles remplissent sommaire + compteurs), la couverture + // et le sommaire sont assembles ensuite en tete de document. + Structure st = loadStructure(campaign); + Ctx ctx = new Ctx(); + ctx.quests = st.quests.size(); + narrative(ctx, st); + quests(ctx, st); + personas(ctx, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false); + personas(ctx, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true); + randomTables(ctx, campaign); + lore(ctx, campaign); - cover(body, campaign); - narrative(body, bookmarks, campaign); - quests(body, bookmarks, campaign); - personas(body, bookmarks, "part-npcs", "Personnages non-joueurs (PNJ)", npcEntries(campaign), npcTemplate, false); - personas(body, bookmarks, "part-enemies", "Bestiaire", enemyEntries(campaign), enemyTemplate, true); - lore(body, bookmarks, campaign); + StringBuilder full = new StringBuilder(); + cover(full, campaign, ctx); + full.append(tocHtml(ctx.toc)); + full.append(ctx.body); return "\n" + "\n" - + "\n" - + "" + bookmarks + "\n" - + "" + body + ""; + + "\n" + + "" + ctx.bookmarks + "\n" + + "" + full + ""; } - private void cover(StringBuilder b, CampaignJpaEntity c) { + private void cover(StringBuilder b, CampaignJpaEntity c, Ctx ctx) { b.append("

"); b.append("
Livret de campagne
"); b.append("

").append(esc(c.getName())).append("

"); + b.append("
"); + String meta = coverMeta(ctx); + if (!meta.isEmpty()) { + b.append("
").append(esc(meta)).append("
"); + } if (notBlank(c.getDescription())) { b.append("
").append(multiline(c.getDescription())).append("
"); } + b.append("
Exporté le ").append(esc(LocalDate.now().format(FR_DATE))) + .append("
"); b.append("
"); } - // ----- Structure narrative : arcs -> quetes -> scenes ----- + /** Ligne "3 scènes · 2 quêtes · 4 PNJ ..." de la couverture (parties non vides seulement). */ + private static String coverMeta(Ctx ctx) { + List parts = new ArrayList<>(); + if (ctx.scenes > 0) parts.add(plural(ctx.scenes, "scène", "scènes")); + if (ctx.quests > 0) parts.add(plural(ctx.quests, "quête", "quêtes")); + if (ctx.npcs > 0) parts.add(ctx.npcs + " PNJ"); + if (ctx.enemies > 0) parts.add(plural(ctx.enemies, "fiche de bestiaire", "fiches de bestiaire")); + if (ctx.randomTables > 0) parts.add(plural(ctx.randomTables, "table aléatoire", "tables aléatoires")); + if (ctx.lorePages > 0) parts.add(plural(ctx.lorePages, "page de lore", "pages de lore")); + return String.join(" · ", parts); + } - private void narrative(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) { - // Tri par ORDRE manuel (glisser-déposer) — cohérent avec l'arbre et les cartes. - List arcs = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); + private static String plural(int n, String one, String many) { + return n + " " + (n == 1 ? one : many); + } + + /** Sommaire imprime : titres a gauche, numeros de page a droite (target-counter). */ + private static String tocHtml(List toc) { + if (toc.isEmpty()) return ""; + StringBuilder t = new StringBuilder(); + t.append("

Sommaire

"); + for (TocEntry e : toc) { + t.append(""); + } + t.append("
").append(esc(e.title())) + .append("
"); + return t.toString(); + } + + // ----- Structure narrative : arcs -> chapitres (ou quetes fusionnees) -> scenes ----- + + private void narrative(Ctx ctx, Structure st) { + List arcs = st.visibleArcs; if (arcs.isEmpty()) return; // Mode plat (miroir de l'UI Niveau 0) : 1 arc d'UN SEUL chapitre → on masque les // niveaux Arc/Chapitre et on présente les scènes À PLAT (cohérent avec la sidebar). if (arcs.size() == 1) { - List only = sortByOrder(chapterRepo.findByArcId(arcs.get(0).getId()), ChapterJpaEntity::getOrder); + List only = st.chaptersByArc.get(arcs.get(0).getId()); if (only.size() == 1) { - narrativeFlat(b, bm, arcs.get(0), only.get(0)); + narrativeFlat(ctx, st, arcs.get(0), only.get(0)); return; } } + StringBuilder b = ctx.body; b.append("

Structure narrative

"); - bm.append(bookmark("Structure narrative", "part-narrative", () -> { - StringBuilder sub = new StringBuilder(); - for (ArcJpaEntity arc : arcs) sub.append(bookmark(arc.getName(), "arc-" + arc.getId(), null)); - return sub.toString(); - })); + ctx.toc(0, "Structure narrative", "part-narrative"); + + StringBuilder arcBms = new StringBuilder(); + for (ArcJpaEntity arc : arcs) { + StringBuilder chBms = new StringBuilder(); + for (ChapterJpaEntity ch : st.chaptersByArc.get(arc.getId())) { + chBms.append(bookmark(ch.getName(), "ch-" + ch.getId(), "")); + } + arcBms.append(bookmark(arc.getName(), "arc-" + arc.getId(), chBms.toString())); + } + ctx.bookmarks.append(bookmark("Structure narrative", "part-narrative", arcBms.toString())); for (ArcJpaEntity arc : arcs) { + ctx.toc(1, arc.getName(), "arc-" + arc.getId()); b.append("

") .append("Arc") .append(esc(arc.getName())).append("

"); @@ -178,20 +397,19 @@ public class PdfExportService { block(b, "Enjeux", arc.getStakes()); block(b, "Recompenses", arc.getRewards()); block(b, "Resolution", arc.getResolution()); - block(b, "Notes MJ", arc.getGmNotes()); + box(b, "secret", "Notes MJ", arc.getGmNotes()); - for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) { - b.append("
") - .append("Chapitre") + for (ChapterJpaEntity ch : st.chaptersByArc.get(arc.getId())) { + QuestJpaEntity fused = st.questByContainerChapter.get(ch.getId()); + ctx.toc(2, ch.getName(), "ch-" + ch.getId()); + b.append("
") + .append("").append(fused != null ? "Quête" : "Chapitre").append("") .append(esc(ch.getName())).append("
"); - illustrations(b, ch.getIllustrationImageIds()); - block(b, "Description", ch.getDescription()); - block(b, "Objectifs joueurs", ch.getPlayerObjectives()); - block(b, "Enjeux narratifs", ch.getNarrativeStakes()); - block(b, "Notes MJ", ch.getGmNotes()); + chapterFields(b, ch, fused, st); - for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) { - scene(b, sc); + for (SceneJpaEntity sc : st.scenesByChapter.get(ch.getId())) { + scene(b, sc, st); + ctx.scenes++; } b.append("
"); } @@ -199,14 +417,46 @@ public class PdfExportService { } } + /** + * Champs d'un chapitre ; si une quete est FUSIONNEE dessus (chapitre-conteneur), ses + * champs sont integres au meme bloc — conditions de deblocage comprises — en dedupliquant + * les textes identiques (les quetes converties du legacy dupliquent le chapitre). + */ + private void chapterFields(StringBuilder b, ChapterJpaEntity ch, QuestJpaEntity fused, Structure st) { + illustrations(b, ch.getIllustrationImageIds()); + if (fused == null) { + block(b, "Description", ch.getDescription()); + block(b, "Objectifs joueurs", ch.getPlayerObjectives()); + block(b, "Enjeux narratifs", ch.getNarrativeStakes()); + box(b, "secret", "Notes MJ", ch.getGmNotes()); + return; + } + illustrations(b, fused.getIllustrationImageIds()); + block(b, "Description", mergeField(ch.getDescription(), fused.getDescription())); + block(b, "Conditions de déblocage", renderPrerequisites(fused.getPrerequisites(), st.questNames)); + block(b, "Nœuds liés", renderQuestNodes(fused, st)); + block(b, "Objectifs joueurs", mergeField(ch.getPlayerObjectives(), fused.getPlayerObjectives())); + block(b, "Enjeux narratifs", mergeField(ch.getNarrativeStakes(), fused.getNarrativeStakes())); + box(b, "secret", "Notes MJ", mergeField(ch.getGmNotes(), fused.getGmNotes())); + } + + /** Deux sources pour un meme champ : garde la non-vide, ou les deux si differentes. */ + private static String mergeField(String chapterValue, String questValue) { + if (!notBlank(chapterValue)) return questValue; + if (!notBlank(questValue)) return chapterValue; + if (chapterValue.trim().equals(questValue.trim())) return chapterValue; + return chapterValue + "\n\n" + questValue; + } + /** * Mode plat : scènes présentées sous « Scènes », sans en-têtes Arc/Chapitre. Le contenu * narratif éventuel des niveaux masqués reste affiché en intro ({@code block} ignore les * valeurs vides → en pratique rien n'apparaît pour un arc/chapitre auto-créés et vides). */ - private void narrativeFlat(StringBuilder b, StringBuilder bm, ArcJpaEntity arc, ChapterJpaEntity ch) { + private void narrativeFlat(Ctx ctx, Structure st, ArcJpaEntity arc, ChapterJpaEntity ch) { + StringBuilder b = ctx.body; b.append("

Scènes

"); - bm.append(bookmark("Scènes", "part-narrative", null)); + ctx.toc(0, "Scènes", "part-narrative"); illustrations(b, arc.getIllustrationImageIds()); block(b, "Description", arc.getDescription()); @@ -214,84 +464,180 @@ public class PdfExportService { block(b, "Enjeux", arc.getStakes()); block(b, "Recompenses", arc.getRewards()); block(b, "Resolution", arc.getResolution()); - block(b, "Notes MJ", arc.getGmNotes()); - illustrations(b, ch.getIllustrationImageIds()); - block(b, "Description", ch.getDescription()); - block(b, "Objectifs joueurs", ch.getPlayerObjectives()); - block(b, "Enjeux narratifs", ch.getNarrativeStakes()); - block(b, "Notes MJ", ch.getGmNotes()); + box(b, "secret", "Notes MJ", arc.getGmNotes()); + chapterFields(b, ch, st.questByContainerChapter.get(ch.getId()), st); - for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) { - scene(b, sc); + StringBuilder scBms = new StringBuilder(); + for (SceneJpaEntity sc : st.scenesByChapter.get(ch.getId())) { + ctx.toc(1, sc.getName(), "scene-" + sc.getId()); + scBms.append(bookmark(sc.getName(), "scene-" + sc.getId(), "")); + scene(b, sc, st); + ctx.scenes++; } + ctx.bookmarks.append(bookmark("Scènes", "part-narrative", scBms.toString())); } - private void scene(StringBuilder b, SceneJpaEntity sc) { - b.append("
") + private void scene(StringBuilder b, SceneJpaEntity sc, Structure st) { + b.append("
") .append("Scène") .append(esc(sc.getName())).append("
"); - block(b, "Lieu", sc.getLocation()); - block(b, "Moment", sc.getTiming()); - block(b, "Ambiance", sc.getAtmosphere()); - block(b, "Narration joueur", sc.getPlayerNarration()); - block(b, "Notes secretes MJ", sc.getGmSecretNotes()); + // Contexte : lieu et moment en une ligne italique sous le titre (comme le + // sous-titre d'une rencontre dans un livre de JdR) ; repli en champs normaux + // si le texte est long/multiligne. + if (isMetaShort(sc.getLocation()) && isMetaShort(sc.getTiming()) + && (notBlank(sc.getLocation()) || notBlank(sc.getTiming()))) { + List meta = new ArrayList<>(); + if (notBlank(sc.getLocation())) meta.add(sc.getLocation().trim()); + if (notBlank(sc.getTiming())) meta.add(sc.getTiming().trim()); + b.append("
").append(esc(String.join(" — ", meta))).append("
"); + } else { + block(b, "Lieu", sc.getLocation()); + block(b, "Moment", sc.getTiming()); + } + block(b, "Ambiance", sc.getAtmosphere(), "ambiance"); + box(b, "readaloud", "À lire aux joueurs", sc.getPlayerNarration()); + box(b, "secret", "Notes secrètes MJ", sc.getGmSecretNotes()); block(b, "Choix & consequences", sc.getChoicesConsequences()); - block(b, "Difficulte du combat", sc.getCombatDifficulty()); + block(b, "Sorties", renderSceneBranches(sc.getBranches(), st)); + // Combat : difficulte + ennemis de la rencontre (refs bestiaire resolues en noms + // + texte libre) dans le meme encadre rouge. + box(b, "combat", "Combat", joinNonBlank(sc.getCombatDifficulty(), + enemyLines(sc.getEnemyIds(), sc.getEnemies(), st))); illustrations(b, sc.getIllustrationImageIds()); // Battlemaps (images uniquement ; les videos ne sont pas rendables en PDF). - // Le libellé de la variante (Jour/Nuit…) est rendu en légende s'il est renseigné. + // Le libellé de la variante (Jour/Nuit…) est rendu en légende sous l'image. if (sc.getBattlemaps() != null) { for (var bm : sc.getBattlemaps()) { - String battlemap = fileImageUri(bm.mediaFileId()); + PdfImage battlemap = fileImage(bm.mediaFileId()); if (battlemap == null) continue; - b.append("
"); - if (bm.label() != null && !bm.label().isBlank()) { - b.append("
").append(esc(bm.label())).append("
"); - } - b.append("
"); + String caption = bm.label() == null || bm.label().isBlank() + ? "Battlemap" : "Battlemap — " + bm.label(); + illustration(b, battlemap, caption); } } + rooms(b, sc, st); b.append("
"); } - // ----- Quêtes (Niveau 1 : entités orthogonales à l'arbre, rattachées à la campagne) ----- + /** Pieces explorables de la scene (donjon, crypte...) — sous-blocs ambres. */ + private void rooms(StringBuilder b, SceneJpaEntity sc, Structure st) { + if (sc.getRooms() == null || sc.getRooms().isEmpty()) return; + List rooms = new ArrayList<>(sc.getRooms()); + rooms.sort(java.util.Comparator.comparingInt(Room::getOrder)); + Map roomNames = new HashMap<>(); + for (Room r : rooms) roomNames.put(r.getId(), r.getName()); - private void quests(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) { - List quests = sortByOrder(questRepo.findByCampaignId(campaign.getId()), QuestJpaEntity::getOrder); - if (quests.isEmpty()) return; - - // Index de noms pour résoudre les références : prérequis -> quête, nœuds -> chapitre/scène. - Map questNames = new HashMap<>(); - for (QuestJpaEntity q : quests) questNames.put(String.valueOf(q.getId()), q.getName()); - Map chapterNames = new HashMap<>(); - Map sceneNames = new HashMap<>(); - for (ArcJpaEntity arc : arcRepo.findByCampaignId(campaign.getId())) { - for (ChapterJpaEntity ch : chapterRepo.findByArcId(arc.getId())) { - chapterNames.put(String.valueOf(ch.getId()), ch.getName()); - for (SceneJpaEntity sc : sceneRepo.findByChapterId(ch.getId())) { - sceneNames.put(String.valueOf(sc.getId()), sc.getName()); + for (Room r : rooms) { + b.append("
Pièce"); + if (r.getFloor() != null) b.append(" · Étage ").append(r.getFloor()); + b.append("").append(esc(r.getName())).append("
"); + block(b, "Description", r.getDescription()); + box(b, "combat", "Ennemis", enemyLines(r.getEnemyIds(), r.getEnemies(), st)); + block(b, "Butin", r.getLoot()); + block(b, "Pièges", r.getTraps()); + box(b, "secret", "Notes MJ", r.getGmNotes()); + PdfImage map = image(r.getMapImageId(), ILLUSTRATION_MAX); + if (map != null) illustration(b, map, "Plan — " + r.getName()); + illustrations(b, r.getIllustrationImageIds()); + if (r.getBranches() != null && !r.getBranches().isEmpty()) { + List parts = new ArrayList<>(); + for (RoomBranch br : r.getBranches()) { + parts.add(branchLine(roomNames.getOrDefault(br.targetRoomId(), "?"), + br.label(), null, br.condition())); } + block(b, "Sorties", joinAsList(parts)); + } + b.append("
"); + } + } + + /** Sorties narratives d'une scene, cibles resolues en noms de scenes. */ + private String renderSceneBranches(List branches, Structure st) { + if (branches == null || branches.isEmpty()) return null; + List parts = new ArrayList<>(); + for (SceneBranch br : branches) { + String kind = br.kind() == LinkType.CLUE ? "indice" + : br.kind() == LinkType.LEAD ? "piste" : null; + parts.add(branchLine(st.sceneNames.getOrDefault(br.targetSceneId(), "?"), + br.label(), kind, br.condition())); + } + return joinAsList(parts); + } + + /** "Vers [indice] (condition : ...)" avec les morceaux presents. */ + private static String branchLine(String target, String label, String kind, String condition) { + StringBuilder line = new StringBuilder("Vers « ").append(target).append(" »"); + if (notBlank(label)) line.append(" — ").append(label.trim()); + if (kind != null) line.append(" [").append(kind).append("]"); + if (notBlank(condition)) line.append(" (condition : ").append(condition.trim()).append(")"); + return line.toString(); + } + + /** + * Ennemis d'une rencontre : refs bestiaire resolues en noms (liste a puces) + * + texte libre eventuel en paragraphe. Null si rien. + */ + private String enemyLines(List enemyIds, String freeText, Structure st) { + List names = new ArrayList<>(); + if (enemyIds != null) { + for (String id : enemyIds) { + String name = st.enemyNames.get(id); + if (name != null) names.add(name); } } + return joinNonBlank(joinAsList(names), freeText); + } + /** Joint les morceaux non vides par une ligne vide (nouveau paragraphe). Null si rien. */ + private static String joinNonBlank(String... parts) { + StringBuilder out = new StringBuilder(); + for (String p : parts) { + if (!notBlank(p)) continue; + if (out.length() > 0) out.append("\n\n"); + out.append(p); + } + return out.length() == 0 ? null : out.toString(); + } + + // ----- Quetes libres / transversales (celles SANS conteneur dans la narration) ----- + + private void quests(Ctx ctx, Structure st) { + List quests = st.standaloneQuests; + if (quests.isEmpty()) return; + + StringBuilder b = ctx.body; b.append("

Quêtes

"); - bm.append(bookmark("Quêtes", "part-quests", () -> { - StringBuilder sub = new StringBuilder(); - for (QuestJpaEntity q : quests) sub.append(bookmark(q.getName(), "quest-" + q.getId(), null)); - return sub.toString(); - })); + ctx.toc(0, "Quêtes", "part-quests"); + StringBuilder qBms = new StringBuilder(); + for (QuestJpaEntity q : quests) qBms.append(bookmark(q.getName(), "quest-" + q.getId(), "")); + ctx.bookmarks.append(bookmark("Quêtes", "part-quests", qBms.toString())); for (QuestJpaEntity q : quests) { - b.append("
"); - b.append("
").append(esc(q.getName())).append("
"); + ctx.toc(1, q.getName(), "quest-" + q.getId()); + b.append("
Quête").append(esc(q.getName())).append("
"); + illustrations(b, q.getIllustrationImageIds()); block(b, "Description", q.getDescription()); - block(b, "Conditions de déblocage", renderPrerequisites(q.getPrerequisites(), questNames)); - block(b, "Nœuds liés", renderQuestNodes(q.getNodes(), chapterNames, sceneNames)); + block(b, "Conditions de déblocage", renderPrerequisites(q.getPrerequisites(), st.questNames)); + block(b, "Nœuds liés", renderQuestNodes(q, st)); block(b, "Objectifs joueurs", q.getPlayerObjectives()); block(b, "Enjeux narratifs", q.getNarrativeStakes()); - block(b, "Notes MJ", q.getGmNotes()); - illustrations(b, q.getIllustrationImageIds()); - b.append("
"); + box(b, "secret", "Notes MJ", q.getGmNotes()); + // Scenes de ses conteneurs (arc SYSTEM, masque de la narration) : c'est ICI + // que vit le contenu jouable d'une quete libre. + Set containers = st.containerChapterIds(q); + if (q.getNodes() != null) { + for (QuestNodeRef n : q.getNodes()) { + if (n.nodeType() != NodeType.CHAPTER || !containers.contains(n.nodeId())) continue; + List scenes = st.scenesByChapter.get(Long.parseLong(n.nodeId())); + if (scenes == null) continue; + for (SceneJpaEntity sc : scenes) { + scene(b, sc, st); + ctx.scenes++; + } + } + } + b.append("
"); } } @@ -308,21 +654,38 @@ public class PdfExportService { parts.add("Fait : " + fs.flagName()); } } - return parts.isEmpty() ? null : String.join("\n", parts); + return joinAsList(parts); } - /** Nœuds narratifs (chapitres / scènes) traversés par une quête, en texte lisible. */ - private String renderQuestNodes(List nodes, Map chapterNames, Map sceneNames) { - if (nodes == null || nodes.isEmpty()) return null; + /** + * Nœuds narratifs (chapitres / scènes) traversés par la quête, en texte lisible. + * Les chapitres-CONTENEURS de la quete sont exclus : c'est de la plomberie (le jumeau + * porte le meme nom que la quete), pas un lien narratif. + */ + private String renderQuestNodes(QuestJpaEntity q, Structure st) { + if (q.getNodes() == null || q.getNodes().isEmpty()) return null; + Set containers = st.containerChapterIds(q); List parts = new ArrayList<>(); - for (QuestNodeRef n : nodes) { + for (QuestNodeRef n : q.getNodes()) { if (n.nodeType() == NodeType.SCENE) { - parts.add("Scène : " + sceneNames.getOrDefault(n.nodeId(), "?")); - } else { - parts.add("Chapitre : " + chapterNames.getOrDefault(n.nodeId(), "?")); + parts.add("Scène : " + st.sceneNames.getOrDefault(n.nodeId(), "?")); + } else if (!containers.contains(n.nodeId())) { + parts.add("Chapitre : " + st.chapterNames.getOrDefault(n.nodeId(), "?")); } } - return parts.isEmpty() ? null : String.join("\n", parts); + return joinAsList(parts); + } + + /** Plusieurs elements -> lignes "- ..." (rendues en vraies puces par parseBlocks). */ + private static String joinAsList(List parts) { + if (parts.isEmpty()) return null; + if (parts.size() == 1) return parts.get(0); + StringBuilder s = new StringBuilder(); + for (String p : parts) { + if (s.length() > 0) s.append('\n'); + s.append("- ").append(p); + } + return s.toString(); } // ----- PNJ / Ennemis (groupes par dossier) ----- @@ -350,11 +713,15 @@ public class PdfExportService { return out; } - private void personas(StringBuilder b, StringBuilder bm, String partId, String title, + private void personas(Ctx ctx, String partId, String title, List rows, List template, boolean enemy) { if (rows.isEmpty()) return; + if (enemy) ctx.enemies = rows.size(); + else ctx.npcs = rows.size(); + + StringBuilder b = ctx.body; b.append("

").append(esc(title)).append("

"); - bm.append(bookmark(title, partId, null)); + ctx.toc(0, title, partId); // Groupement par dossier (dossiers tries, non-classes en dernier). Map> byFolder = new TreeMap<>(); @@ -364,25 +731,39 @@ public class PdfExportService { if (f.isEmpty()) ungrouped.add(r); else byFolder.computeIfAbsent(f, k -> new ArrayList<>()).add(r); } + StringBuilder fBms = new StringBuilder(); + int fi = 0; for (Map.Entry> e : byFolder.entrySet()) { - b.append("

").append(esc(e.getKey().replace("/", " / "))).append("

"); + String anchor = partId + "-f" + (fi++); + String label = e.getKey().replace("/", " / "); + ctx.toc(1, label, anchor); + fBms.append(bookmark(label, anchor, "")); + b.append("

").append(esc(label)).append("

"); e.getValue().sort(java.util.Comparator.comparingInt(PersonaRow::order)); for (PersonaRow r : e.getValue()) personaCard(b, r, template, enemy); } if (!ungrouped.isEmpty()) { - if (!byFolder.isEmpty()) b.append("

Sans dossier

"); + if (!byFolder.isEmpty()) { + String anchor = partId + "-f" + fi; + ctx.toc(1, "Sans dossier", anchor); + fBms.append(bookmark("Sans dossier", anchor, "")); + b.append("

Sans dossier

"); + } ungrouped.sort(java.util.Comparator.comparingInt(PersonaRow::order)); for (PersonaRow r : ungrouped) personaCard(b, r, template, enemy); } + ctx.bookmarks.append(bookmark(title, partId, fBms.toString())); } private void personaCard(StringBuilder b, PersonaRow r, List template, boolean enemy) { // Mise en page en TABLE (portrait | contenu) : openhtmltopdf gere mal le float // + overflow (le texte se superposait au portrait). b.append("
"); - String portrait = imageUri(r.portraitId(), PORTRAIT_MAX); + PdfImage portrait = image(r.portraitId(), PORTRAIT_MAX); if (portrait != null) { - b.append(""); + double widthCm = Math.min(3.0, PORTRAIT_MAX_HEIGHT_CM * portrait.w() / (double) portrait.h()); + b.append(""); } b.append(""); - } - if (rows.length() > 0) { - b.append("
").append(esc(f.getName())) - .append("
"); b.append("
").append(esc(r.name())); @@ -395,8 +776,10 @@ public class PdfExportService { Map clean = cleanStats(r.foundryStats()); if (!clean.isEmpty()) { b.append("
Statistiques
"); + int row = 0; for (Map.Entry s : clean.entrySet()) { - b.append("" : "") + .append(""); } b.append("
").append(esc(s.getKey())).append("") + b.append(row++ % 2 == 1 ? "
").append(esc(s.getKey())).append("") .append(esc(s.getValue())).append("
"); @@ -439,9 +822,55 @@ public class PdfExportService { return s.isEmpty() ? key : Character.toUpperCase(s.charAt(0)) + s.substring(1); } + // ----- Tables aleatoires (le contenu le plus « papier » du livret) ----- + + private void randomTables(Ctx ctx, CampaignJpaEntity campaign) { + List tables = randomTableRepo.findByCampaignIdOrderByOrderAsc(campaign.getId()); + if (tables.isEmpty()) return; + ctx.randomTables = tables.size(); + + StringBuilder b = ctx.body; + b.append("

Tables aléatoires

"); + ctx.toc(0, "Tables aléatoires", "part-tables"); + StringBuilder tBms = new StringBuilder(); + for (RandomTableJpaEntity t : tables) tBms.append(bookmark(t.getName(), "rt-" + t.getId(), "")); + ctx.bookmarks.append(bookmark("Tables aléatoires", "part-tables", tBms.toString())); + + for (RandomTableJpaEntity t : tables) { + ctx.toc(1, t.getName(), "rt-" + t.getId()); + b.append("
"); + b.append("
").append(esc(t.getName())); + if (notBlank(t.getDiceFormula())) { + b.append(" ").append(esc(t.getDiceFormula())).append(""); + } + b.append("
"); + block(b, "Description", t.getDescription()); + if (!t.getEntries().isEmpty()) { + b.append(""); + int row = 0; + for (RandomTableEntryJpaEntity e : t.getEntries()) { + String roll = e.getMinRoll() == e.getMaxRoll() + ? String.valueOf(e.getMinRoll()) + : e.getMinRoll() + "–" + e.getMaxRoll(); + b.append(row++ % 2 == 1 ? "" : "") + .append(""); + } + b.append("
") + .append(esc(notBlank(t.getDiceFormula()) ? t.getDiceFormula() : "Jet")) + .append("Résultat
").append(esc(roll)).append("") + .append(esc(e.getLabel())); + if (notBlank(e.getDetail())) { + b.append("
").append(multiline(e.getDetail())).append(""); + } + b.append("
"); + } + b.append("
"); + } + } + // ----- Lore (pages groupees par dossier) ----- - private void lore(StringBuilder b, StringBuilder bm, CampaignJpaEntity campaign) { + private void lore(Ctx ctx, CampaignJpaEntity campaign) { String loreId = campaign.getLoreId(); if (loreId == null || loreId.isBlank()) return; Long lid; @@ -449,9 +878,11 @@ public class PdfExportService { List pages = pageRepo.findByLoreId(lid); if (pages.isEmpty()) return; + ctx.lorePages = pages.size(); + StringBuilder b = ctx.body; b.append("

Lore

"); - bm.append(bookmark("Lore", "part-lore", null)); + ctx.toc(0, "Lore", "part-lore"); // Chemins de dossiers (LoreNode) + templates par id. Map nodes = new HashMap<>(); @@ -463,9 +894,14 @@ public class PdfExportService { for (PageJpaEntity p : pages) { byPath.computeIfAbsent(nodePath(p.getNodeId(), nodes), k -> new ArrayList<>()).add(p); } + StringBuilder fBms = new StringBuilder(); + int fi = 0; for (Map.Entry> e : byPath.entrySet()) { String label = e.getKey().isEmpty() ? "Sans dossier" : e.getKey(); - b.append("

").append(esc(label)).append("

"); + String anchor = "lore-f" + (fi++); + ctx.toc(1, label, anchor); + fBms.append(bookmark(label, anchor, "")); + b.append("

").append(esc(label)).append("

"); e.getValue().sort(java.util.Comparator.comparingInt(PageJpaEntity::getOrder)); for (PageJpaEntity p : e.getValue()) { b.append("
") @@ -476,6 +912,7 @@ public class PdfExportService { b.append("
"); } } + ctx.bookmarks.append(bookmark("Lore", "part-lore", fBms.toString())); } private String nodePath(Long nodeId, Map nodes) { @@ -513,18 +950,8 @@ public class PdfExportService { case KEY_VALUE_LIST -> { Map inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null; List labels = f.getLabels(); - if (inner != null && labels != null) { - StringBuilder rows = new StringBuilder(); - for (String label : labels) { - String v = inner.get(label); - if (notBlank(v)) rows.append("
").append(esc(label)).append("") - .append(esc(v)).append("
").append(rows) - .append("
"); - } + if (inner != null && labels != null && labels.stream().anyMatch(l -> notBlank(inner.get(l)))) { + keyValueTable(b, f.getName(), labels, inner); } } case IMAGE -> { @@ -539,9 +966,10 @@ public class PdfExportService { .append("
"); for (String col : cols) b.append(""); b.append(""); - for (Map row : data) { - b.append(""); - for (String col : cols) b.append(""); + int row = 0; + for (Map line : data) { + b.append(row++ % 2 == 1 ? "" : ""); + for (String col : cols) b.append(""); b.append(""); } b.append("
").append(esc(col)).append("
").append(esc(row.get(col))).append("
").append(esc(line.get(col))).append("
"); @@ -551,26 +979,184 @@ public class PdfExportService { } } - /** Bloc "label + valeur multiligne" si la valeur est non vide. */ - private void block(StringBuilder b, String label, String value) { - if (!notBlank(value)) return; - b.append("
").append(esc(label)) - .append("
").append(multiline(value)).append("
"); + /** + * Liste cle/valeur : en TABLEAU HORIZONTAL compact quand cela ressemble a une ligne de + * caracteristiques (peu d'entrees, libelles et valeurs courts — FOR/DEX/CON...), + * sinon en tableau vertical classique a deux colonnes. + */ + private void keyValueTable(StringBuilder b, String name, List labels, Map inner) { + boolean compact = labels.size() >= 2 && labels.size() <= 8 + && labels.stream().allMatch(l -> l != null && l.length() <= 5) + && labels.stream().allMatch(l -> { + String v = inner.get(l); + return v == null || v.trim().length() <= 8; + }); + b.append("
").append(esc(name)).append("
"); + if (compact) { + b.append(""); + for (String label : labels) b.append(""); + b.append(""); + for (String label : labels) { + String v = inner.get(label); + b.append(""); + } + b.append("
").append(esc(label)).append("
").append(notBlank(v) ? esc(v) : "—").append("
"); + } else { + b.append(""); + int row = 0; + for (String label : labels) { + String v = inner.get(label); + if (!notBlank(v)) continue; + b.append(row++ % 2 == 1 ? "" : "") + .append(""); + } + b.append("
").append(esc(label)).append("") + .append(esc(v)).append("
"); + } } - /** Galerie d'illustrations (liste d'ids d'images) -> blocs image. */ + // ----- Bloc "libelle + texte" ----- + + /** Un paragraphe (lignes jointes par <br/>) ou une liste a puces. */ + private record TextBlock(boolean list, List lines) {} + + /** Un lieu/moment assez court pour la ligne de contexte de scene (sinon champ normal). */ + private static boolean isMetaShort(String s) { + return s == null || (!s.contains("\n") && !s.contains("\r") && s.trim().length() <= 90); + } + + /** + * ENCADRE special (codes visuels des livres de JdR) : "readaloud" = texte a lire aux + * joueurs (parchemin, filets or), "secret" = reserve au MJ (violet tirete), "combat" = + * rencontre (accent rouge). Libelle sur sa propre ligne, contenu en blocs. + */ + private void box(StringBuilder b, String cssClass, String label, String value) { + if (!notBlank(value)) return; + b.append("
"); + b.append("
").append(esc(label)).append("
"); + List blocks = parseBlocks(value); + for (int i = 0; i < blocks.size(); i++) { + TextBlock t = blocks.get(i); + if (t.list()) { + b.append("
    "); + for (String line : t.lines()) b.append("
  • ").append(esc(line)).append("
  • "); + b.append("
"); + } else if (i == 0) { + b.append(paragraphHtml(t)); + } else { + b.append("
").append(paragraphHtml(t)).append("
"); + } + } + b.append("
"); + } + + /** + * Bloc "libelle en tete de ligne + valeur" si la valeur est non vide. Le libelle est + * rendu EN LIGNE devant le premier paragraphe (style stat-block : compact et balayable), + * les paragraphes suivants et les listes a puces (lignes "- ...") en dessous. + */ + private void block(StringBuilder b, String label, String value) { + block(b, label, value, null); + } + + /** Variante avec classe CSS additionnelle sur le champ (ex : "ambiance" -> italique). */ + private void block(StringBuilder b, String label, String value, String extraClass) { + if (!notBlank(value)) return; + List blocks = parseBlocks(value); + b.append("
").append(esc(label)).append(""); + int i = 0; + if (!blocks.isEmpty() && !blocks.get(0).list()) { + b.append(paragraphHtml(blocks.get(0))); + i = 1; + } + for (; i < blocks.size(); i++) { + TextBlock t = blocks.get(i); + if (t.list()) { + b.append("
    "); + for (String line : t.lines()) b.append("
  • ").append(esc(line)).append("
  • "); + b.append("
"); + } else { + b.append("
").append(paragraphHtml(t)).append("
"); + } + } + b.append("
"); + } + + private static String paragraphHtml(TextBlock t) { + StringBuilder p = new StringBuilder(); + for (int i = 0; i < t.lines().size(); i++) { + if (i > 0) p.append("
"); + p.append(esc(t.lines().get(i))); + } + return p.toString(); + } + + /** + * Decoupe un texte brut en blocs : les lignes vides separent les paragraphes, les + * lignes commencant par "- ", "• " ou "* " deviennent de vraies listes a puces. + */ + private static List parseBlocks(String value) { + String norm = value.replace("\r\n", "\n").replace('\r', '\n'); + List blocks = new ArrayList<>(); + List cur = new ArrayList<>(); + boolean curList = false; + for (String line : norm.split("\n", -1)) { + String t = line.trim(); + if (t.isEmpty()) { + if (!cur.isEmpty()) blocks.add(new TextBlock(curList, cur)); + cur = new ArrayList<>(); + continue; + } + boolean bullet = t.startsWith("- ") || t.startsWith("• ") || t.startsWith("* "); + if (!cur.isEmpty() && bullet != curList) { + blocks.add(new TextBlock(curList, cur)); + cur = new ArrayList<>(); + } + curList = bullet; + cur.add(bullet ? t.substring(2).trim() : t); + } + if (!cur.isEmpty()) blocks.add(new TextBlock(curList, cur)); + return blocks; + } + + // ----- Illustrations ----- + + /** Galerie d'illustrations (liste d'ids d'images) -> blocs image centres, hauteur bornee. */ private void illustrations(StringBuilder b, List imageIds) { if (imageIds == null) return; for (String id : imageIds) { - String uri = imageUri(id, ILLUSTRATION_MAX); - if (uri != null) b.append("
"); + PdfImage img = image(id, ILLUSTRATION_MAX); + if (img != null) illustration(b, img, null); } } + /** + * Une illustration centree, avec legende optionnelle. La largeur est calculee pour que + * la hauteur affichee reste bornee : une image tres verticale ne mange plus la page. + */ + private void illustration(StringBuilder b, PdfImage img, String caption) { + double widthCm = Math.min(CONTENT_WIDTH_CM, + ILLUSTRATION_MAX_HEIGHT_CM * img.w() / (double) img.h()); + b.append("
"); + if (caption != null) { + b.append("
").append(esc(caption)).append("
"); + } + b.append("
"); + } + + private static String cm(double v) { + return String.format(Locale.ROOT, "%.1fcm", v); + } + // ====================================================================== Images - /** Data-URI JPEG redimensionne d'une image LoreMind, ou null. */ - private String imageUri(String imageId, int maxDim) { + /** Image re-encodee prete a inliner : data-URI + dimensions reelles (pour l'aspect). */ + private record PdfImage(String uri, int w, int h) {} + + /** Image LoreMind re-encodee (data-URI JPEG redimensionne), ou null. */ + private PdfImage image(String imageId, int maxDim) { if (imageId == null || imageId.isBlank()) return null; ImageJpaEntity e; try { @@ -582,8 +1168,8 @@ public class PdfExportService { return encode(imageStorage.download(e.getStorageKey()), maxDim, imageId); } - /** Data-URI d'une battlemap (fichier stocke) seulement si c'est une image (pas une video). */ - private String fileImageUri(String fileId) { + /** Battlemap (fichier stocke) re-encodee, seulement si c'est une image (pas une video). */ + private PdfImage fileImage(String fileId) { if (fileId == null || fileId.isBlank()) return null; StoredFileJpaEntity e; try { @@ -598,7 +1184,7 @@ public class PdfExportService { } /** Lit un flux image, le redimensionne (max maxDim) et le re-encode en data-URI JPEG. */ - private String encode(InputStream in, int maxDim, String ref) { + private PdfImage encode(InputStream in, int maxDim, String ref) { if (in == null) return null; try (in) { BufferedImage src = ImageIO.read(in); @@ -619,7 +1205,8 @@ public class PdfExportService { g.dispose(); ByteArrayOutputStream out = new ByteArrayOutputStream(); ImageIO.write(dst, "jpeg", out); - return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray()); + return new PdfImage("data:image/jpeg;base64," + Base64.getEncoder().encodeToString(out.toByteArray()), + nw, nh); } catch (IOException | RuntimeException ex) { log.warn("Image PDF ignoree ({}) : {}", ref, ex.getMessage()); return null; @@ -654,13 +1241,18 @@ public class PdfExportService { return s != null && !s.isBlank(); } - /** Un signet PDF (avec enfants optionnels fournis par `children`). */ - private static String bookmark(String name, String anchor, java.util.function.Supplier children) { - String inner = children != null ? children.get() : ""; - return "" + inner + ""; + /** Un signet PDF (childrenHtml : signets enfants deja rendus, ou chaine vide). */ + private static String bookmark(String name, String anchor, String childrenHtml) { + return "" + + childrenHtml + ""; } - /** Echappe le texte pour XHTML et retire les caracteres interdits en XML 1.0. */ + /** + * Echappe le texte pour XHTML et retire les caracteres interdits en XML 1.0. + * Les glyphes exotiques (fleches, ≈, cyrillique...) sont couverts par les polices + * DejaVu embarquees ; seuls les emojis (hors plan de base, absents de DejaVu) + * sont retires pour ne pas sortir en "#". + */ private static String esc(String s) { if (s == null) return ""; StringBuilder b = new StringBuilder(s.length() + 16); @@ -672,7 +1264,10 @@ public class PdfExportService { case '<' -> b.append("<"); case '>' -> b.append(">"); case '"' -> b.append("""); - default -> b.append(c); + case '\u202F', '\u2007' -> b.append('\u00A0'); // espaces fines (U+202F, U+2007) -> insecable + default -> { + if (!Character.isSurrogate(c)) b.append(c); // emojis : sans glyphe, on retire + } } } return b.toString(); @@ -684,51 +1279,125 @@ public class PdfExportService { return esc(s.replace("\r\n", "\n").replace('\r', '\n')).replace("\n", "
"); } + /** CSS final : en-tete courant renseigne avec le nom de la campagne. */ + private static String cssFor(String campaignName) { + String header = campaignName == null ? "" : campaignName.trim(); + if (header.length() > 70) header = header.substring(0, 69) + "…"; + return CSS.replace("__HEADER__", header.replace("\\", "\\\\").replace("'", "\\'")); + } + // CSS print (CSS 2.1 + paged media supporte par openhtmltopdf : pas de flexbox/grid). + // Polices de base PDF : serif (Times) pour le texte courant — plus lisible en long —, + // sans-serif (Helvetica) pour titres, libelles et tables. + // ATTENTION : ce CSS vit dans