diff --git a/.gitignore b/.gitignore index 57d6fd4..936d052 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,4 @@ installers/desktop/README.md web/coverage/ brain/htmlcov/ brain/.coverage +foundry-module/ diff --git a/README.fr.md b/README.fr.md index cf9fe2b..4ce11ec 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ -# LoreMind +# DM Loremind [English](README.md) · **Français** @@ -10,33 +10,45 @@ [![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation) [![Discord](https://img.shields.io/badge/Discord-rejoindre-5865F2)](https://discord.gg/cPpFzCjEzQ) -## Découvrir LoreMind en vidéo +## Découvrir DM Loremind en vidéo -[![Présentation LoreMind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) +[![Présentation DM Loremind](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) ## Ce que ça fait -LoreMind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu. +DM Loremind regroupe ce qu'un MJ utilise habituellement éparpillé entre plusieurs outils. L'application s'articule autour de trois modules principaux, augmentés par un assistant IA qui exploite tout votre contenu. ### Lore Construire votre univers avec une arborescence de pages templatées : lieux, factions, PNJ, événements, organisations... Chaque type de page suit un template configurable, ce qui garantit la cohérence et facilite la navigation dans des univers riches. -### Game System +### Système de JDR Stocker les règles de votre système de jeu (D&D, Nimble, créations maison...) et définir les modèles de fiches de personnages associés. Les règles indexées peuvent être injectées dans le contexte de l'IA pour des réponses fidèles à votre système. -### Campaign +### Campagne -Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du game system retenu. +Structurer vos campagnes en Arcs → Chapitres → Scènes avec séparation claire du contenu MJ et du contenu joueurs. Gérer les PJ et PNJ via des fiches dynamiques basées sur les templates du système de JDR retenu. ### Assistant IA Un assistant contextuel qui pioche dans votre Lore, vos règles et vos campagnes pour répondre à vos questions, suggérer du contenu cohérent, ou rebondir sur une situation improvisée en table. -L'IA s'exécute **en local via [Ollama](https://ollama.com/)** ou via **[1min.ai](https://1min.ai/)**. D'autres moteurs seront supportés à l'avenir. +L'IA s'exécute **en local via [Ollama](https://ollama.com/)** — vos données ne quittent jamais votre machine — ou dans le **cloud** avec votre propre clé API via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) ou [OpenRouter](https://openrouter.ai/). + +## Démarrage rapide + +**Bureau (le plus simple)** — récupérez le dernier installeur sur la [page Releases](https://github.com/IGMLcreation/LoreMind/releases) : Windows `.msi` ou Linux `.AppImage`, puis lancez-le. Vos données restent en local. + +**Auto-hébergement Docker** — sous Linux : + +```bash +curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash +``` + +Sous Windows, suivez le [guide d'installation](https://loremind-docs.igmlcreation.fr/). ## Documentation @@ -49,20 +61,20 @@ Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation. Quelques limites à connaître : - 10 utilisateurs maximum simultanés (instances isolées) - Session limitée à 20 minutes avant réinitialisation -- Partie IA non incluse dans la démo (nécessite Ollama ou 1min.ai côté serveur) +- Fonctions IA non incluses dans la démo (elles nécessitent un fournisseur d'IA — Ollama en local ou une clé API cloud — configuré côté serveur) ## Soutenir le projet -LoreMind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : +DM Loremind est **et restera gratuit en auto-hébergement**. Le développement avance plus vite avec votre soutien : - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — accès anticipé aux features, vote sur la roadmap, devlogs exclusifs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs ## Licence -LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. +DM Loremind est distribué sous licence **[GNU AGPL v3](LICENSE)**. En pratique : - Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - Si vous modifiez le code et que vous exposez l'application modifiée sur un réseau (même en SaaS privé), vous devez rendre vos modifications publiques sous la même licence. -- Les univers (Lore) et campagnes que vous créez avec LoreMind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application. +- Les univers (Lore) et campagnes que vous créez avec DM Loremind **vous appartiennent entièrement** — la licence ne couvre que le code de l'application. diff --git a/README.md b/README.md index ca73f92..10c2cee 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# LoreMind +# DM Loremind **English** · [Français](README.fr.md) @@ -10,15 +10,15 @@ [![Patreon](https://img.shields.io/badge/Patreon-support-red)](https://www.patreon.com/c/IGMLCreation) [![Discord](https://img.shields.io/badge/Discord-join-5865F2)](https://discord.gg/cPpFzCjEzQ) -## See LoreMind in action +## See DM Loremind in action -[![LoreMind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) +[![DM Loremind overview](https://img.youtube.com/vi/llJkmlotbB8/maxresdefault.jpg)](https://www.youtube.com/watch?v=llJkmlotbB8) ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png) ## What it does -LoreMind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content. +DM Loremind brings together what a game master usually scatters across several tools. The app is built around three core modules, augmented by an AI assistant that draws on all of your content. ### Lore @@ -36,7 +36,19 @@ Structure your campaigns as Arcs → Chapters → Scenes, with a clear split bet A context-aware assistant that pulls from your Lore, rules and campaigns to answer your questions, suggest consistent content, or improvise around an unexpected situation at the table. -The AI runs **locally via [Ollama](https://ollama.com/)** or via **[1min.ai](https://1min.ai/)**. More engines will be supported in the future. +The AI runs **locally via [Ollama](https://ollama.com/)** — your data stays on your machine — or in the **cloud** with your own API key via [1min.ai](https://1min.ai/), [Mistral](https://mistral.ai/), [Google AI Studio (Gemini)](https://aistudio.google.com/) or [OpenRouter](https://openrouter.ai/). + +## Quick start + +**Desktop (easiest)** — grab the latest installer from the [Releases page](https://github.com/IGMLcreation/LoreMind/releases): Windows `.msi` or Linux `.AppImage`, then run it. Your data stays local. + +**Self-host with Docker** — on Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash +``` + +On Windows, follow the [installation guide](https://loremind-docs.igmlcreation.fr/en/). ## Documentation @@ -49,20 +61,20 @@ A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremi A few limitations to be aware of: - 10 concurrent users maximum (isolated instances) - Sessions limited to 20 minutes before reset -- The AI part is not included in the demo (requires Ollama or 1min.ai server-side) +- The AI features are not included in the demo (they require an AI provider — local Ollama or a cloud API key — configured server-side) ## Support the project -LoreMind is **and will remain free when self-hosted**. Development moves faster with your support: +DM Loremind is **and will remain free when self-hosted**. Development moves faster with your support: - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback ## License -LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license. +DM Loremind is distributed under the **[GNU AGPL v3](LICENSE)** license. In practice: - You can use it for free, host it, modify it, and redistribute it. - If you modify the code and expose the modified app over a network (even as a private SaaS), you must make your changes public under the same license. -- The worlds (Lore) and campaigns you create with LoreMind **belong entirely to you** — the license only covers the application's code. +- The worlds (Lore) and campaigns you create with DM Loremind **belong entirely to you** — the license only covers the application's code. diff --git a/core/src/main/java/com/loremind/application/files/StoredFileService.java b/core/src/main/java/com/loremind/application/files/StoredFileService.java new file mode 100644 index 0000000..6643865 --- /dev/null +++ b/core/src/main/java/com/loremind/application/files/StoredFileService.java @@ -0,0 +1,118 @@ +package com.loremind.application.files; + +import com.loremind.domain.files.StoredFile; +import com.loremind.domain.files.ports.FileStorage; +import com.loremind.domain.files.ports.StoredFileRepository; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +/** + * Service d'application pour les fichiers generiques (port {@link FileStorage} + * + {@link StoredFileRepository}). Pendant de + * {@link com.loremind.application.images.ImageService}, mais SANS la contrainte + * "image" : accepte aussi video et sidecars JSON (battlemaps Universal VTT). + *

+ * Validation MIME volontairement permissive : l'appli est mono-utilisateur + * (bureau / self-hosted), le risque "upload piege" est faible, et les outils de + * cartes (Dungeon Alchemist...) servent parfois le sidecar {@code .dd2vtt} sans + * type MIME fiable. + */ +@Service +public class StoredFileService { + + /** Types MIME explicitement autorises (en plus de tout {@code image/*} et {@code video/*}). */ + private static final Set ALLOWED_EXACT_MIME = Set.of( + "application/json", + "application/octet-stream", + "text/plain" + ); + + /** Coherent avec spring.servlet.multipart.max-file-size (application.properties). */ + private static final long MAX_SIZE_BYTES = 128L * 1024 * 1024; // 128 Mo + + private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; + + private final StoredFileRepository repository; + private final FileStorage storage; + + public StoredFileService(StoredFileRepository repository, FileStorage storage) { + this.repository = repository; + this.storage = storage; + } + + /** + * Use case upload : valide -> envoie le binaire -> persiste les metadonnees. + * En cas d'echec DB apres ecriture du binaire, compense (supprime l'orphelin). + */ + public StoredFile upload(String filename, String contentType, InputStream data, long sizeBytes) { + String resolvedType = resolveContentType(contentType); + validateUpload(filename, resolvedType, sizeBytes); + + String storageKey = storage.upload(filename, resolvedType, data, sizeBytes); + + try { + StoredFile file = StoredFile.builder() + .filename(filename) + .contentType(resolvedType) + .sizeBytes(sizeBytes) + .storageKey(storageKey) + .uploadedAt(LocalDateTime.now()) + .build(); + return repository.save(file); + } catch (RuntimeException ex) { + storage.delete(storageKey); + throw ex; + } + } + + public Optional getById(String id) { + return repository.findById(id); + } + + public Optional downloadById(String id) { + return repository.findById(id) + .map(f -> storage.download(f.getStorageKey())); + } + + public void deleteById(String id) { + repository.findById(id).ifPresent(f -> { + storage.delete(f.getStorageKey()); + repository.deleteById(id); + }); + } + + // --- Validation -------------------------------------------------------- + + private String resolveContentType(String contentType) { + if (contentType == null || contentType.isBlank()) { + return DEFAULT_CONTENT_TYPE; + } + return contentType.toLowerCase(); + } + + private void validateUpload(String filename, String contentType, long sizeBytes) { + if (filename == null || filename.isBlank()) { + throw new IllegalArgumentException("Le nom du fichier est requis."); + } + if (!isAllowedMime(contentType)) { + throw new IllegalArgumentException("Type de fichier non supporte : " + contentType); + } + if (sizeBytes <= 0) { + throw new IllegalArgumentException("Le fichier est vide."); + } + if (sizeBytes > MAX_SIZE_BYTES) { + throw new IllegalArgumentException( + "Fichier trop volumineux (max " + (MAX_SIZE_BYTES / 1024 / 1024) + " Mo)."); + } + } + + private boolean isAllowedMime(String contentType) { + return contentType.startsWith("image/") + || contentType.startsWith("video/") + || ALLOWED_EXACT_MIME.contains(contentType); + } +} diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java index ef1a6c7..7188067 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Arc.java @@ -55,12 +55,6 @@ public class Arc { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - /** - * IDs des images utilisees comme cartes / plans (outil de table). - */ - @Builder.Default - private List mapImageIds = new ArrayList<>(); - private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java index 7eb70a9..9397ce9 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Chapter.java @@ -50,12 +50,6 @@ public class Chapter { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - /** - * IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table). - */ - @Builder.Default - private List mapImageIds = new ArrayList<>(); - private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java index 4187298..9b9419d 100644 --- a/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java +++ b/core/src/main/java/com/loremind/domain/campaigncontext/Scene.java @@ -65,12 +65,16 @@ public class Scene { private List illustrationImageIds = new ArrayList<>(); /** - * IDs des images utilisees comme cartes / plans. - * Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique. - * Rendu different des illustrations : vignettes plus grandes, ratio natif preserve. + * "Battlemap" de la scene destinee a l'export Foundry : paire { media + sidecar }. + * Le media est un fichier image ou video ({@link com.loremind.domain.files.StoredFile}), + * le sidecar le .json/.dd2vtt Universal VTT (grille, murs, portes, lumieres) sorti + * de Dungeon Alchemist / Dungeondraft. Non affichee dans l'appli : passee telle quelle + * a l'export pour recreer la Scene cote Foundry. Null = scene sans carte. */ - @Builder.Default - private List mapImageIds = new ArrayList<>(); + private String battlemapMediaFileId; + + /** ID du fichier sidecar Universal VTT (json) associe au media. Null si absent. */ + private String battlemapDataFileId; /** * Sorties narratives possibles depuis cette scène (graphe intra-chapitre). diff --git a/core/src/main/java/com/loremind/domain/files/StoredFile.java b/core/src/main/java/com/loremind/domain/files/StoredFile.java new file mode 100644 index 0000000..43dab58 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/files/StoredFile.java @@ -0,0 +1,44 @@ +package com.loremind.domain.files; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * Entite de domaine representant un FICHIER generique uploade par l'utilisateur. + *

+ * Pendant de {@link com.loremind.domain.images.Image}, mais SANS la contrainte + * "image" : un StoredFile peut etre une video, un JSON, etc. Sert notamment aux + * "battlemaps" de scene (paire media + sidecar JSON Universal VTT) destinees a + * l'export Foundry, qui ne sont ni des images (mp4) ni limitees a 10 Mo. + *

+ * Meme design que Image : + * - Metadata en DB relationnelle (table {@code stored_files}) + * - Binaire sur object storage (MinIO/S3) ou filesystem, reference par {@code storageKey} + * - Le domaine ne connait que la cle opaque. + *

+ * Architecture Hexagonale : entite pure, aucune dependance technique. + */ +@Data +@Builder +public class StoredFile { + + /** Identifiant stable (String pour rester agnostique vis-a-vis du stockage). */ + private String id; + + /** Nom original du fichier uploade (ex: "cellier.dd2vtt", "donjon.mp4"). */ + private String filename; + + /** Type MIME (ex: "video/mp4", "application/json", "image/png"). */ + private String contentType; + + /** Taille en octets. */ + private long sizeBytes; + + /** Cle opaque dans le stockage (ex: "files/abc123.mp4"). */ + private String storageKey; + + /** Horodatage de l'upload initial (le fichier est immuable apres creation). */ + private LocalDateTime uploadedAt; +} diff --git a/core/src/main/java/com/loremind/domain/files/ports/FileStorage.java b/core/src/main/java/com/loremind/domain/files/ports/FileStorage.java new file mode 100644 index 0000000..a343488 --- /dev/null +++ b/core/src/main/java/com/loremind/domain/files/ports/FileStorage.java @@ -0,0 +1,34 @@ +package com.loremind.domain.files.ports; + +import java.io.InputStream; + +/** + * Port de sortie pour le stockage du BINAIRE des fichiers generiques. + *

+ * Pendant de {@link com.loremind.domain.images.ports.ImageStorage}, mais pour + * des fichiers non-images (video, JSON...). Separe du port image pour que les + * deux familles aient des regles (MIME, taille, prefixe de cle) independantes. + *

+ * Les implementations (MinIO, filesystem) traduisent la cle opaque selon leur + * logique physique. Convention de cle : prefixe {@code files/}. + */ +public interface FileStorage { + + /** + * Envoie un flux binaire et retourne la cle generee (prefixe {@code files/}). + */ + String upload(String filename, String contentType, InputStream data, long sizeBytes); + + /** + * Stocke un flux binaire SOUS UNE CLE IMPOSEE (pas de generation). Utilise + * par l'import de contenu pour reinjecter un fichier sous sa cle d'origine. + * Ecrase si la cle existe deja. + */ + void store(String storageKey, String contentType, InputStream data, long sizeBytes); + + /** Recupere le flux binaire associe a une cle, ou null si inexistante. */ + InputStream download(String storageKey); + + /** Supprime le binaire. No-op silencieux si la cle n'existe pas. */ + void delete(String storageKey); +} diff --git a/core/src/main/java/com/loremind/domain/files/ports/StoredFileRepository.java b/core/src/main/java/com/loremind/domain/files/ports/StoredFileRepository.java new file mode 100644 index 0000000..ef6d15e --- /dev/null +++ b/core/src/main/java/com/loremind/domain/files/ports/StoredFileRepository.java @@ -0,0 +1,24 @@ +package com.loremind.domain.files.ports; + +import com.loremind.domain.files.StoredFile; + +import java.util.Optional; + +/** + * Port de sortie pour la persistance des metadonnees de fichiers generiques. + *

+ * Pendant de {@link com.loremind.domain.images.ports.ImageRepository}. Ne + * manipule QUE les metadonnees ; le binaire est gere par {@link FileStorage}. + */ +public interface StoredFileRepository { + + StoredFile save(StoredFile file); + + Optional findById(String id); + + Optional findByStorageKey(String storageKey); + + void deleteById(String id); + + boolean existsById(String id); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java index 2d6f82b..70ed888 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ArcJpaEntity.java @@ -86,12 +86,6 @@ public class ArcJpaEntity { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - /** IDs des images "cartes / plans". */ - @Column(name = "map_image_ids", columnDefinition = "TEXT") - @Convert(converter = StringListJsonConverter.class) - @Builder.Default - private List mapImageIds = new ArrayList<>(); - @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java index 6138f43..bf778bd 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/ChapterJpaEntity.java @@ -72,11 +72,6 @@ public class ChapterJpaEntity { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - @Column(name = "map_image_ids", columnDefinition = "TEXT") - @Convert(converter = StringListJsonConverter.class) - @Builder.Default - private List mapImageIds = new ArrayList<>(); - @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java index 8733609..bbedcd8 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/SceneJpaEntity.java @@ -91,10 +91,13 @@ public class SceneJpaEntity { @Builder.Default private List illustrationImageIds = new ArrayList<>(); - @Column(name = "map_image_ids", columnDefinition = "TEXT") - @Convert(converter = StringListJsonConverter.class) - @Builder.Default - private List mapImageIds = new ArrayList<>(); + /** Battlemap Foundry : fichier media (image/video). Null = pas de carte. */ + @Column(name = "battlemap_media_file_id") + private String battlemapMediaFileId; + + /** Battlemap Foundry : fichier sidecar Universal VTT (json/dd2vtt). Null si absent. */ + @Column(name = "battlemap_data_file_id") + private String battlemapDataFileId; // Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes. // Persisté en TEXT JSON via converter (pattern homogène avec les autres listes). diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java b/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java new file mode 100644 index 0000000..d9431d9 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/entity/StoredFileJpaEntity.java @@ -0,0 +1,50 @@ +package com.loremind.infrastructure.persistence.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Entite JPA pour les metadonnees de fichiers generiques en PostgreSQL. + * Le binaire est stocke cote MinIO/filesystem (reference par storage_key). + * Pendant de {@link ImageJpaEntity} pour les fichiers non-images. + */ +@Entity +@Table(name = "stored_files") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class StoredFileJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String filename; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "size_bytes", nullable = false) + private long sizeBytes; + + /** Cle opaque dans le stockage, unique. */ + @Column(name = "storage_key", nullable = false, unique = true) + private String storageKey; + + @Column(name = "uploaded_at", nullable = false, updatable = false) + private LocalDateTime uploadedAt; + + @PrePersist + protected void onCreate() { + if (uploadedAt == null) { + uploadedAt = LocalDateTime.now(); + } + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/StoredFileJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/StoredFileJpaRepository.java new file mode 100644 index 0000000..6f215b2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/StoredFileJpaRepository.java @@ -0,0 +1,17 @@ +package com.loremind.infrastructure.persistence.jpa; + +import com.loremind.infrastructure.persistence.entity.StoredFileJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Repository Spring Data JPA pour StoredFileJpaEntity. + */ +@Repository +public interface StoredFileJpaRepository extends JpaRepository { + + /** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */ + Optional findByStorageKey(String storageKey); +} diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java index cff458e..162ce03 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepository.java @@ -85,9 +85,6 @@ public class PostgresArcRepository implements ArcRepository { .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null ? new ArrayList<>(jpaEntity.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(jpaEntity.getMapImageIds() != null - ? new ArrayList<>(jpaEntity.getMapImageIds()) - : new ArrayList<>()) .createdAt(jpaEntity.getCreatedAt()) .updatedAt(jpaEntity.getUpdatedAt()) .build(); @@ -114,9 +111,6 @@ public class PostgresArcRepository implements ArcRepository { .illustrationImageIds(arc.getIllustrationImageIds() != null ? new ArrayList<>(arc.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(arc.getMapImageIds() != null - ? new ArrayList<>(arc.getMapImageIds()) - : new ArrayList<>()) .createdAt(arc.getCreatedAt()) .updatedAt(arc.getUpdatedAt()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java index 4263ee6..7572e9f 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepository.java @@ -84,9 +84,6 @@ public class PostgresChapterRepository implements ChapterRepository { .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null ? new ArrayList<>(jpaEntity.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(jpaEntity.getMapImageIds() != null - ? new ArrayList<>(jpaEntity.getMapImageIds()) - : new ArrayList<>()) .createdAt(jpaEntity.getCreatedAt()) .updatedAt(jpaEntity.getUpdatedAt()) .build(); @@ -113,9 +110,6 @@ public class PostgresChapterRepository implements ChapterRepository { .illustrationImageIds(chapter.getIllustrationImageIds() != null ? new ArrayList<>(chapter.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(chapter.getMapImageIds() != null - ? new ArrayList<>(chapter.getMapImageIds()) - : new ArrayList<>()) .createdAt(chapter.getCreatedAt()) .updatedAt(chapter.getUpdatedAt()) .build(); diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java index 84bb9b7..5514585 100644 --- a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepository.java @@ -89,9 +89,8 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null ? new ArrayList<>(jpaEntity.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(jpaEntity.getMapImageIds() != null - ? new ArrayList<>(jpaEntity.getMapImageIds()) - : new ArrayList<>()) + .battlemapMediaFileId(jpaEntity.getBattlemapMediaFileId()) + .battlemapDataFileId(jpaEntity.getBattlemapDataFileId()) .branches(jpaEntity.getBranches() != null ? new ArrayList<>(jpaEntity.getBranches()) : new ArrayList<>()) @@ -129,9 +128,8 @@ public class PostgresSceneRepository implements SceneRepository { .illustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(scene.getMapImageIds() != null - ? new ArrayList<>(scene.getMapImageIds()) - : new ArrayList<>()) + .battlemapMediaFileId(scene.getBattlemapMediaFileId()) + .battlemapDataFileId(scene.getBattlemapDataFileId()) .branches(scene.getBranches() != null ? new ArrayList<>(scene.getBranches()) : new ArrayList<>()) diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepository.java new file mode 100644 index 0000000..14acd8a --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepository.java @@ -0,0 +1,74 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.files.StoredFile; +import com.loremind.domain.files.ports.StoredFileRepository; +import com.loremind.infrastructure.persistence.entity.StoredFileJpaEntity; +import com.loremind.infrastructure.persistence.jpa.StoredFileJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Adaptateur de sortie : implemente le port StoredFileRepository du domaine. + * Fait la traduction StoredFile (domaine) <-> StoredFileJpaEntity (JPA). + */ +@Repository +public class PostgresStoredFileRepository implements StoredFileRepository { + + private final StoredFileJpaRepository jpaRepository; + + public PostgresStoredFileRepository(StoredFileJpaRepository jpaRepository) { + this.jpaRepository = jpaRepository; + } + + @Override + public StoredFile save(StoredFile file) { + StoredFileJpaEntity saved = jpaRepository.save(toJpa(file)); + return toDomain(saved); + } + + @Override + public Optional findById(String id) { + return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain); + } + + @Override + public Optional findByStorageKey(String storageKey) { + return jpaRepository.findByStorageKey(storageKey).map(this::toDomain); + } + + @Override + public void deleteById(String id) { + jpaRepository.deleteById(Long.parseLong(id)); + } + + @Override + public boolean existsById(String id) { + return jpaRepository.existsById(Long.parseLong(id)); + } + + // --- Conversions ------------------------------------------------------- + + private StoredFile toDomain(StoredFileJpaEntity e) { + return StoredFile.builder() + .id(e.getId().toString()) + .filename(e.getFilename()) + .contentType(e.getContentType()) + .sizeBytes(e.getSizeBytes()) + .storageKey(e.getStorageKey()) + .uploadedAt(e.getUploadedAt()) + .build(); + } + + private StoredFileJpaEntity toJpa(StoredFile f) { + Long id = f.getId() != null ? Long.parseLong(f.getId()) : null; + return StoredFileJpaEntity.builder() + .id(id) + .filename(f.getFilename()) + .contentType(f.getContentType()) + .sizeBytes(f.getSizeBytes()) + .storageKey(f.getStorageKey()) + .uploadedAt(f.getUploadedAt()) + .build(); + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/storage/FilesystemFileStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemFileStorageAdapter.java new file mode 100644 index 0000000..2c8ba8f --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemFileStorageAdapter.java @@ -0,0 +1,112 @@ +package com.loremind.infrastructure.storage; + +import com.loremind.domain.files.ports.FileStorage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * Adaptateur filesystem pour le port {@link FileStorage} (fichiers generiques). + *

+ * Pendant de {@link FilesystemImageStorageAdapter} : meme racine de stockage + * ({@code storage.filesystem.path}), mais prefixe de cle {@code files/} pour + * coexister sans collision avec les images. Active en mode local-first + * ({@code storage.backend=filesystem}). + */ +@Component +@ConditionalOnProperty(name = "storage.backend", havingValue = "filesystem") +public class FilesystemFileStorageAdapter implements FileStorage { + + private final Path root; + + public FilesystemFileStorageAdapter(@Value("${storage.filesystem.path}") String basePath) { + this.root = Path.of(basePath).toAbsolutePath().normalize(); + } + + @PostConstruct + void ensureRootExists() { + try { + Files.createDirectories(root.resolve("files")); + System.out.println("[Storage] Backend filesystem (fichiers) actif — racine : " + root); + } catch (IOException e) { + throw new UncheckedIOException("Impossible de creer le dossier de stockage : " + root, e); + } + } + + @Override + public String upload(String filename, String contentType, InputStream data, long sizeBytes) { + String storageKey = generateStorageKey(filename); + Path target = resolveKey(storageKey); + try { + Files.createDirectories(target.getParent()); + Files.copy(data, target); + return storageKey; + } catch (IOException e) { + throw new UncheckedIOException("Echec de l'ecriture du fichier sur disque : " + target, e); + } + } + + @Override + public void store(String storageKey, String contentType, InputStream data, long sizeBytes) { + Path target = resolveKey(storageKey); + try { + Files.createDirectories(target.getParent()); + Files.copy(data, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("Echec de l'ecriture du fichier sur disque (cle imposee) : " + target, e); + } + } + + @Override + public InputStream download(String storageKey) { + Path source = resolveKey(storageKey); + if (!Files.exists(source)) { + return null; + } + try { + return Files.newInputStream(source); + } catch (IOException e) { + throw new UncheckedIOException("Echec de la lecture du fichier : " + source, e); + } + } + + @Override + public void delete(String storageKey) { + try { + Files.deleteIfExists(resolveKey(storageKey)); + } catch (IOException e) { + System.err.println("[Storage] Erreur suppression fichier (non bloquante) : " + e.getMessage()); + } + } + + /** Resout une cle opaque en chemin physique, en bloquant la traversee de repertoire. */ + private Path resolveKey(String storageKey) { + Path resolved = root.resolve(storageKey).normalize(); + if (!resolved.startsWith(root)) { + throw new IllegalArgumentException("Cle de stockage invalide (hors racine) : " + storageKey); + } + return resolved; + } + + private String generateStorageKey(String originalFilename) { + return "files/" + UUID.randomUUID() + extractExtension(originalFilename); + } + + /** Extensions acceptees : images + video + sidecars Universal VTT (json/dd2vtt/uvtt). */ + private String extractExtension(String filename) { + if (filename == null) return ""; + int dot = filename.lastIndexOf('.'); + if (dot < 0 || dot == filename.length() - 1) return ""; + String ext = filename.substring(dot).toLowerCase(); + return ext.matches("\\.(jpg|jpeg|png|webp|gif|mp4|webm|json|dd2vtt|uvtt)") ? ext : ""; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/storage/MinioFileStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/MinioFileStorageAdapter.java new file mode 100644 index 0000000..1511b87 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/storage/MinioFileStorageAdapter.java @@ -0,0 +1,108 @@ +package com.loremind.infrastructure.storage; + +import com.loremind.domain.files.ports.FileStorage; +import io.minio.GetObjectArgs; +import io.minio.MinioClient; +import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; +import io.minio.errors.ErrorResponseException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.util.UUID; + +/** + * Adaptateur MinIO pour le port {@link FileStorage} (fichiers generiques). + *

+ * Pendant de {@link MinioImageStorageAdapter} : reutilise le meme bucket, mais + * prefixe de cle {@code files/} pour coexister sans collision avec les images. + * Backend par defaut ({@code storage.backend=minio} ou propriete absente). + */ +@Component +@ConditionalOnProperty(name = "storage.backend", havingValue = "minio", matchIfMissing = true) +public class MinioFileStorageAdapter implements FileStorage { + + private final MinioClient minioClient; + private final String bucket; + + public MinioFileStorageAdapter(MinioClient minioClient, + @Value("${minio.bucket}") String bucket) { + this.minioClient = minioClient; + this.bucket = bucket; + } + + @Override + public String upload(String filename, String contentType, InputStream data, long sizeBytes) { + String storageKey = generateStorageKey(filename); + try { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucket) + .object(storageKey) + .stream(data, sizeBytes, -1) + .contentType(contentType != null ? contentType : "application/octet-stream") + .build() + ); + return storageKey; + } catch (Exception e) { + throw new RuntimeException("Echec de l'upload du fichier vers MinIO : " + e.getMessage(), e); + } + } + + @Override + public void store(String storageKey, String contentType, InputStream data, long sizeBytes) { + try { + minioClient.putObject( + PutObjectArgs.builder() + .bucket(bucket) + .object(storageKey) + .stream(data, sizeBytes, -1) + .contentType(contentType != null ? contentType : "application/octet-stream") + .build() + ); + } catch (Exception e) { + throw new RuntimeException("Echec du store MinIO (cle imposee) : " + e.getMessage(), e); + } + } + + @Override + public InputStream download(String storageKey) { + try { + return minioClient.getObject( + GetObjectArgs.builder().bucket(bucket).object(storageKey).build() + ); + } catch (ErrorResponseException e) { + if ("NoSuchKey".equals(e.errorResponse().code())) { + return null; + } + throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Echec du download MinIO : " + e.getMessage(), e); + } + } + + @Override + public void delete(String storageKey) { + try { + minioClient.removeObject( + RemoveObjectArgs.builder().bucket(bucket).object(storageKey).build() + ); + } catch (Exception e) { + System.err.println("[MinIO] Erreur suppression fichier (non bloquante) : " + e.getMessage()); + } + } + + private String generateStorageKey(String originalFilename) { + return "files/" + UUID.randomUUID() + extractExtension(originalFilename); + } + + private String extractExtension(String filename) { + if (filename == null) return ""; + int dot = filename.lastIndexOf('.'); + if (dot < 0 || dot == filename.length() - 1) return ""; + String ext = filename.substring(dot).toLowerCase(); + return ext.matches("\\.(jpg|jpeg|png|webp|gif|mp4|webm|json|dd2vtt|uvtt)") ? ext : ""; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java index f64035a..45401f3 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java @@ -2,6 +2,7 @@ package com.loremind.infrastructure.transfer; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.loremind.domain.files.ports.FileStorage; import com.loremind.domain.images.ports.ImageStorage; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.entity.*; @@ -60,6 +61,8 @@ public class ExportService { private final RandomTableJpaRepository randomTableRepo; private final ImageJpaRepository imageRepo; private final ImageStorage imageStorage; + private final StoredFileJpaRepository storedFileRepo; + private final FileStorage fileStorage; private final ObjectMapper objectMapper; private final String appVersion; @@ -79,6 +82,8 @@ public class ExportService { RandomTableJpaRepository randomTableRepo, ImageJpaRepository imageRepo, ImageStorage imageStorage, + StoredFileJpaRepository storedFileRepo, + FileStorage fileStorage, ObjectMapper objectMapper, @Nullable BuildProperties buildProperties) { this.gameSystemRepo = gameSystemRepo; @@ -97,6 +102,8 @@ public class ExportService { this.randomTableRepo = randomTableRepo; this.imageRepo = imageRepo; this.imageStorage = imageStorage; + this.storedFileRepo = storedFileRepo; + this.fileStorage = fileStorage; this.objectMapper = objectMapper; this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; } @@ -140,10 +147,12 @@ public class ExportService { .map(this::toRandomTableDto).toList(); List images = imageRepo.findAll().stream() .map(this::toImageDto).toList(); + List storedFiles = storedFileRepo.findAll().stream() + .map(this::toStoredFileDto).toList(); return new ContentExport(manifest, gameSystems, lores, loreNodes, templates, pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies, - itemCatalogs, randomTables, images); + itemCatalogs, randomTables, images, storedFiles); } /** @@ -184,6 +193,24 @@ public class ExportService { zip.closeEntry(); } } + + // Binaires fichiers (battlemaps : media + sidecar) : ceux references par + // les scenes. Stockes a part sous "files/". + Set referencedFiles = collectReferencedFileStorageKeys(export); + Set filesWritten = new LinkedHashSet<>(); + for (String key : referencedFiles) { + if (key == null || key.isBlank() || !filesWritten.add(key)) { + continue; + } + try (InputStream data = fileStorage.download(key)) { + if (data == null) { + continue; // cle orpheline : on ignore silencieusement + } + zip.putNextEntry(new ZipEntry("files/" + key)); + data.transferTo(zip); + zip.closeEntry(); + } + } } catch (IOException e) { throw new UncheckedIOException("Echec de la generation du zip d'export", e); } @@ -198,15 +225,12 @@ public class ExportService { Set keys = new LinkedHashSet<>(); for (ContentExport.ArcDto a : export.arcs()) { addAll(keys, a.illustrationImageIds()); - addAll(keys, a.mapImageIds()); } for (ContentExport.ChapterDto c : export.chapters()) { addAll(keys, c.illustrationImageIds()); - addAll(keys, c.mapImageIds()); } for (ContentExport.SceneDto s : export.scenes()) { addAll(keys, s.illustrationImageIds()); - addAll(keys, s.mapImageIds()); } for (ContentExport.CharacterDto c : export.characters()) { add(keys, c.portraitImageId()); @@ -229,6 +253,29 @@ public class ExportService { return keys; } + /** + * Collecte les storageKeys des fichiers (battlemaps) references par les scenes. + * Resout l'ID StoredFile porte par la scene -> storageKey via l'index storedFiles. + */ + private Set collectReferencedFileStorageKeys(ContentExport export) { + java.util.Map keyById = new java.util.HashMap<>(); + for (ContentExport.StoredFileDto f : export.storedFiles()) { + if (f.id() != null) keyById.put(f.id().toString(), f.storageKey()); + } + Set keys = new LinkedHashSet<>(); + for (ContentExport.SceneDto s : export.scenes()) { + addFileKey(keys, keyById, s.battlemapMediaFileId()); + addFileKey(keys, keyById, s.battlemapDataFileId()); + } + return keys; + } + + private void addFileKey(Set keys, java.util.Map keyById, String fileId) { + if (fileId == null || fileId.isBlank()) return; + String key = keyById.get(fileId); + if (key != null && !key.isBlank()) keys.add(key); + } + private void add(Set keys, String key) { if (key != null && !key.isBlank()) keys.add(key); } @@ -282,14 +329,14 @@ public class ExportService { e.getType() != null ? e.getType().name() : null, e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(), e.getRewards(), e.getResolution(), e.getRelatedPageIds(), - e.getIllustrationImageIds(), e.getMapImageIds()); + e.getIllustrationImageIds()); } private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) { return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(), e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(), e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(), - e.getRelatedPageIds(), e.getIllustrationImageIds(), e.getMapImageIds()); + e.getRelatedPageIds(), e.getIllustrationImageIds()); } private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) { @@ -298,7 +345,8 @@ public class ExportService { e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(), e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(), e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(), - e.getIllustrationImageIds(), e.getMapImageIds(), e.getBranches(), e.getRooms()); + e.getIllustrationImageIds(), e.getBattlemapMediaFileId(), + e.getBattlemapDataFileId(), e.getBranches(), e.getRooms()); } private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) { @@ -347,4 +395,9 @@ public class ExportService { return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(), e.getSizeBytes(), e.getStorageKey()); } + + private ContentExport.StoredFileDto toStoredFileDto(StoredFileJpaEntity e) { + return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(), + e.getSizeBytes(), e.getStorageKey()); + } } 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 60e1a45..21ce968 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java @@ -62,6 +62,7 @@ public class ImportService { private final ItemCatalogJpaRepository itemCatalogRepo; private final RandomTableJpaRepository randomTableRepo; private final ImageImporter imageImporter; + private final StoredFileImporter storedFileImporter; private final ObjectMapper objectMapper; public ImportService(GameSystemJpaRepository gameSystemRepo, @@ -79,6 +80,7 @@ public class ImportService { ItemCatalogJpaRepository itemCatalogRepo, RandomTableJpaRepository randomTableRepo, ImageImporter imageImporter, + StoredFileImporter storedFileImporter, ObjectMapper objectMapper) { this.gameSystemRepo = gameSystemRepo; this.loreRepo = loreRepo; @@ -95,6 +97,7 @@ public class ImportService { this.itemCatalogRepo = itemCatalogRepo; this.randomTableRepo = randomTableRepo; this.imageImporter = imageImporter; + this.storedFileImporter = storedFileImporter; this.objectMapper = objectMapper; } @@ -106,8 +109,9 @@ public class ImportService { ImportResult.Builder result = new ImportResult.Builder(); - // 2. Reecriture des images (cle preservee). + // 2. Reecriture des images + fichiers (cle preservee). imageImporter.importImages(export, archive.imageBinaries(), result); + storedFileImporter.importFiles(export, archive.fileBinaries(), result); // 3. Insertion top-down + maps de remapping. Map gameSystemMap = new HashMap<>(); @@ -223,7 +227,6 @@ public class ImportService { e.setResolution(d.resolution()); e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setIllustrationImageIds(d.illustrationImageIds()); - e.setMapImageIds(d.mapImageIds()); arcMap.put(d.id(), arcRepo.save(e).getId()); } result.count("arcs", arcMap.size()); @@ -299,7 +302,6 @@ public class ImportService { e.setNarrativeStakes(d.narrativeStakes()); e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setIllustrationImageIds(d.illustrationImageIds()); - e.setMapImageIds(d.mapImageIds()); chapterMap.put(d.id(), chapterRepo.save(e).getId()); } result.count("chapters", chapterMap.size()); @@ -373,7 +375,10 @@ public class ImportService { e.setEnemyIds(d.enemyIds()); // remappe plus bas e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setIllustrationImageIds(d.illustrationImageIds()); - e.setMapImageIds(d.mapImageIds()); + // Battlemap : ids StoredFile passes tels quels (meme logique que les refs + // d'images illustration, non remappees). Cf. ImportService doc. + e.setBattlemapMediaFileId(d.battlemapMediaFileId()); + e.setBattlemapDataFileId(d.battlemapDataFileId()); e.setBranches(d.branches()); // remappe plus bas e.setRooms(d.rooms()); // Rooms: UUID, non remappes sceneMap.put(d.id(), sceneRepo.save(e).getId()); @@ -462,8 +467,10 @@ public class ImportService { // ----- Lecture de l'archive ----- - /** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */ - private record ParsedArchive(ContentExport export, Map imageBinaries) { + /** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */ + private record ParsedArchive(ContentExport export, + Map imageBinaries, + Map fileBinaries) { } /** @@ -473,6 +480,7 @@ public class ImportService { 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) { @@ -484,6 +492,11 @@ public class ImportService { // 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(); } @@ -493,7 +506,7 @@ public class ImportService { if (export == null) { throw new IllegalArgumentException("Archive invalide : data.json introuvable"); } - return new ParsedArchive(export, imageBinaries); + return new ParsedArchive(export, imageBinaries, fileBinaries); } // ----- Helpers divers ----- diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/StoredFileImporter.java b/core/src/main/java/com/loremind/infrastructure/transfer/StoredFileImporter.java new file mode 100644 index 0000000..ce4145d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/StoredFileImporter.java @@ -0,0 +1,81 @@ +package com.loremind.infrastructure.transfer; + +import com.loremind.domain.files.ports.FileStorage; +import com.loremind.infrastructure.persistence.entity.StoredFileJpaEntity; +import com.loremind.infrastructure.persistence.jpa.StoredFileJpaRepository; +import com.loremind.infrastructure.transfer.dto.ContentExport; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Reecriture des fichiers generiques (battlemaps) lors d'un import (cf. {@link ImportService}). + *

+ * Pendant de {@link ImageImporter} pour la table {@code stored_files}. Les binaires + * sont stockes sous LEUR CLE D'ORIGINE ; un fichier dont la cle existe deja est + * REUTILISE (pas de reupload). + */ +@Component +class StoredFileImporter { + + private final StoredFileJpaRepository fileRepo; + private final FileStorage fileStorage; + + StoredFileImporter(StoredFileJpaRepository fileRepo, FileStorage fileStorage) { + this.fileRepo = fileRepo; + this.fileStorage = fileStorage; + } + + /** + * Reecrit les binaires de fichiers (cle preservee) et leurs metadonnees. + * + * @param export contenu importe (source des metadonnees par cle) + * @param fileBinaries {@code storageKey -> binaire} lus depuis le zip (prefixe files/) + * @param result compteurs a incrementer + */ + void importFiles(ContentExport export, + Map fileBinaries, + ImportResult.Builder result) { + Map metaByKey = new HashMap<>(); + for (ContentExport.StoredFileDto f : nullSafe(export.storedFiles())) { + if (f.storageKey() != null) metaByKey.put(f.storageKey(), f); + } + + int imported = 0; + for (Map.Entry bin : fileBinaries.entrySet()) { + String storageKey = bin.getKey(); + byte[] data = bin.getValue(); + if (fileRepo.findByStorageKey(storageKey).isPresent()) { + continue; // deja present : reutilise, pas de reupload + } + ContentExport.StoredFileDto meta = metaByKey.get(storageKey); + String contentType = meta != null && meta.contentType() != null + ? meta.contentType() : "application/octet-stream"; + long size = meta != null ? meta.sizeBytes() : data.length; + + fileStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length); + + StoredFileJpaEntity e = new StoredFileJpaEntity(); + e.setFilename(meta != null && meta.filename() != null + ? meta.filename() : fileNameOf(storageKey)); + e.setContentType(contentType); + e.setSizeBytes(size); + e.setStorageKey(storageKey); + fileRepo.save(e); + imported++; + } + result.count("storedFiles", imported); + } + + private static List nullSafe(List list) { + return list != null ? list : List.of(); + } + + private static String fileNameOf(String storageKey) { + int slash = storageKey.lastIndexOf('/'); + return slash >= 0 ? storageKey.substring(slash + 1) : storageKey; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java index c4cdd94..e5a22a2 100644 --- a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java +++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java @@ -35,7 +35,8 @@ public record ContentExport( List enemies, List itemCatalogs, List randomTables, - List images + List images, + List storedFiles ) { /** @@ -124,8 +125,7 @@ public record ContentExport( String rewards, String resolution, List relatedPageIds, - List illustrationImageIds, - List mapImageIds + List illustrationImageIds ) {} public record ChapterDto( @@ -144,8 +144,7 @@ public record ContentExport( String playerObjectives, String narrativeStakes, List relatedPageIds, - List illustrationImageIds, - List mapImageIds + List illustrationImageIds ) {} public record SceneDto( @@ -166,7 +165,8 @@ public record ContentExport( List enemyIds, List relatedPageIds, List illustrationImageIds, - List mapImageIds, + String battlemapMediaFileId, + String battlemapDataFileId, List branches, List rooms ) {} @@ -262,4 +262,17 @@ public record ContentExport( long sizeBytes, String storageKey ) {} + + /** + * Metadonnees d'un fichier generique (battlemap : media + sidecar JSON). + * Le binaire voyage a part dans le zip sous {@code files/}. + * La cle est PRESERVEE telle quelle a l'import (meme logique que ImageDto). + */ + public record StoredFileDto( + Long id, + String filename, + String contentType, + long sizeBytes, + String storageKey + ) {} } diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java new file mode 100644 index 0000000..9c49649 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryBundle.java @@ -0,0 +1,87 @@ +package com.loremind.infrastructure.transfer.foundry; + +import java.util.List; +import java.util.Map; + +/** + * Records du bundle d'export Foundry (cf. {@code docs/foundry-bundle-schema.md}). + *

+ * Contrat NEUTRE vis-a-vis de Foundry : decrit fidelement les entites LoreMind ; + * c'est le module Foundry {@code loremind-importer} qui decide du mapping vers + * dossiers / scenes / journaux. Serialise avec inclusion NON_NULL (les champs + * absents disparaissent du JSON). + */ +public final class FoundryBundle { + + private FoundryBundle() {} + + public record Manifest( + String formatVersion, + String generator, + String appVersion, + String exportedAt, + String campaignId, + String campaignName, + String contentFormat, + Map counts + ) {} + + /** Contenu de data.json : campagne + listes a plat + index des assets. */ + public record Data( + String formatVersion, + Campaign campaign, + List arcs, + List quests, + List scenes, + List npcs, + List enemies, + List assets + ) {} + + public record Campaign(String id, String name, String description, String gameSystemId) {} + + public record Arc( + String id, String name, String description, int order, String type, String icon, + String themes, String stakes, String gmNotes, String rewards, String resolution, + List illustrationAssetIds + ) {} + + public record Quest( + String id, String arcId, String name, String description, int order, String icon, + String playerObjectives, String narrativeStakes, String gmNotes, + List> prerequisites, List illustrationAssetIds + ) {} + + public record Scene( + String id, String questId, String name, String description, int order, String icon, + String location, String timing, String atmosphere, + String playerNarration, String gmSecretNotes, String choicesConsequences, + String combatDifficulty, String enemies, List enemyIds, + List illustrationAssetIds, Battlemap battlemap, + List branches, List rooms + ) {} + + public record Battlemap(String mediaAssetId, String dataAssetId) {} + + public record Branch(String label, String targetSceneId, String condition) {} + + public record Room( + String id, String name, String description, String enemies, List enemyIds, + String loot, String traps, String gmNotes, Integer floor, int order, + List illustrationAssetIds, Battlemap battlemap, List branches + ) {} + + public record RoomBranch(String label, String targetRoomId, String condition) {} + + public record Persona( + String id, String name, String folder, int order, + String portraitAssetId, String headerAssetId, String level, List fields + ) {} + + /** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */ + public record Field(String type, String label, String value, List entries, List assetIds) {} + + public record Entry(String label, String value) {} + + public record Asset(String id, String kind, String path, String filename, String mime, long sizeBytes) {} +} diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java new file mode 100644 index 0000000..f5534c2 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/transfer/foundry/FoundryExportService.java @@ -0,0 +1,402 @@ +package com.loremind.infrastructure.transfer.foundry; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.loremind.domain.campaigncontext.Prerequisite; +import com.loremind.domain.campaigncontext.Room; +import com.loremind.domain.campaigncontext.RoomBranch; +import com.loremind.domain.campaigncontext.SceneBranch; +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.loremind.domain.files.ports.FileStorage; +import com.loremind.domain.images.ports.ImageStorage; +import org.springframework.boot.info.BuildProperties; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * Construit le bundle d'export Foundry d'UNE campagne (cf. + * {@code docs/foundry-bundle-schema.md}) : {@code manifest.json} + {@code data.json} + * + {@code assets/} (images + battlemaps). Volontairement decouple de Foundry : + * le bundle decrit les entites LoreMind, le module Foundry fait le mapping. + */ +@Service +public class FoundryExportService { + + private static final String FORMAT_VERSION = "1.0"; + private static final String CONTENT_FORMAT = "plain"; // textarea, ni markdown ni HTML + + private final CampaignJpaRepository campaignRepo; + private final ArcJpaRepository arcRepo; + private final ChapterJpaRepository chapterRepo; + private final SceneJpaRepository sceneRepo; + private final NpcJpaRepository npcRepo; + private final EnemyJpaRepository enemyRepo; + private final GameSystemJpaRepository gameSystemRepo; + private final ImageJpaRepository imageRepo; + private final StoredFileJpaRepository storedFileRepo; + private final ImageStorage imageStorage; + private final FileStorage fileStorage; + private final ObjectMapper objectMapper; + private final String appVersion; + + public FoundryExportService(CampaignJpaRepository campaignRepo, + ArcJpaRepository arcRepo, + ChapterJpaRepository chapterRepo, + SceneJpaRepository sceneRepo, + NpcJpaRepository npcRepo, + EnemyJpaRepository enemyRepo, + GameSystemJpaRepository gameSystemRepo, + ImageJpaRepository imageRepo, + StoredFileJpaRepository storedFileRepo, + ImageStorage imageStorage, + FileStorage fileStorage, + ObjectMapper objectMapper, + @Nullable BuildProperties buildProperties) { + this.campaignRepo = campaignRepo; + this.arcRepo = arcRepo; + this.chapterRepo = chapterRepo; + this.sceneRepo = sceneRepo; + this.npcRepo = npcRepo; + this.enemyRepo = enemyRepo; + this.gameSystemRepo = gameSystemRepo; + this.imageRepo = imageRepo; + this.storedFileRepo = storedFileRepo; + this.imageStorage = imageStorage; + this.fileStorage = fileStorage; + this.objectMapper = objectMapper; + this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; + } + + /** Bundle assemble (metadonnees uniquement ; les binaires sont streames au writeZip). */ + public record BuiltBundle(FoundryBundle.Manifest manifest, + FoundryBundle.Data data, + List binaries) {} + + /** Reference d'un binaire a copier dans le zip : chemin cible + cle de stockage. */ + public record BinaryRef(String path, String storageKey, boolean image) {} + + /** + * Assemble le bundle d'une campagne (sans toucher aux binaires : peu couteux). + * + * @throws NoSuchElementException si la campagne n'existe pas + */ + public BuiltBundle buildBundle(String campaignId, String exportedAt) { + CampaignJpaEntity campaign = campaignRepo.findById(Long.parseLong(campaignId)) + .orElseThrow(() -> new NoSuchElementException("Campagne introuvable : " + campaignId)); + + List npcTemplate = resolveTemplate(campaign.getGameSystemId(), true); + List enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false); + + AssetRegistry assets = new AssetRegistry(); + + // Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order). + List arcs = new ArrayList<>(); + List quests = new ArrayList<>(); + List scenes = new ArrayList<>(); + + List arcEntities = sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder); + for (ArcJpaEntity arc : arcEntities) { + arcs.add(new FoundryBundle.Arc( + str(arc.getId()), arc.getName(), arc.getDescription(), arc.getOrder(), + arc.getType() != null ? arc.getType().name() : null, arc.getIcon(), + arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(), + assets.images(arc.getIllustrationImageIds()))); + + for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) { + quests.add(new FoundryBundle.Quest( + str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(), + ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(), + prerequisites(ch.getPrerequisites()), assets.images(ch.getIllustrationImageIds()))); + + for (SceneJpaEntity sc : sortByOrder(sceneRepo.findByChapterId(ch.getId()), SceneJpaEntity::getOrder)) { + scenes.add(toScene(sc, str(ch.getId()), assets)); + } + } + } + + List npcs = new ArrayList<>(); + for (NpcJpaEntity n : npcRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + npcs.add(new FoundryBundle.Persona( + str(n.getId()), n.getName(), n.getFolder(), n.getOrder(), + assets.image(n.getPortraitImageId()), assets.image(n.getHeaderImageId()), null, + fields(npcTemplate, n.getValues(), n.getKeyValueValues(), n.getImageValues(), assets))); + } + + List enemies = new ArrayList<>(); + for (EnemyJpaEntity e : enemyRepo.findByCampaignIdOrderByOrderAsc(campaign.getId())) { + enemies.add(new FoundryBundle.Persona( + str(e.getId()), e.getName(), e.getFolder(), e.getOrder(), + assets.image(e.getPortraitImageId()), assets.image(e.getHeaderImageId()), e.getLevel(), + fields(enemyTemplate, e.getValues(), e.getKeyValueValues(), e.getImageValues(), assets))); + } + + FoundryBundle.Campaign campaignNode = new FoundryBundle.Campaign( + str(campaign.getId()), campaign.getName(), campaign.getDescription(), campaign.getGameSystemId()); + + FoundryBundle.Data data = new FoundryBundle.Data( + FORMAT_VERSION, campaignNode, arcs, quests, scenes, npcs, enemies, assets.assets()); + + Map counts = new LinkedHashMap<>(); + counts.put("arcs", arcs.size()); + counts.put("quests", quests.size()); + counts.put("scenes", scenes.size()); + counts.put("npcs", npcs.size()); + counts.put("enemies", enemies.size()); + counts.put("assets", assets.assets().size()); + + FoundryBundle.Manifest manifest = new FoundryBundle.Manifest( + FORMAT_VERSION, "loremind", appVersion, exportedAt, + str(campaign.getId()), campaign.getName(), CONTENT_FORMAT, counts); + + return new BuiltBundle(manifest, data, assets.binaries()); + } + + /** Serialise le bundle dans le flux au format .zip (binaires streames a la volee). */ + public void writeZip(BuiltBundle bundle, OutputStream out) { + ObjectMapper writer = objectMapper.copy() + .setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL) + .enable(SerializationFeature.INDENT_OUTPUT); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("manifest.json")); + zip.write(writer.writeValueAsBytes(bundle.manifest())); + zip.closeEntry(); + + zip.putNextEntry(new ZipEntry("data.json")); + zip.write(writer.writeValueAsBytes(bundle.data())); + zip.closeEntry(); + + Set written = new HashSet<>(); + for (BinaryRef ref : bundle.binaries()) { + if (!written.add(ref.path())) continue; // dedup defensif + InputStream data = ref.image() + ? imageStorage.download(ref.storageKey()) + : fileStorage.download(ref.storageKey()); + if (data == null) continue; // cle orpheline : on ignore + try (data) { + zip.putNextEntry(new ZipEntry(ref.path())); + data.transferTo(zip); + zip.closeEntry(); + } + } + } catch (IOException e) { + throw new UncheckedIOException("Echec de la generation du bundle Foundry", e); + } + } + + // ----- Mapping Scene ----- + + private FoundryBundle.Scene toScene(SceneJpaEntity sc, String questId, AssetRegistry assets) { + FoundryBundle.Battlemap battlemap = battlemap(sc.getBattlemapMediaFileId(), sc.getBattlemapDataFileId(), assets); + return new FoundryBundle.Scene( + str(sc.getId()), questId, sc.getName(), sc.getDescription(), sc.getOrder(), sc.getIcon(), + sc.getLocation(), sc.getTiming(), sc.getAtmosphere(), + sc.getPlayerNarration(), sc.getGmSecretNotes(), sc.getChoicesConsequences(), + sc.getCombatDifficulty(), sc.getEnemies(), copy(sc.getEnemyIds()), + assets.images(sc.getIllustrationImageIds()), battlemap, + branches(sc.getBranches()), rooms(sc.getRooms(), assets)); + } + + private FoundryBundle.Battlemap battlemap(String mediaId, String dataId, AssetRegistry assets) { + String media = assets.file(mediaId, "battlemapMedia"); + String data = assets.file(dataId, "battlemapData"); + if (media == null && data == null) return null; + return new FoundryBundle.Battlemap(media, data); + } + + private List branches(List branches) { + if (branches == null) return List.of(); + List out = new ArrayList<>(); + for (SceneBranch b : branches) out.add(new FoundryBundle.Branch(b.label(), b.targetSceneId(), b.condition())); + return out; + } + + private List rooms(List rooms, AssetRegistry assets) { + if (rooms == null) return List.of(); + List out = new ArrayList<>(); + for (Room r : rooms) { + List rb = new ArrayList<>(); + if (r.getBranches() != null) { + for (RoomBranch b : r.getBranches()) rb.add(new FoundryBundle.RoomBranch(b.label(), b.targetRoomId(), b.condition())); + } + out.add(new FoundryBundle.Room( + r.getId(), r.getName(), r.getDescription(), r.getEnemies(), copy(r.getEnemyIds()), + r.getLoot(), r.getTraps(), r.getGmNotes(), r.getFloor(), r.getOrder(), + assets.images(r.getIllustrationImageIds()), null, rb)); + } + return out; + } + + private List> prerequisites(List prereqs) { + if (prereqs == null || prereqs.isEmpty()) return List.of(); + List> out = new ArrayList<>(); + for (Prerequisite p : prereqs) { + if (p instanceof Prerequisite.QuestCompleted qc) { + out.add(Map.of("type", "questCompleted", "questId", String.valueOf(qc.questId()))); + } else if (p instanceof Prerequisite.SessionReached sr) { + out.add(Map.of("type", "sessionReached", "minSessionNumber", sr.minSessionNumber())); + } else if (p instanceof Prerequisite.FlagSet fs) { + out.add(Map.of("type", "flagSet", "flagName", fs.flagName())); + } + } + return out; + } + + // ----- Resolution des champs PNJ/Ennemi via le template ----- + + private List resolveTemplate(String gameSystemId, boolean npc) { + if (gameSystemId == null || gameSystemId.isBlank()) return null; + try { + return gameSystemRepo.findById(Long.parseLong(gameSystemId)) + .map(gs -> npc ? gs.getNpcTemplate() : gs.getEnemyTemplate()) + .orElse(null); + } catch (NumberFormatException ex) { + return null; + } + } + + private List fields(List template, + Map values, + Map> keyValueValues, + Map> imageValues, + AssetRegistry assets) { + List out = new ArrayList<>(); + // Repli : pas de template -> paires brutes label=cle. + if (template == null || template.isEmpty()) { + if (values != null) { + for (Map.Entry e : values.entrySet()) { + if (e.getValue() != null && !e.getValue().isBlank()) { + out.add(new FoundryBundle.Field("text", e.getKey(), e.getValue(), null, null)); + } + } + } + return out; + } + for (TemplateField f : template) { + if (f == null || f.getName() == null || f.getType() == null) continue; + FieldType type = f.getType(); + if (type == FieldType.TEXT || type == FieldType.NUMBER) { + String v = values != null ? values.get(f.getName()) : null; + if (v != null && !v.isBlank()) { + out.add(new FoundryBundle.Field(type == FieldType.NUMBER ? "number" : "text", + f.getName(), v, null, null)); + } + } else if (type == FieldType.KEY_VALUE_LIST) { + Map inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null; + List labels = f.getLabels(); + if (inner != null && labels != null) { + List entries = new ArrayList<>(); + for (String label : labels) { + String v = inner.get(label); + if (v != null && !v.isBlank()) entries.add(new FoundryBundle.Entry(label, v)); + } + if (!entries.isEmpty()) { + out.add(new FoundryBundle.Field("keyValueList", f.getName(), null, entries, null)); + } + } + } else if (type == FieldType.IMAGE) { + List ids = imageValues != null ? imageValues.get(f.getName()) : null; + List assetIds = assets.images(ids); + if (!assetIds.isEmpty()) { + out.add(new FoundryBundle.Field("image", f.getName(), null, null, assetIds)); + } + } + // TABLE : pas de stockage cote PNJ/Ennemi -> ignore. + } + return out; + } + + // ----- Registre des assets (images + battlemaps), dedup + index ----- + + private final class AssetRegistry { + private final LinkedHashMap byId = new LinkedHashMap<>(); + private final List binaries = new ArrayList<>(); + + /** Enregistre une image par son ID LoreMind, retourne l'assetId ("img-") ou null. */ + String image(String imageId) { + if (imageId == null || imageId.isBlank()) return null; + String assetId = "img-" + imageId; + if (byId.containsKey(assetId)) return assetId; + ImageJpaEntity e; + try { + e = imageRepo.findById(Long.parseLong(imageId)).orElse(null); + } catch (NumberFormatException ex) { + return null; + } + if (e == null) return null; + String path = "assets/images/" + assetId + extOf(e.getStorageKey()); + byId.put(assetId, new FoundryBundle.Asset(assetId, "image", path, + e.getFilename(), e.getContentType(), e.getSizeBytes())); + binaries.add(new BinaryRef(path, e.getStorageKey(), true)); + return assetId; + } + + List images(List imageIds) { + if (imageIds == null) return List.of(); + List out = new ArrayList<>(); + for (String id : imageIds) { + String a = image(id); + if (a != null) out.add(a); + } + return out; + } + + /** Enregistre un fichier (battlemap) par son ID, retourne l'assetId ("file-") ou null. */ + String file(String fileId, String kind) { + if (fileId == null || fileId.isBlank()) return null; + String assetId = "file-" + fileId; + if (byId.containsKey(assetId)) return assetId; + StoredFileJpaEntity e; + try { + e = storedFileRepo.findById(Long.parseLong(fileId)).orElse(null); + } catch (NumberFormatException ex) { + return null; + } + if (e == null) return null; + String path = "assets/battlemaps/" + assetId + extOf(e.getStorageKey()); + byId.put(assetId, new FoundryBundle.Asset(assetId, kind, path, + e.getFilename(), e.getContentType(), e.getSizeBytes())); + binaries.add(new BinaryRef(path, e.getStorageKey(), false)); + return assetId; + } + + List assets() { return new ArrayList<>(byId.values()); } + + List binaries() { return binaries; } + } + + // ----- Helpers ----- + + private static List sortByOrder(List list, java.util.function.ToIntFunction order) { + List copy = new ArrayList<>(list); + copy.sort(Comparator.comparingInt(order)); + return copy; + } + + private static List copy(List list) { + return list != null ? new ArrayList<>(list) : List.of(); + } + + private static String str(Long id) { + return id != null ? id.toString() : null; + } + + /** Extension (avec le point) extraite de la cle de stockage "prefix/UUID.ext". */ + private static String extOf(String storageKey) { + if (storageKey == null) return ""; + int slash = storageKey.lastIndexOf('/'); + int dot = storageKey.lastIndexOf('.'); + return (dot >= 0 && dot > slash) ? storageKey.substring(dot) : ""; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FileController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FileController.java new file mode 100644 index 0000000..1d5c3cf --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FileController.java @@ -0,0 +1,98 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.application.files.StoredFileService; +import com.loremind.domain.files.StoredFile; +import com.loremind.infrastructure.web.dto.files.StoredFileDTO; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Optional; + +/** + * REST Controller pour les fichiers generiques (battlemaps : media + sidecar JSON). + *

+ * Expose : + * - POST /api/files (multipart/form-data, champ "file") + * - GET /api/files/{id} (metadonnees JSON) + * - GET /api/files/{id}/content (binaire) + * - DELETE /api/files/{id} + */ +@RestController +@RequestMapping("/api/files") +public class FileController { + + private final StoredFileService fileService; + + public FileController(StoredFileService fileService) { + this.fileService = fileService; + } + + @PostMapping + public ResponseEntity upload(@RequestParam("file") MultipartFile file) throws IOException { + if (file.isEmpty()) { + return ResponseEntity.badRequest().build(); + } + try (InputStream in = file.getInputStream()) { + StoredFile saved = fileService.upload( + file.getOriginalFilename(), + file.getContentType(), + in, + file.getSize() + ); + return ResponseEntity.ok(toDTO(saved)); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(null); + } + } + + @GetMapping("/{id}") + public ResponseEntity getMetadata(@PathVariable String id) { + return fileService.getById(id) + .map(f -> ResponseEntity.ok(toDTO(f))) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/{id}/content") + public ResponseEntity getContent(@PathVariable String id) { + Optional metadata = fileService.getById(id); + if (metadata.isEmpty()) { + return ResponseEntity.notFound().build(); + } + StoredFile file = metadata.get(); + InputStream stream = fileService.downloadById(id).orElse(null); + if (stream == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(file.getContentType())) + .contentLength(file.getSizeBytes()) + .header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable") + .header("Cross-Origin-Resource-Policy", "cross-origin") + .body(new InputStreamResource(stream)); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + fileService.deleteById(id); + return ResponseEntity.noContent().build(); + } + + // --- Mapping ----------------------------------------------------------- + + private StoredFileDTO toDTO(StoredFile f) { + StoredFileDTO dto = new StoredFileDTO(); + dto.setId(f.getId()); + dto.setFilename(f.getFilename()); + dto.setContentType(f.getContentType()); + dto.setSizeBytes(f.getSizeBytes()); + dto.setUrl("/api/files/" + f.getId() + "/content"); + dto.setUploadedAt(f.getUploadedAt()); + return dto; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java new file mode 100644 index 0000000..b007735 --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/FoundryExportController.java @@ -0,0 +1,62 @@ +package com.loremind.infrastructure.web.controller; + +import com.loremind.infrastructure.transfer.foundry.FoundryExportService; +import com.loremind.infrastructure.transfer.foundry.FoundryExportService.BuiltBundle; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.time.Instant; +import java.util.NoSuchElementException; + +/** + * Export d'une campagne vers un bundle Foundry VTT (cf. docs/foundry-bundle-schema.md). + *

+ * {@code GET /api/campaigns/{campaignId}/foundry-export} -> .zip (manifest + data.json + * + assets/). Le bundle assemble (metadonnees) est construit en synchrone pour pouvoir + * renvoyer 404 immediatement ; seuls les binaires sont streames ensuite. + */ +@RestController +@RequestMapping("/api/campaigns/{campaignId}/foundry-export") +public class FoundryExportController { + + private final FoundryExportService foundryExportService; + + public FoundryExportController(FoundryExportService foundryExportService) { + this.foundryExportService = foundryExportService; + } + + @GetMapping(produces = "application/zip") + public ResponseEntity export(@PathVariable String campaignId) { + String exportedAt = Instant.now().toString(); + BuiltBundle bundle; + try { + bundle = foundryExportService.buildBundle(campaignId, exportedAt); + } catch (NoSuchElementException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage()); + } + + String filename = slug(bundle.manifest().campaignName()) + "-foundry.zip"; + StreamingResponseBody body = out -> foundryExportService.writeZip(bundle, out); + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .contentType(MediaType.parseMediaType("application/zip")) + .body(body); + } + + /** Nom de fichier sur : alphanum + tirets, le reste en "_". */ + private static String slug(String name) { + if (name == null || name.isBlank()) return "campagne"; + String s = name.trim().replaceAll("[^a-zA-Z0-9-_]+", "_").replaceAll("_+", "_"); + s = s.replaceAll("^_|_$", ""); + return s.isBlank() ? "campagne" : s; + } +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java index 73f8a2a..5f00be4 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ArcDTO.java @@ -35,7 +35,4 @@ public class ArcDTO { /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */ private List illustrationImageIds = new ArrayList<>(); - - /** IDs des images utilisees comme cartes / plans. */ - private List mapImageIds = new ArrayList<>(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java index 1e0765e..3d961d1 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/ChapterDTO.java @@ -46,7 +46,4 @@ public class ChapterDTO { /** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */ private List illustrationImageIds = new ArrayList<>(); - - /** IDs des images utilisees comme cartes / plans. */ - private List mapImageIds = new ArrayList<>(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java index babdf96..9f3643e 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/campaigncontext/SceneDTO.java @@ -39,8 +39,11 @@ public class SceneDTO { /** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */ private List illustrationImageIds = new ArrayList<>(); - /** IDs des images utilisees comme cartes / plans (outil de table). */ - private List mapImageIds = new ArrayList<>(); + /** Battlemap Foundry : ID du fichier media (image/video). Null = pas de carte. */ + private String battlemapMediaFileId; + + /** Battlemap Foundry : ID du fichier sidecar Universal VTT (json). Null si absent. */ + private String battlemapDataFileId; /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */ private List branches = new ArrayList<>(); diff --git a/core/src/main/java/com/loremind/infrastructure/web/dto/files/StoredFileDTO.java b/core/src/main/java/com/loremind/infrastructure/web/dto/files/StoredFileDTO.java new file mode 100644 index 0000000..a295aab --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/dto/files/StoredFileDTO.java @@ -0,0 +1,20 @@ +package com.loremind.infrastructure.web.dto.files; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * DTO de retour pour les metadonnees d'un fichier generique. + * Ne contient PAS le binaire : celui-ci est servi via GET /api/files/{id}/content. + */ +@Data +public class StoredFileDTO { + private String id; + private String filename; + private String contentType; + private long sizeBytes; + /** URL relative pour telecharger le binaire. */ + private String url; + private LocalDateTime uploadedAt; +} diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java index c5e0019..c3d1cd9 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ArcMapper.java @@ -34,7 +34,6 @@ public class ArcMapper { dto.setResolution(arc.getResolution()); dto.setRelatedPageIds(copyList(arc.getRelatedPageIds())); dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds())); - dto.setMapImageIds(copyList(arc.getMapImageIds())); return dto; } @@ -58,7 +57,6 @@ public class ArcMapper { .resolution(dto.getResolution()) .relatedPageIds(copyList(dto.getRelatedPageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds())) - .mapImageIds(copyList(dto.getMapImageIds())) .build(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java index 6eca364..f183914 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/ChapterMapper.java @@ -41,7 +41,6 @@ public class ChapterMapper { dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds())); dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds())); - dto.setMapImageIds(copyList(chapter.getMapImageIds())); return dto; } @@ -61,7 +60,6 @@ public class ChapterMapper { .narrativeStakes(dto.getNarrativeStakes()) .relatedPageIds(copyList(dto.getRelatedPageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds())) - .mapImageIds(copyList(dto.getMapImageIds())) .build(); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java index 1d09a68..87f50ad 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java +++ b/core/src/main/java/com/loremind/infrastructure/web/mapper/SceneMapper.java @@ -49,9 +49,8 @@ public class SceneMapper { dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null ? new ArrayList<>(scene.getIllustrationImageIds()) : new ArrayList<>()); - dto.setMapImageIds(scene.getMapImageIds() != null - ? new ArrayList<>(scene.getMapImageIds()) - : new ArrayList<>()); + dto.setBattlemapMediaFileId(scene.getBattlemapMediaFileId()); + dto.setBattlemapDataFileId(scene.getBattlemapDataFileId()); dto.setBranches(toBranchDTOs(scene.getBranches())); dto.setRooms(toRoomDTOs(scene.getRooms())); return dto; @@ -86,9 +85,8 @@ public class SceneMapper { .illustrationImageIds(dto.getIllustrationImageIds() != null ? new ArrayList<>(dto.getIllustrationImageIds()) : new ArrayList<>()) - .mapImageIds(dto.getMapImageIds() != null - ? new ArrayList<>(dto.getMapImageIds()) - : new ArrayList<>()) + .battlemapMediaFileId(dto.getBattlemapMediaFileId()) + .battlemapDataFileId(dto.getBattlemapDataFileId()) .branches(toBranchDomain(dto.getBranches())) .rooms(toRoomDomain(dto.getRooms())) .build(); diff --git a/core/src/main/resources/application.properties b/core/src/main/resources/application.properties index d0e25c0..495d56d 100644 --- a/core/src/main/resources/application.properties +++ b/core/src/main/resources/application.properties @@ -86,10 +86,11 @@ minio.access-key=${MINIO_ACCESS_KEY:minioadmin} minio.secret-key=${MINIO_SECRET_KEY:minioadmin} minio.bucket=${MINIO_BUCKET:loremind-images} -# Limites d'upload multipart (MB) : images ET PDF de regles. Releve a 64 Mo -# pour accepter les livres de regles illustres (le Brain plafonne aussi a 60 Mo). -spring.servlet.multipart.max-file-size=64MB -spring.servlet.multipart.max-request-size=64MB +# Limites d'upload multipart (MB) : images, PDF de regles ET battlemaps video. +# Releve a 128 Mo pour accepter les cartes animees (.mp4) sorties de Dungeon +# Alchemist & co (le Brain plafonne de son cote a 60 Mo pour les PDF). +spring.servlet.multipart.max-file-size=128MB +spring.servlet.multipart.max-request-size=128MB # Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings # cote serveur. Activer via DEMO_MODE=true sur les deploiements publics. diff --git a/core/src/main/resources/db/migration/V2__battlemaps_and_stored_files.sql b/core/src/main/resources/db/migration/V2__battlemaps_and_stored_files.sql new file mode 100644 index 0000000..1be9363 --- /dev/null +++ b/core/src/main/resources/db/migration/V2__battlemaps_and_stored_files.sql @@ -0,0 +1,31 @@ +-- ============================================================================ +-- V2 : Battlemaps de scene (export Foundry) + stockage fichier generique. +-- ============================================================================ +-- Contexte : les "maps" (anciennement images attachees aux arcs/chapitres/scenes) +-- deviennent des "battlemaps" portees UNIQUEMENT par la scene, sous forme d'une +-- paire { media + sidecar JSON Universal VTT } stockee comme fichiers generiques +-- (table stored_files). Le media peut etre une video (.mp4) -> hors perimetre de +-- la table images (image/* + 10 Mo). +-- +-- SQL ecrit en dialecte PostgreSQL ; rejoue tel quel sur H2 (MODE=PostgreSQL). + +-- 1. Table des fichiers generiques (pendant de la table images). +create table stored_files ( + id bigint generated by default as identity, + size_bytes bigint not null, + uploaded_at timestamp(6) not null, + content_type varchar(255) not null, + filename varchar(255) not null, + storage_key varchar(255) not null unique, + primary key (id) +); + +-- 2. La scene porte desormais une battlemap (2 refs StoredFile, nullables). +alter table scenes add column battlemap_media_file_id varchar(255); +alter table scenes add column battlemap_data_file_id varchar(255); + +-- 3. Suppression des anciennes colonnes "cartes / plans". +-- Les maps ne vivent plus que sur la scene (via la battlemap ci-dessus). +alter table scenes drop column map_image_ids; +alter table arcs drop column map_image_ids; +alter table chapters drop column map_image_ids; diff --git a/core/src/test/java/com/loremind/application/files/StoredFileServiceTest.java b/core/src/test/java/com/loremind/application/files/StoredFileServiceTest.java new file mode 100644 index 0000000..7fff44c --- /dev/null +++ b/core/src/test/java/com/loremind/application/files/StoredFileServiceTest.java @@ -0,0 +1,160 @@ +package com.loremind.application.files; + +import com.loremind.domain.files.StoredFile; +import com.loremind.domain.files.ports.FileStorage; +import com.loremind.domain.files.ports.StoredFileRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Test unitaire pour StoredFileService. + * Specificites vs ImageService : accepte video + JSON, tolere un content-type + * absent (defaut application/octet-stream), plafond a 128 Mo. + */ +@ExtendWith(MockitoExtension.class) +class StoredFileServiceTest { + + @Mock private StoredFileRepository repository; + @Mock private FileStorage storage; + + @InjectMocks private StoredFileService service; + + private InputStream data; + + @BeforeEach + void setUp() { + data = new ByteArrayInputStream(new byte[]{1, 2, 3}); + } + + @Test + void upload_video_persistsMetadata() { + when(storage.upload(eq("donjon.mp4"), eq("video/mp4"), any(), eq(2048L))) + .thenReturn("files/abc.mp4"); + when(repository.save(any(StoredFile.class))).thenAnswer(inv -> { + StoredFile f = inv.getArgument(0); + f.setId("file-1"); + return f; + }); + + StoredFile result = service.upload("donjon.mp4", "video/mp4", data, 2048L); + + assertEquals("file-1", result.getId()); + assertEquals("files/abc.mp4", result.getStorageKey()); + assertNotNull(result.getUploadedAt()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(StoredFile.class); + verify(repository).save(captor.capture()); + assertEquals("video/mp4", captor.getValue().getContentType()); + } + + @Test + void upload_jsonSidecar_accepted() { + when(storage.upload(anyString(), anyString(), any(), anyLong())).thenReturn("k"); + when(repository.save(any(StoredFile.class))).thenAnswer(inv -> inv.getArgument(0)); + + assertDoesNotThrow(() -> service.upload("map.dd2vtt", "application/json", data, 100L)); + } + + @Test + void upload_nullContentType_defaultsToOctetStreamAndAccepted() { + when(storage.upload(anyString(), eq("application/octet-stream"), any(), anyLong())) + .thenReturn("files/k"); + when(repository.save(any(StoredFile.class))).thenAnswer(inv -> inv.getArgument(0)); + + StoredFile r = service.upload("map.dd2vtt", null, data, 100L); + + assertEquals("application/octet-stream", r.getContentType()); + } + + @Test + void upload_unsupportedMime_throws() { + assertThrows(IllegalArgumentException.class, + () -> service.upload("a.html", "text/html", data, 100L)); + verifyNoInteractions(storage); + } + + @Test + void upload_blankFilename_throws() { + assertThrows(IllegalArgumentException.class, + () -> service.upload(" ", "video/mp4", data, 100L)); + verifyNoInteractions(storage); + verifyNoInteractions(repository); + } + + @Test + void upload_zeroSize_throws() { + assertThrows(IllegalArgumentException.class, + () -> service.upload("a.mp4", "video/mp4", data, 0L)); + } + + @Test + void upload_tooLarge_throws() { + long tooBig = 128L * 1024 * 1024 + 1; + assertThrows(IllegalArgumentException.class, + () -> service.upload("a.mp4", "video/mp4", data, tooBig)); + verifyNoInteractions(storage); + } + + @Test + void upload_dbFailure_compensatesByDeletingBinary() { + when(storage.upload(anyString(), anyString(), any(), anyLong())) + .thenReturn("files/orphan.mp4"); + when(repository.save(any(StoredFile.class))).thenThrow(new RuntimeException("DB down")); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> service.upload("a.mp4", "video/mp4", data, 500L)); + assertEquals("DB down", ex.getMessage()); + verify(storage).delete("files/orphan.mp4"); + } + + @Test + void downloadById_found_returnsStream() { + StoredFile f = StoredFile.builder().id("file-1").storageKey("files/k.mp4").build(); + InputStream stream = new ByteArrayInputStream(new byte[]{9}); + when(repository.findById("file-1")).thenReturn(Optional.of(f)); + when(storage.download("files/k.mp4")).thenReturn(stream); + + Optional result = service.downloadById("file-1"); + + assertTrue(result.isPresent()); + assertSame(stream, result.get()); + } + + @Test + void deleteById_removesBinaryThenMetadata() { + StoredFile f = StoredFile.builder().id("file-1").storageKey("files/k.mp4").build(); + when(repository.findById("file-1")).thenReturn(Optional.of(f)); + + service.deleteById("file-1"); + + var order = inOrder(storage, repository); + order.verify(storage).delete("files/k.mp4"); + order.verify(repository).deleteById("file-1"); + } + + @Test + void deleteById_notFound_noOp() { + when(repository.findById("missing")).thenReturn(Optional.empty()); + + service.deleteById("missing"); + + verifyNoInteractions(storage); + verify(repository, never()).deleteById(anyString()); + } +} diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java index 867c481..b2af7a7 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/ArcTest.java @@ -26,10 +26,8 @@ class ArcTest { assertNotNull(arc.getRelatedPageIds(), "relatedPageIds ne doit jamais etre null"); assertNotNull(arc.getIllustrationImageIds(), "illustrationImageIds ne doit jamais etre null"); - assertNotNull(arc.getMapImageIds(), "mapImageIds ne doit jamais etre null"); assertTrue(arc.getRelatedPageIds().isEmpty()); assertTrue(arc.getIllustrationImageIds().isEmpty()); - assertTrue(arc.getMapImageIds().isEmpty()); } @Test @@ -37,12 +35,10 @@ class ArcTest { Arc arc = Arc.builder() .relatedPageIds(List.of("page-a", "page-b")) .illustrationImageIds(List.of("img-1")) - .mapImageIds(List.of("map-1", "map-2", "map-3")) .build(); assertEquals(2, arc.getRelatedPageIds().size()); assertEquals(1, arc.getIllustrationImageIds().size()); - assertEquals(3, arc.getMapImageIds().size()); } @Test diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java index cf02c8b..de6ddc8 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/ChapterTest.java @@ -26,10 +26,8 @@ class ChapterTest { assertNotNull(chapter.getRelatedPageIds()); assertNotNull(chapter.getIllustrationImageIds()); - assertNotNull(chapter.getMapImageIds()); assertTrue(chapter.getRelatedPageIds().isEmpty()); assertTrue(chapter.getIllustrationImageIds().isEmpty()); - assertTrue(chapter.getMapImageIds().isEmpty()); } @Test @@ -37,12 +35,10 @@ class ChapterTest { Chapter chapter = Chapter.builder() .relatedPageIds(List.of("page-x")) .illustrationImageIds(List.of("img-1", "img-2")) - .mapImageIds(List.of("map-dungeon")) .build(); assertEquals(1, chapter.getRelatedPageIds().size()); assertEquals(2, chapter.getIllustrationImageIds().size()); - assertEquals(1, chapter.getMapImageIds().size()); } @Test diff --git a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java index bf746a8..5e195bc 100644 --- a/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java +++ b/core/src/test/java/com/loremind/domain/campaigncontext/SceneTest.java @@ -6,13 +6,14 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests unitaires du domaine pour Scene. - * Scene est la plus riche en champs : on valide les quatre collections - * @Builder.Default (relatedPageIds, illustrationImageIds, mapImageIds, branches) - * et la preservation de l'ensemble des champs narratifs. + * Scene est la plus riche en champs : on valide les collections @Builder.Default + * (relatedPageIds, illustrationImageIds, branches), la battlemap (paire media + + * sidecar, nullable) et la preservation de l'ensemble des champs narratifs. */ class SceneTest { @@ -27,12 +28,24 @@ class SceneTest { assertNotNull(scene.getRelatedPageIds()); assertNotNull(scene.getIllustrationImageIds()); - assertNotNull(scene.getMapImageIds()); assertNotNull(scene.getBranches(), "branches ne doit jamais etre null — une scene sans branche est une feuille"); assertTrue(scene.getRelatedPageIds().isEmpty()); assertTrue(scene.getIllustrationImageIds().isEmpty()); - assertTrue(scene.getMapImageIds().isEmpty()); assertTrue(scene.getBranches().isEmpty()); + // Battlemap : aucune carte par defaut (les deux refs sont null). + assertNull(scene.getBattlemapMediaFileId()); + assertNull(scene.getBattlemapDataFileId()); + } + + @Test + void builder_preservesBattlemap_whenProvided() { + Scene scene = Scene.builder() + .battlemapMediaFileId("42") + .battlemapDataFileId("43") + .build(); + + assertEquals("42", scene.getBattlemapMediaFileId()); + assertEquals("43", scene.getBattlemapDataFileId()); } @Test diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java new file mode 100644 index 0000000..bb6c8d3 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/FlywayMigrationTest.java @@ -0,0 +1,63 @@ +package com.loremind.infrastructure.persistence; + +import org.flywaydb.core.Flyway; +import org.flywaydb.core.api.output.MigrateResult; +import org.junit.jupiter.api.Test; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Valide que la CHAINE de migrations Flyway (V1 baseline + V2 battlemaps) s'applique + * proprement sur H2 en mode PostgreSQL — exactement la configuration du desktop + * local-first ({@code MODE=PostgreSQL}), et au plus pres de la prod Postgres. + *

+ * Necessaire car la suite @SpringBootTest DESACTIVE Flyway (schema via ddl-auto + * create-drop). Sans ce test, le SQL des migrations ne serait jamais execute par + * la CI, alors qu'il pilote le schema des vraies installations. + */ +class FlywayMigrationTest { + + // Meme cocktail d'options que application-local.properties (desktop H2). + private static final String URL = + "jdbc:h2:mem:flyway_v2_test;MODE=PostgreSQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1"; + + @Test + void migrationsApplyCleanly_onH2PostgresMode() throws SQLException { + MigrateResult result = Flyway.configure() + .dataSource(URL, "sa", "") + .locations("classpath:db/migration") + .load() + .migrate(); + + // Au moins V1 + V2 jouees sur une base vierge. + assertTrue(result.migrationsExecuted >= 2, + "Attendu >= 2 migrations, obtenu " + result.migrationsExecuted); + + try (Connection conn = DriverManager.getConnection(URL, "sa", ""); + Statement st = conn.createStatement()) { + + // V2.1 : la table stored_files existe et est interrogeable. + st.executeQuery("select id, filename, content_type, size_bytes, storage_key, uploaded_at from stored_files"); + + // V2.2 : la scene porte la battlemap. + st.executeQuery("select battlemap_media_file_id, battlemap_data_file_id from scenes"); + + // V2.3 : les anciennes colonnes "cartes / plans" ont bien disparu. + assertThrows(SQLException.class, + () -> st.executeQuery("select map_image_ids from scenes"), + "scenes.map_image_ids aurait du etre supprimee par V2"); + assertThrows(SQLException.class, + () -> st.executeQuery("select map_image_ids from arcs"), + "arcs.map_image_ids aurait du etre supprimee par V2"); + assertThrows(SQLException.class, + () -> st.executeQuery("select map_image_ids from chapters"), + "chapters.map_image_ids aurait du etre supprimee par V2"); + } + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java index 713f54d..6cf158b 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresArcRepositoryTest.java @@ -19,8 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests d'integration pour PostgresArcRepository. - * Valide la persistance des 3 collections JSONB (relatedPageIds, - * illustrationImageIds, mapImageIds) et des 5 champs narratifs enrichis. + * Valide la persistance des collections JSONB (relatedPageIds, + * illustrationImageIds) et des 5 champs narratifs enrichis. */ @SpringBootTest @Transactional @@ -44,7 +44,6 @@ class PostgresArcRepositoryTest { .themes("trahison").stakes("survie").gmNotes("secret").rewards("artefact").resolution("couronnement") .relatedPageIds(List.of("page-1")) .illustrationImageIds(List.of("img-a", "img-b")) - .mapImageIds(List.of("map-1")) .build(); Arc saved = repository.save(arc); @@ -56,7 +55,6 @@ class PostgresArcRepositoryTest { assertEquals("secret", r.getGmNotes()); assertEquals(List.of("page-1"), r.getRelatedPageIds()); assertEquals(2, r.getIllustrationImageIds().size()); - assertEquals(List.of("map-1"), r.getMapImageIds()); } @Test @@ -86,6 +84,5 @@ class PostgresArcRepositoryTest { assertNotNull(r.getRelatedPageIds()); assertTrue(r.getRelatedPageIds().isEmpty()); assertTrue(r.getIllustrationImageIds().isEmpty()); - assertTrue(r.getMapImageIds().isEmpty()); } } diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java index f98a8f1..8674e9a 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresChapterRepositoryTest.java @@ -43,7 +43,6 @@ class PostgresChapterRepositoryTest { .gmNotes("note secrete").playerObjectives("trouver l'indice").narrativeStakes("si echec allie meurt") .relatedPageIds(List.of("page-x")) .illustrationImageIds(List.of("img-1")) - .mapImageIds(List.of("map-donjon")) .build(); Chapter saved = repository.save(chapter); @@ -54,7 +53,6 @@ class PostgresChapterRepositoryTest { assertEquals("note secrete", r.getGmNotes()); assertEquals("trouver l'indice", r.getPlayerObjectives()); assertEquals(List.of("page-x"), r.getRelatedPageIds()); - assertEquals(List.of("map-donjon"), r.getMapImageIds()); } @Test diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java index b7b2738..5fed042 100644 --- a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresSceneRepositoryTest.java @@ -54,7 +54,7 @@ class PostgresSceneRepositoryTest { .choicesConsequences("Si attaque -> gardes").combatDifficulty("facile").enemies("3 brigands") .relatedPageIds(List.of("page-aubergiste")) .illustrationImageIds(List.of("img-1", "img-2")) - .mapImageIds(List.of("plan-taverne")) + .battlemapMediaFileId("100").battlemapDataFileId("101") .build(); Scene saved = repository.save(scene); @@ -65,7 +65,8 @@ class PostgresSceneRepositoryTest { assertEquals("Taverne du Dragon d'Or", r.getLocation()); assertEquals("Piege cache", r.getGmSecretNotes()); assertEquals(2, r.getIllustrationImageIds().size()); - assertEquals(List.of("plan-taverne"), r.getMapImageIds()); + assertEquals("100", r.getBattlemapMediaFileId()); + assertEquals("101", r.getBattlemapDataFileId()); } @Test diff --git a/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepositoryTest.java b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepositoryTest.java new file mode 100644 index 0000000..8be8ae4 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/persistence/postgres/PostgresStoredFileRepositoryTest.java @@ -0,0 +1,69 @@ +package com.loremind.infrastructure.persistence.postgres; + +import com.loremind.domain.files.StoredFile; +import com.loremind.domain.files.ports.StoredFileRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests d'integration pour PostgresStoredFileRepository. + * StoredFile = pendant generique d'Image (battlemaps : video/json), metadata + + * cle opaque. Valide aussi la recherche par cle (utilisee a l'import). + */ +@SpringBootTest +@Transactional +class PostgresStoredFileRepositoryTest { + + @Autowired private StoredFileRepository repository; + + @Test + void save_fileWithAllMetadata_roundTrips() { + StoredFile file = StoredFile.builder() + .filename("cellier.mp4") + .contentType("video/mp4") + .sizeBytes(8_123_456L) + .storageKey("files/abc123.mp4") + .uploadedAt(LocalDateTime.now()) + .build(); + + StoredFile saved = repository.save(file); + assertNotNull(saved.getId()); + + StoredFile r = repository.findById(saved.getId()).orElseThrow(); + assertEquals("cellier.mp4", r.getFilename()); + assertEquals("video/mp4", r.getContentType()); + assertEquals(8_123_456L, r.getSizeBytes()); + assertEquals("files/abc123.mp4", r.getStorageKey()); + assertNotNull(r.getUploadedAt()); + } + + @Test + void findByStorageKey_returnsMatch() { + repository.save(StoredFile.builder() + .filename("map.dd2vtt").contentType("application/json").sizeBytes(2048L) + .storageKey("files/sidecar.json").uploadedAt(LocalDateTime.now()).build()); + + assertTrue(repository.findByStorageKey("files/sidecar.json").isPresent()); + assertTrue(repository.findByStorageKey("files/inconnu.json").isEmpty()); + } + + @Test + void deleteById_removesFile() { + StoredFile saved = repository.save(StoredFile.builder() + .filename("x.webm").contentType("video/webm").sizeBytes(100L) + .storageKey("files/k.webm").uploadedAt(LocalDateTime.now()).build()); + + assertTrue(repository.existsById(saved.getId())); + repository.deleteById(saved.getId()); + assertFalse(repository.existsById(saved.getId())); + } +} diff --git a/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java new file mode 100644 index 0000000..1af6238 --- /dev/null +++ b/core/src/test/java/com/loremind/infrastructure/transfer/foundry/FoundryExportServiceTest.java @@ -0,0 +1,134 @@ +package com.loremind.infrastructure.transfer.foundry; + +import com.loremind.domain.shared.template.TemplateField; +import com.loremind.infrastructure.persistence.entity.*; +import com.loremind.infrastructure.persistence.jpa.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Test d'integration de l'assemblage du bundle Foundry (metadonnees uniquement : + * {@code buildBundle} ne touche pas aux binaires, donc pas besoin de MinIO). + * Couvre : hierarchie arc/quete/scene, battlemap (media+sidecar), assets (image + + * fichiers), et resolution des champs PNJ via le template du GameSystem. + */ +@SpringBootTest +@Transactional +class FoundryExportServiceTest { + + @Autowired private FoundryExportService service; + @Autowired private CampaignJpaRepository campaignRepo; + @Autowired private ArcJpaRepository arcRepo; + @Autowired private ChapterJpaRepository chapterRepo; + @Autowired private SceneJpaRepository sceneRepo; + @Autowired private NpcJpaRepository npcRepo; + @Autowired private GameSystemJpaRepository gameSystemRepo; + @Autowired private ImageJpaRepository imageRepo; + @Autowired private StoredFileJpaRepository fileRepo; + + @Test + void buildBundle_assemblesFullCampaign_withBattlemapAssetsAndResolvedNpcFields() { + // GameSystem avec template PNJ : un TEXT "Histoire" + un KEY_VALUE_LIST "Caracs". + GameSystemJpaEntity gs = gameSystemRepo.save(GameSystemJpaEntity.builder() + .name("Test System").isPublic(false) + .npcTemplate(List.of( + TemplateField.text("Histoire"), + TemplateField.keyValueList("Caracs", List.of("FOR", "DEX")))) + .build()); + + CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder() + .name("Le baron de la drogue").description("desc").arcsCount(1) + .gameSystemId(String.valueOf(gs.getId())).build()); + + // Assets references par la scene : une image (illustration) + un media + un sidecar. + ImageJpaEntity img = imageRepo.save(ImageJpaEntity.builder() + .filename("baron.webp").contentType("image/webp").sizeBytes(100L) + .storageKey("images/aaa.webp").build()); + StoredFileJpaEntity media = fileRepo.save(StoredFileJpaEntity.builder() + .filename("convoi.mp4").contentType("video/mp4").sizeBytes(2048L) + .storageKey("files/bbb.mp4").build()); + StoredFileJpaEntity sidecar = fileRepo.save(StoredFileJpaEntity.builder() + .filename("convoi.dd2vtt").contentType("application/json").sizeBytes(512L) + .storageKey("files/ccc.json").build()); + + ArcJpaEntity arc = arcRepo.save(ArcJpaEntity.builder() + .campaignId(camp.getId()).name("Acte I").order(0).build()); + ChapterJpaEntity quest = chapterRepo.save(ChapterJpaEntity.builder() + .arcId(arc.getId()).name("Le convoi").order(0).build()); + sceneRepo.save(SceneJpaEntity.builder() + .chapterId(quest.getId()).name("L'attaque du convoi").order(0) + .playerNarration("Le convoi s'arrete...") + .illustrationImageIds(List.of(String.valueOf(img.getId()))) + .battlemapMediaFileId(String.valueOf(media.getId())) + .battlemapDataFileId(String.valueOf(sidecar.getId())) + .build()); + + npcRepo.save(NpcJpaEntity.builder() + .campaignId(camp.getId()).name("Le baron").order(0) + .values(Map.of("Histoire", "Ancien capitaine de la garde")) + .keyValueValues(Map.of("Caracs", Map.of("FOR", "16"))) + .build()); + + FoundryExportService.BuiltBundle bundle = service.buildBundle(String.valueOf(camp.getId()), "2026-06-25T00:00:00Z"); + FoundryBundle.Data data = bundle.data(); + + // Hierarchie + assertEquals("Le baron de la drogue", data.campaign().name()); + assertEquals(1, data.arcs().size()); + assertEquals(1, data.quests().size()); + assertEquals(1, data.scenes().size()); + assertEquals(String.valueOf(arc.getId()), data.quests().get(0).arcId()); + assertEquals(String.valueOf(quest.getId()), data.scenes().get(0).questId()); + + // Battlemap : refs vers les assets fichiers (prefixe "file-"). + FoundryBundle.Scene scene = data.scenes().get(0); + assertNotNull(scene.battlemap()); + assertEquals("file-" + media.getId(), scene.battlemap().mediaAssetId()); + assertEquals("file-" + sidecar.getId(), scene.battlemap().dataAssetId()); + assertEquals(List.of("img-" + img.getId()), scene.illustrationAssetIds()); + + // Index des assets : 1 image + 2 fichiers, chacun avec un chemin coherent. + assertEquals(3, data.assets().size()); + assertTrue(data.assets().stream().anyMatch(a -> + a.id().equals("file-" + media.getId()) && a.kind().equals("battlemapMedia") + && a.path().equals("assets/battlemaps/file-" + media.getId() + ".mp4"))); + assertTrue(data.assets().stream().anyMatch(a -> + a.id().equals("img-" + img.getId()) && a.kind().equals("image") + && a.path().equals("assets/images/img-" + img.getId() + ".webp"))); + + // Binaires a copier : 3 (1 image + 2 fichiers). + assertEquals(3, bundle.binaries().size()); + + // PNJ : champs resolus via le template (TEXT + KEY_VALUE_LIST). + assertEquals(1, data.npcs().size()); + List fields = data.npcs().get(0).fields(); + assertTrue(fields.stream().anyMatch(f -> + "text".equals(f.type()) && "Histoire".equals(f.label()) + && "Ancien capitaine de la garde".equals(f.value()))); + FoundryBundle.Field caracs = fields.stream() + .filter(f -> "keyValueList".equals(f.type()) && "Caracs".equals(f.label())) + .findFirst().orElseThrow(); + assertEquals(1, caracs.entries().size()); + assertEquals("FOR", caracs.entries().get(0).label()); + assertEquals("16", caracs.entries().get(0).value()); + + // Manifest + assertEquals("1.0", bundle.manifest().formatVersion()); + assertEquals("plain", bundle.manifest().contentFormat()); + assertEquals(1, bundle.manifest().counts().get("scenes")); + assertEquals(3, bundle.manifest().counts().get("assets")); + } + + @Test + void buildBundle_unknownCampaign_throwsNoSuchElement() { + assertThrows(java.util.NoSuchElementException.class, + () -> service.buildBundle("999999999", "2026-06-25T00:00:00Z")); + } +} diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html index 19e7ee3..2c08541 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html @@ -38,18 +38,6 @@ {{ 'arcEdit.illustrationsHint' | translate }} - -

- - - - {{ 'arcEdit.mapsHint' | translate }} -
-
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]), diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.html b/web/src/app/campaigns/arc/arc-view/arc-view.component.html index 4b8338a..68b383e 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.html +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.html @@ -29,13 +29,6 @@ } - - @if ((arc.mapImageIds?.length ?? 0) > 0) { -
-

🗺️ {{ 'arcView.mapsTitle' | translate }}

- -
- } @if (arc.type === 'HUB') { diff --git a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html index 458facb..8321299 100644 --- a/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html +++ b/web/src/app/campaigns/campaign/campaign-detail/campaign-detail.component.html @@ -28,6 +28,10 @@
+
- -
- - - - {{ 'chapterEdit.mapsHint' | translate }} -
diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts index c872441..23f2fcb 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.ts @@ -69,7 +69,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { loreId: string | null = null; relatedPageIds: string[] = []; illustrationImageIds: string[] = []; - mapImageIds: string[] = []; /** Prérequis (donnée de scénario). */ prerequisites: Prerequisite[] = []; @@ -149,7 +148,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { this.relatedPageIds = [...(chapter.relatedPageIds ?? [])]; this.selectedIcon = chapter.icon ?? null; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; - this.mapImageIds = [...(chapter.mapImageIds ?? [])]; this.prerequisites = [...(chapter.prerequisites ?? [])]; @@ -194,7 +192,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy { prerequisites: this.prerequisites, relatedPageIds: this.relatedPageIds, illustrationImageIds: this.illustrationImageIds, - mapImageIds: this.mapImageIds, icon: this.selectedIcon }).subscribe({ next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]), diff --git a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html index 4307993..3336747 100644 --- a/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html +++ b/web/src/app/campaigns/chapter/chapter-view/chapter-view.component.html @@ -64,13 +64,6 @@ } - - @if ((chapter.mapImageIds?.length ?? 0) > 0) { -
-

🗺️ {{ 'chapterView.maps' | translate }}

- -
- }

📖 {{ 'chapterView.synopsis' | translate }}

@if (chapter.description?.trim()) { diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html index cd558f4..ae602ef 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.html @@ -38,16 +38,40 @@ {{ 'sceneEdit.illustrationsHint' | translate }}
- +
- - - - {{ 'sceneEdit.mapsHint' | translate }} + + {{ 'sceneEdit.battlemapHint' | translate }} + +
+ {{ 'sceneEdit.battlemapMedia' | translate }} + @if (battlemapMediaFileId) { + {{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
+ +
+ {{ 'sceneEdit.battlemapData' | translate }} + @if (battlemapDataFileId) { + {{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }} + + } @else { + + } +
diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss index 2f56580..dd5decd 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.scss @@ -3,6 +3,44 @@ max-width: 760px; } +// Widget battlemap (export Foundry) : deux emplacements media + sidecar JSON. +.battlemap-slot { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0; + + .battlemap-slot-label { + min-width: 160px; + font-size: 0.9rem; + opacity: 0.85; + } + + .battlemap-file { + font-size: 0.9rem; + word-break: break-all; + } + + .battlemap-pick { + cursor: pointer; + padding: 0.35rem 0.75rem; + border: 1px dashed currentColor; + border-radius: 6px; + font-size: 0.85rem; + opacity: 0.9; + } + + .battlemap-remove { + background: none; + border: none; + color: inherit; + opacity: 0.7; + cursor: pointer; + text-decoration: underline; + font-size: 0.85rem; + } +} + // Header local : titre à gauche, actions (Assistant IA) à droite. .page-header { display: flex; diff --git a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts index 7cbdbda..3ca9989 100644 --- a/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts +++ b/web/src/app/campaigns/scene/scene-edit/scene-edit.component.ts @@ -7,6 +7,7 @@ import { switchMap } from 'rxjs/operators'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { CampaignService } from '../../../services/campaign.service'; +import { StoredFileService } from '../../../services/stored-file.service'; import { CharacterService } from '../../../services/character.service'; import { NpcService } from '../../../services/npc.service'; import { RandomTableService } from '../../../services/random-table.service'; @@ -70,7 +71,14 @@ export class SceneEditComponent implements OnInit, OnDestroy { availableEnemies: Enemy[] = []; enemyIds: string[] = []; illustrationImageIds: string[] = []; - mapImageIds: string[] = []; + + /** Battlemap Foundry : paire { media + sidecar JSON Universal VTT }. Non affichee. */ + battlemapMediaFileId: string | null = null; + battlemapDataFileId: string | null = null; + battlemapMediaName: string | null = null; + battlemapDataName: string | null = null; + battlemapUploadingMedia = false; + battlemapUploadingData = false; /** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */ siblingScenes: Scene[] = []; @@ -87,6 +95,7 @@ export class SceneEditComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private router: Router, private campaignService: CampaignService, + private storedFileService: StoredFileService, private characterService: CharacterService, private npcService: NpcService, private randomTableService: RandomTableService, @@ -161,7 +170,18 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.enemyIds = [...(scene.enemyIds ?? [])]; this.selectedIcon = scene.icon ?? null; this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; - this.mapImageIds = [...(scene.mapImageIds ?? [])]; + this.battlemapMediaFileId = scene.battlemapMediaFileId ?? null; + this.battlemapDataFileId = scene.battlemapDataFileId ?? null; + this.battlemapMediaName = null; + this.battlemapDataName = null; + if (this.battlemapMediaFileId) { + this.storedFileService.getById(this.battlemapMediaFileId) + .subscribe({ next: f => this.battlemapMediaName = f.filename, error: () => {} }); + } + if (this.battlemapDataFileId) { + this.storedFileService.getById(this.battlemapDataFileId) + .subscribe({ next: f => this.battlemapDataName = f.filename, error: () => {} }); + } this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId); this.branches = (scene.branches ?? []).map(b => ({ ...b })); this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] })); @@ -200,7 +220,8 @@ export class SceneEditComponent implements OnInit, OnDestroy { enemyIds: this.enemyIds, relatedPageIds: this.relatedPageIds, illustrationImageIds: this.illustrationImageIds, - mapImageIds: this.mapImageIds, + battlemapMediaFileId: this.battlemapMediaFileId, + battlemapDataFileId: this.battlemapDataFileId, branches: this.branches, rooms: this.rooms, icon: this.selectedIcon @@ -253,6 +274,53 @@ export class SceneEditComponent implements OnInit, OnDestroy { this.branches[index].condition = value; } + // ─────────────── Battlemap Foundry (media + sidecar JSON) ─────────────── + // On NE supprime PAS le binaire au "retirer" (juste la reference locale) : + // si l'utilisateur annule le formulaire, la scene garde son fichier intact. + // Le binaire orphelin eventuel est inoffensif (nettoyage ulterieur possible). + + onBattlemapMediaSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + this.battlemapUploadingMedia = true; + this.storedFileService.upload(file).subscribe({ + next: f => { + this.battlemapMediaFileId = f.id; + this.battlemapMediaName = f.filename; + this.battlemapUploadingMedia = false; + }, + error: () => { this.battlemapUploadingMedia = false; } + }); + input.value = ''; + } + + onBattlemapDataSelected(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + this.battlemapUploadingData = true; + this.storedFileService.upload(file).subscribe({ + next: f => { + this.battlemapDataFileId = f.id; + this.battlemapDataName = f.filename; + this.battlemapUploadingData = false; + }, + error: () => { this.battlemapUploadingData = false; } + }); + input.value = ''; + } + + removeBattlemapMedia(): void { + this.battlemapMediaFileId = null; + this.battlemapMediaName = null; + } + + removeBattlemapData(): void { + this.battlemapDataFileId = null; + this.battlemapDataName = null; + } + ngOnDestroy(): void { // Volontairement vide : la sidebar reste prise en charge par le composant // suivant (autre sous-route ou le composant detail parent) qui appellera diff --git a/web/src/app/campaigns/scene/scene-view/scene-view.component.html b/web/src/app/campaigns/scene/scene-view/scene-view.component.html index 8d20b79..7cdd6c5 100644 --- a/web/src/app/campaigns/scene/scene-view/scene-view.component.html +++ b/web/src/app/campaigns/scene/scene-view/scene-view.component.html @@ -27,13 +27,6 @@ } - - @if ((scene.mapImageIds?.length ?? 0) > 0) { -
-

🗺️ {{ 'sceneView.mapsSectionTitle' | translate }}

- -
- }

📝 {{ 'sceneView.descriptionSectionTitle' | translate }}

diff --git a/web/src/app/services/campaign.model.ts b/web/src/app/services/campaign.model.ts index 576b844..083607b 100644 --- a/web/src/app/services/campaign.model.ts +++ b/web/src/app/services/campaign.model.ts @@ -68,9 +68,6 @@ export interface Arc { /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */ illustrationImageIds?: string[]; - - /** IDs des images utilisees comme cartes / plans (outil de table). */ - mapImageIds?: string[]; } // Payload pour la création d'un Arc (pas d'id) @@ -90,7 +87,6 @@ export interface ArcCreate { relatedPageIds?: string[]; illustrationImageIds?: string[]; - mapImageIds?: string[]; } export interface Chapter { @@ -122,7 +118,6 @@ export interface Chapter { relatedPageIds?: string[]; illustrationImageIds?: string[]; - mapImageIds?: string[]; } export interface ChapterCreate { @@ -140,7 +135,6 @@ export interface ChapterCreate { relatedPageIds?: string[]; illustrationImageIds?: string[]; - mapImageIds?: string[]; } /** @@ -238,7 +232,13 @@ export interface Scene { relatedPageIds?: string[]; illustrationImageIds?: string[]; - mapImageIds?: string[]; + + /** + * Battlemap Foundry : ID du fichier media (image/video) + ID du sidecar JSON + * Universal VTT. Non affichee dans l'appli ; transportee a l'export Foundry. + */ + battlemapMediaFileId?: string | null; + battlemapDataFileId?: string | null; /** Sorties narratives (graphe intra-chapitre). */ branches?: SceneBranch[]; @@ -268,7 +268,8 @@ export interface SceneCreate { relatedPageIds?: string[]; illustrationImageIds?: string[]; - mapImageIds?: string[]; + battlemapMediaFileId?: string | null; + battlemapDataFileId?: string | null; branches?: SceneBranch[]; rooms?: Room[]; } diff --git a/web/src/app/services/campaign.service.ts b/web/src/app/services/campaign.service.ts index 0c41e64..482fcad 100644 --- a/web/src/app/services/campaign.service.ts +++ b/web/src/app/services/campaign.service.ts @@ -42,6 +42,11 @@ export class CampaignService { return this.http.get(`${this.apiUrl}/${id}`); } + /** Télécharge le bundle d'export Foundry de la campagne (.zip). */ + exportFoundry(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' }); + } + createCampaign(campaign: CampaignCreate): Observable { return this.http.post(this.apiUrl, campaign); } diff --git a/web/src/app/services/stored-file.model.ts b/web/src/app/services/stored-file.model.ts new file mode 100644 index 0000000..757f279 --- /dev/null +++ b/web/src/app/services/stored-file.model.ts @@ -0,0 +1,13 @@ +// Interface TypeScript pour StoredFileDTO (Backend Java). +// Miroir de com.loremind.infrastructure.web.dto.files.StoredFileDTO. +// Fichier generique (battlemap : media video/image + sidecar JSON Universal VTT). + +export interface StoredFile { + id: string; + filename: string; + contentType: string; + sizeBytes: number; + /** URL relative du binaire, ex: "/api/files/42/content". */ + url: string; + uploadedAt: string; +} diff --git a/web/src/app/services/stored-file.service.ts b/web/src/app/services/stored-file.service.ts new file mode 100644 index 0000000..972d5fb --- /dev/null +++ b/web/src/app/services/stored-file.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { StoredFile } from './stored-file.model'; + +/** + * Service HTTP pour les fichiers generiques (/api/files). + * Pendant de {@link ImageService} pour les battlemaps : media (image/video) + + * sidecar JSON Universal VTT, qui sortent du perimetre des images (mp4, json, + * taille jusqu'a 128 Mo). + */ +@Injectable({ providedIn: 'root' }) +export class StoredFileService { + readonly apiBase = ''; + private apiUrl = `${this.apiBase}/api/files`; + + constructor(private http: HttpClient) {} + + /** Upload multipart/form-data. Le backend valide le MIME et la taille (128 Mo max). */ + upload(file: File): Observable { + const form = new FormData(); + form.append('file', file); + return this.http.post(this.apiUrl, form); + } + + getById(id: string): Observable { + return this.http.get(`${this.apiUrl}/${id}`); + } + + delete(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } + + /** URL absolue du binaire (peu utile : la battlemap n'est pas affichee). */ + contentUrl(id: string): string { + return `${this.apiUrl}/${id}/content`; + } +} diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index 88b8424..ab8e7bd 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -52,12 +52,6 @@ {{ 'sidebar.gameSystems' | translate }} - @if (!config.demoMode) { - - } @if (!config.demoMode) {