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 @@
[](https://www.patreon.com/c/IGMLCreation)
[](https://discord.gg/cPpFzCjEzQ)
-## Découvrir LoreMind en vidéo
+## Découvrir DM Loremind en vidéo
-[](https://www.youtube.com/watch?v=llJkmlotbB8)
+[](https://www.youtube.com/watch?v=llJkmlotbB8)

## 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 @@
[](https://www.patreon.com/c/IGMLCreation)
[](https://discord.gg/cPpFzCjEzQ)
-## See LoreMind in action
+## See DM Loremind in action
-[](https://www.youtube.com/watch?v=llJkmlotbB8)
+[](https://www.youtube.com/watch?v=llJkmlotbB8)

## 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