Compare commits

..

11 Commits

Author SHA1 Message Date
18f5a260b0 passage v1.0.0-beta 2026-06-25 12:18:19 +02:00
c0e1da78c8 Mise en place de l'export foundry ; modification des Arc, chapitres et scènes..... => Suppression de la section maps pour arc, chapitres ; et dans scène on importe les maps format foundry (image / vidéo + json)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m35s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m37s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-25 12:16:27 +02:00
5bc038acd8 Suppression de la sélection du langage au démarrage de l'appli : maintenant c'est le système qui décide de la langue.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 33s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m30s
Build & Push Images / build (brain) (push) Successful in 1m22s
E2E Tests / e2e (push) Failing after 5m47s
Build & Push Images / build (core) (push) Successful in 3m29s
Build & Push Images / build-switcher (push) Successful in 48s
Build & Push Images / build (web) (push) Successful in 1m55s
Changement relicat Loremind -> DM Loremind
Passage en 0.18.0 stable publique
2026-06-22 16:59:55 +02:00
f347dcd357 Passage à la 0.17.3 et ajout d'un splash screen pour le démarrage de l'application
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 18s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Build & Push Images / build (brain) (push) Successful in 1m11s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m9s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 44s
Build & Push Images / build (core) (push) Successful in 3m2s
2026-06-22 11:34:55 +02:00
46fea8d53c On arrête de mettre appimagetool dans la release - actif après la release 0.17.2
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 22s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 25s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 1m55s
2026-06-22 11:15:41 +02:00
e05b26563f passage v0.17.2 release officielle
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 28s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m22s
Build & Push Images / build (brain) (push) Successful in 1m20s
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (web) (push) Successful in 1m52s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build-switcher (push) Successful in 43s
2026-06-22 10:37:00 +02:00
dc66199177 Passage v0.17.2-beta suite au changement de l'installeur
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m25s
Build & Push Images / build (core) (push) Successful in 3m20s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-22 09:51:26 +02:00
b133d0f16d Correction dans l'installeur : ajout d'une case pour pouvoir lancer le programme automatiquement à la fin de l'installation (sous windows) afin d'éviter que l'utilisateur ne soit perturbé 2026-06-22 09:49:33 +02:00
024d37dea6 Correction des worflow pour linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m44s
Build & Push Images / build (core) (push) Successful in 3m12s
Build & Push Images / build-switcher (push) Successful in 16s
2026-06-20 11:16:46 +02:00
997aadf5b5 Mise en place de la distribution en .appimage pour les distro linux
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m21s
Build & Push Images / build (web) (push) Successful in 1m49s
Build & Push Images / build (core) (push) Successful in 3m22s
Build & Push Images / build-switcher (push) Successful in 19s
2026-06-20 11:04:15 +02:00
560c07d5c3 Changement du logo pour l'application et changement du nom en "DM Loremind" pour le tittre de l'application.
Some checks failed
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 20s
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 29s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Successful in 2m17s
Build & Push Images / build (brain) (push) Successful in 1m13s
E2E Tests / e2e (push) Failing after 5m22s
Build & Push Images / build (core) (push) Successful in 3m25s
Build & Push Images / build-switcher (push) Successful in 51s
Build & Push Images / build (web) (push) Successful in 1m57s
2026-06-19 10:00:13 +02:00
96 changed files with 2874 additions and 271 deletions

View File

@@ -1,12 +1,12 @@
name: Desktop installers name: Desktop installers
# Produit les installeurs de BUREAU (.msi Windows pour l'instant) et les publie # Produit les installeurs de BUREAU (.msi Windows + AppImage Linux) et les publie
# en tant qu'assets d'une GitHub Release, sur tag `v*`. # en tant qu'assets d'une GitHub Release, sur tag `v*`.
# #
# Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui, # Complementaire au pipeline Gitea Actions (.gitea/workflows/release.yml) qui,
# lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage et # lui, build et pousse les IMAGES Docker. Ici on est sur GitHub car jpackage ne
# PyInstaller ne savent PAS cross-compiler : le .msi DOIT etre construit sur un # sait PAS cross-compiler : le .msi DOIT etre construit sur un runner Windows et
# runner Windows, et GitHub en fournit gratuitement (windows-latest). # l'AppImage sur un runner Linux — GitHub fournit les deux gratuitement.
# #
# Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags # Prerequis : le depot Gitea doit etre mirrore vers GitHub (push mirror, tags
# inclus) pour que le tag declenche ce workflow. # inclus) pour que le tag declenche ce workflow.
@@ -191,6 +191,72 @@ jobs:
path: core/target/dist-out/*.msi path: core/target/dist-out/*.msi
retention-days: 90 retention-days: 90
# TODO (plus tard) : job `linux` sur ubuntu-latest produisant un AppImage # LINUX : AppImage (1 fichier, toutes distros) attache a la MEME release.
# (jpackage --type app-image + appimagetool) + PyInstaller Linux du Brain, # Tourne sur ubuntu-latest (jpackage ne cross-compile pas -> build natif Linux).
# attache a la MEME release. Reutilise la meme matrice / les memes etapes. # Brain empaquete via python-build-standalone (pas de Python embeddable Linux
# officiel ; pas de PyInstaller -> pas de faux positif AV). Memes regles de
# publication que windows : stable -> release publique ; beta -> artefact prive.
linux:
needs: tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
# Apporte jpackage (empaquetage natif) dans le PATH.
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
# Pas de setup-python : le Brain utilise SON python-build-standalone
# (telecharge par le script, avec son propre pip). Le host n'en a pas besoin.
# Version (numerique X.Y.Z) + tag + isbeta, comme le job windows.
- name: Derive version
id: ver
run: |
if [ '${{ github.event_name }}' = 'workflow_dispatch' ]; then
raw='${{ inputs.version }}'
else
raw='${{ github.ref_name }}'
fi
raw="${raw#v}" # 0.15.0 ou 0.15.0-beta
num="${raw%%-*}" # 0.15.0
case "$raw" in *-beta*) isbeta=true ;; *) isbeta=false ;; esac
echo "version=$num" >> "$GITHUB_OUTPUT"
echo "tag=v$raw" >> "$GITHUB_OUTPUT"
echo "isbeta=$isbeta" >> "$GITHUB_OUTPUT"
- name: Build Linux AppImage
env:
# Authentifie l'appel API GitHub (resolution python-build-standalone) :
# evite le rate limit 60/h anonyme des runners partages.
GITHUB_TOKEN: ${{ github.token }}
run: bash ./installers/desktop/build-linux.sh --version ${{ steps.ver.outputs.version }}
# STABLE uniquement : attache l'AppImage a la release publique (meme tag que
# le .msi -> les deux installeurs sur la meme release).
- name: Publish AppImage to GitHub Release (stable)
if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/DM_Loremind*.AppImage
fail_on_unmatched_files: true
# BETA uniquement : artefact PRIVE (pas de release publique).
- name: Upload AppImage as private artifact (beta)
if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-appimage
path: core/target/dist-out/DM_Loremind*.AppImage
retention-days: 90

1
.gitignore vendored
View File

@@ -128,3 +128,4 @@ installers/desktop/README.md
web/coverage/ web/coverage/
brain/htmlcov/ brain/htmlcov/
brain/.coverage brain/.coverage
foundry-module/

View File

@@ -1,4 +1,4 @@
# LoreMind # DM Loremind
[English](README.md) · **Français** [English](README.md) · **Français**
@@ -10,33 +10,45 @@
[![Patreon](https://img.shields.io/badge/Patreon-soutenir-red)](https://www.patreon.com/c/IGMLCreation) [![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) [![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) ![Tableau de bord](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## Ce que ça fait ## 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 ### 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. 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. 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 ### 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. 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 ## Documentation
@@ -49,20 +61,20 @@ Une instance de démonstration est disponible sur **[loremind-demo.igmlcreation.
Quelques limites à connaître : Quelques limites à connaître :
- 10 utilisateurs maximum simultanés (instances isolées) - 10 utilisateurs maximum simultanés (instances isolées)
- Session limitée à 20 minutes avant réinitialisation - 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 ## 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 - **[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 - **[Discord](https://discord.gg/cPpFzCjEzQ)** — annonces, support, retours utilisateurs
## Licence ## Licence
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**. DM Loremind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
En pratique : En pratique :
- Vous pouvez l'utiliser gratuitement, l'héberger, la modifier, la redistribuer. - 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. - 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.

View File

@@ -1,4 +1,4 @@
# LoreMind # DM Loremind
**English** · [Français](README.fr.md) **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) [![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) [![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) ![Dashboard](https://raw.githubusercontent.com/IGMLcreation/loremind-docs/main/static/img/screenshots/dashboard.png)
## What it does ## 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 ### 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. 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 ## Documentation
@@ -49,20 +61,20 @@ A demo instance is available at **[loremind-demo.igmlcreation.fr](https://loremi
A few limitations to be aware of: A few limitations to be aware of:
- 10 concurrent users maximum (isolated instances) - 10 concurrent users maximum (isolated instances)
- Sessions limited to 20 minutes before reset - 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 ## 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 - **[Patreon](https://www.patreon.com/c/IGMLCreation)** — early access to features, roadmap voting, exclusive devlogs
- **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback - **[Discord](https://discord.gg/cPpFzCjEzQ)** — announcements, support, user feedback
## License ## License
LoreMind is distributed under the **[GNU AGPL v3](LICENSE)** license. DM Loremind is distributed under the **[GNU AGPL v3](LICENSE)** license.
In practice: In practice:
- You can use it for free, host it, modify it, and redistribute it. - 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. - 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.

View File

@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.16.3", version="1.0.0-beta",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -23,7 +23,11 @@ sys.path.insert(0, _HERE)
# AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement). # AVANT que l'app n'importe le pdf_extractor (qui détecte la version au chargement).
# tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR # tessdata (fra+eng) est embarqué dans tesseract/tessdata. Sans ce bloc, l'OCR
# reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés). # reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés).
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe") # Binaire selon l'OS : tesseract.exe (Windows embeddable) ou tesseract (Linux/Mac).
# Si aucun Tesseract n'est bundlé (cas Linux/AppImage par défaut), le bloc est
# sauté et pytesseract retombe sur le tesseract SYSTÈME du PATH (ex. apt install
# tesseract-ocr) — sinon OCR désactivé en dégradation gracieuse.
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe" if os.name == "nt" else "tesseract")
if os.path.exists(_TESS): if os.path.exists(_TESS):
os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract")) os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract"))
try: try:

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.16.3</version> <version>1.0.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -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).
* <p>
* 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<String> 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<StoredFile> getById(String id) {
return repository.findById(id);
}
public Optional<InputStream> 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);
}
}

View File

@@ -55,12 +55,6 @@ public class Arc {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -50,12 +50,6 @@ public class Chapter {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/**
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
*/
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }

View File

@@ -65,12 +65,16 @@ public class Scene {
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** /**
* IDs des images utilisees comme cartes / plans. * "Battlemap" de la scene destinee a l'export Foundry : paire { media + sidecar }.
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique. * Le media est un fichier image ou video ({@link com.loremind.domain.files.StoredFile}),
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve. * 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 String battlemapMediaFileId;
private List<String> mapImageIds = new ArrayList<>();
/** 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). * Sorties narratives possibles depuis cette scène (graphe intra-chapitre).

View File

@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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;
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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);
}

View File

@@ -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.
* <p>
* 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<StoredFile> findById(String id);
Optional<StoredFile> findByStorageKey(String storageKey);
void deleteById(String id);
boolean existsById(String id);
}

View File

@@ -7,6 +7,8 @@ import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.awt.SplashScreen;
/** /**
* En mode bureau (profil "local"), ouvre le navigateur par defaut sur * En mode bureau (profil "local"), ouvre le navigateur par defaut sur
* l'application des que le serveur est pret. L'app n'ayant pas de fenetre * l'application des que le serveur est pret. L'app n'ayant pas de fenetre
@@ -25,7 +27,26 @@ public class DesktopBrowserOpener {
@EventListener(ApplicationReadyEvent.class) @EventListener(ApplicationReadyEvent.class)
public void onReady() { public void onReady() {
// Ferme le splash natif (affiche par la JVM via -splash des le double-clic)
// juste avant d'ouvrir le navigateur : le relais visuel est assure.
closeSplash();
log.info("[Desktop] Application prete — ouverture du navigateur."); log.info("[Desktop] Application prete — ouverture du navigateur.");
DesktopSingleInstance.openAppInBrowser(); DesktopSingleInstance.openAppInBrowser();
} }
/**
* Ferme l'ecran de demarrage si l'app a ete lancee avec {@code -splash:...}
* (cas des installeurs jpackage). No-op silencieux sinon (lancement dev sans
* splash, ou environnement sans affichage).
*/
private void closeSplash() {
try {
SplashScreen splash = SplashScreen.getSplashScreen();
if (splash != null) {
splash.close();
}
} catch (Exception e) {
log.debug("[Desktop] Pas de splash a fermer : {}", e.getMessage());
}
}
} }

View File

@@ -20,6 +20,9 @@ import java.awt.RenderingHints;
import java.awt.SystemTray; import java.awt.SystemTray;
import java.awt.TrayIcon; import java.awt.TrayIcon;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
/** /**
* Icone dans la zone de notification (barre des taches) en mode bureau * Icone dans la zone de notification (barre des taches) en mode bureau
@@ -63,7 +66,7 @@ public class SystemTrayManager {
try { try {
popup = new PopupMenu(); popup = new PopupMenu();
MenuItem open = new MenuItem("Ouvrir LoreMind"); MenuItem open = new MenuItem("Ouvrir DM Loremind");
open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); open.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
popup.add(open); popup.add(open);
@@ -79,17 +82,17 @@ public class SystemTrayManager {
popup.add(editConfig); popup.add(editConfig);
// Acces au dossier de donnees (~/.loremind : base, images, config…). // Acces au dossier de donnees (~/.loremind : base, images, config…).
MenuItem openFolder = new MenuItem("Ouvrir le dossier LoreMind"); MenuItem openFolder = new MenuItem("Ouvrir le dossier DM Loremind");
openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir())); openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir()));
popup.add(openFolder); popup.add(openFolder);
popup.addSeparator(); popup.addSeparator();
MenuItem quit = new MenuItem("Quitter LoreMind"); MenuItem quit = new MenuItem("Quitter DM Loremind");
quit.addActionListener(e -> quit()); quit.addActionListener(e -> quit());
popup.add(quit); popup.add(quit);
trayIcon = new TrayIcon(createIcon(), "LoreMind", popup); trayIcon = new TrayIcon(createIcon(), "DM Loremind", popup);
trayIcon.setImageAutoSize(true); trayIcon.setImageAutoSize(true);
// Double-clic sur l'icone : ouvre l'application dans le navigateur. // Double-clic sur l'icone : ouvre l'application dans le navigateur.
trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser()); trayIcon.addActionListener(e -> DesktopSingleInstance.openAppInBrowser());
@@ -122,7 +125,7 @@ public class SystemTrayManager {
popup.insertSeparator(1); popup.insertSeparator(1);
trayIcon.displayMessage( trayIcon.displayMessage(
"LoreMind — mise a jour disponible", "DM Loremind — mise a jour disponible",
"Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion() "Version " + info.latestVersion() + " disponible (vous avez " + info.currentVersion()
+ "). Menu de l'icone → Telecharger.", + "). Menu de l'icone → Telecharger.",
TrayIcon.MessageType.INFO); TrayIcon.MessageType.INFO);
@@ -151,19 +154,35 @@ public class SystemTrayManager {
} }
/** /**
* Genere une petite icone (carre arrondi violet « L ») sans dependre d'un * Icone du systray : charge l'image de marque {@code /tray-icon.png} depuis le
* fichier image — robuste quel que soit l'empaquetage. * classpath (embarquee dans le jar). Repli sur une icone dessinee si le fichier
* est absent ou illisible — l'application reste fonctionnelle dans tous les cas.
*/ */
private Image createIcon() { private Image createIcon() {
try (InputStream in = getClass().getResourceAsStream("/tray-icon.png")) {
if (in != null) {
BufferedImage loaded = ImageIO.read(in);
if (loaded != null) {
return loaded;
}
}
} catch (IOException e) {
// ignore : on retombe sur l'icone dessinee ci-dessous
}
return drawFallbackIcon();
}
/** Icone de repli (carre arrondi violet « DM ») si l'image embarquee manque. */
private Image drawFallbackIcon() {
int size = 16; int size = 16;
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics(); Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque LoreMind g.setColor(new Color(0x8B, 0x5C, 0xF6)); // violet de marque
g.fillRoundRect(0, 0, size, size, 5, 5); g.fillRoundRect(0, 0, size, size, 5, 5);
g.setColor(Color.WHITE); g.setColor(Color.WHITE);
g.setFont(new Font("SansSerif", Font.BOLD, 12)); g.setFont(new Font("SansSerif", Font.BOLD, 9));
g.drawString("L", 4, 13); g.drawString("DM", 1, 12);
g.dispose(); g.dispose();
return img; return img;
} }

View File

@@ -86,12 +86,6 @@ public class ArcJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images "cartes / plans". */
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -72,11 +72,6 @@ public class ChapterJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT")
@Convert(converter = StringListJsonConverter.class)
@Builder.Default
private List<String> mapImageIds = new ArrayList<>();
@Column(name = "created_at", nullable = false, updatable = false) @Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;

View File

@@ -91,10 +91,13 @@ public class SceneJpaEntity {
@Builder.Default @Builder.Default
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
@Column(name = "map_image_ids", columnDefinition = "TEXT") /** Battlemap Foundry : fichier media (image/video). Null = pas de carte. */
@Convert(converter = StringListJsonConverter.class) @Column(name = "battlemap_media_file_id")
@Builder.Default private String battlemapMediaFileId;
private List<String> mapImageIds = new ArrayList<>();
/** 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. // Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes). // Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).

View File

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

View File

@@ -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<StoredFileJpaEntity, Long> {
/** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */
Optional<StoredFileJpaEntity> findByStorageKey(String storageKey);
}

View File

@@ -85,9 +85,6 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.build(); .build();
@@ -114,9 +111,6 @@ public class PostgresArcRepository implements ArcRepository {
.illustrationImageIds(arc.getIllustrationImageIds() != null .illustrationImageIds(arc.getIllustrationImageIds() != null
? new ArrayList<>(arc.getIllustrationImageIds()) ? new ArrayList<>(arc.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(arc.getMapImageIds() != null
? new ArrayList<>(arc.getMapImageIds())
: new ArrayList<>())
.createdAt(arc.getCreatedAt()) .createdAt(arc.getCreatedAt())
.updatedAt(arc.getUpdatedAt()) .updatedAt(arc.getUpdatedAt())
.build(); .build();

View File

@@ -84,9 +84,6 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null
? new ArrayList<>(jpaEntity.getMapImageIds())
: new ArrayList<>())
.createdAt(jpaEntity.getCreatedAt()) .createdAt(jpaEntity.getCreatedAt())
.updatedAt(jpaEntity.getUpdatedAt()) .updatedAt(jpaEntity.getUpdatedAt())
.build(); .build();
@@ -113,9 +110,6 @@ public class PostgresChapterRepository implements ChapterRepository {
.illustrationImageIds(chapter.getIllustrationImageIds() != null .illustrationImageIds(chapter.getIllustrationImageIds() != null
? new ArrayList<>(chapter.getIllustrationImageIds()) ? new ArrayList<>(chapter.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(chapter.getMapImageIds() != null
? new ArrayList<>(chapter.getMapImageIds())
: new ArrayList<>())
.createdAt(chapter.getCreatedAt()) .createdAt(chapter.getCreatedAt())
.updatedAt(chapter.getUpdatedAt()) .updatedAt(chapter.getUpdatedAt())
.build(); .build();

View File

@@ -89,9 +89,8 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null .illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
? new ArrayList<>(jpaEntity.getIllustrationImageIds()) ? new ArrayList<>(jpaEntity.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(jpaEntity.getMapImageIds() != null .battlemapMediaFileId(jpaEntity.getBattlemapMediaFileId())
? new ArrayList<>(jpaEntity.getMapImageIds()) .battlemapDataFileId(jpaEntity.getBattlemapDataFileId())
: new ArrayList<>())
.branches(jpaEntity.getBranches() != null .branches(jpaEntity.getBranches() != null
? new ArrayList<>(jpaEntity.getBranches()) ? new ArrayList<>(jpaEntity.getBranches())
: new ArrayList<>()) : new ArrayList<>())
@@ -129,9 +128,8 @@ public class PostgresSceneRepository implements SceneRepository {
.illustrationImageIds(scene.getIllustrationImageIds() != null .illustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds()) ? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(scene.getMapImageIds() != null .battlemapMediaFileId(scene.getBattlemapMediaFileId())
? new ArrayList<>(scene.getMapImageIds()) .battlemapDataFileId(scene.getBattlemapDataFileId())
: new ArrayList<>())
.branches(scene.getBranches() != null .branches(scene.getBranches() != null
? new ArrayList<>(scene.getBranches()) ? new ArrayList<>(scene.getBranches())
: new ArrayList<>()) : new ArrayList<>())

View File

@@ -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<StoredFile> findById(String id) {
return jpaRepository.findById(Long.parseLong(id)).map(this::toDomain);
}
@Override
public Optional<StoredFile> 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();
}
}

View File

@@ -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).
* <p>
* 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 : "";
}
}

View File

@@ -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).
* <p>
* 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 : "";
}
}

View File

@@ -2,6 +2,7 @@ package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.loremind.domain.files.ports.FileStorage;
import com.loremind.domain.images.ports.ImageStorage; import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter; import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*; import com.loremind.infrastructure.persistence.entity.*;
@@ -60,6 +61,8 @@ public class ExportService {
private final RandomTableJpaRepository randomTableRepo; private final RandomTableJpaRepository randomTableRepo;
private final ImageJpaRepository imageRepo; private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage; private final ImageStorage imageStorage;
private final StoredFileJpaRepository storedFileRepo;
private final FileStorage fileStorage;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String appVersion; private final String appVersion;
@@ -79,6 +82,8 @@ public class ExportService {
RandomTableJpaRepository randomTableRepo, RandomTableJpaRepository randomTableRepo,
ImageJpaRepository imageRepo, ImageJpaRepository imageRepo,
ImageStorage imageStorage, ImageStorage imageStorage,
StoredFileJpaRepository storedFileRepo,
FileStorage fileStorage,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) { @Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo; this.gameSystemRepo = gameSystemRepo;
@@ -97,6 +102,8 @@ public class ExportService {
this.randomTableRepo = randomTableRepo; this.randomTableRepo = randomTableRepo;
this.imageRepo = imageRepo; this.imageRepo = imageRepo;
this.imageStorage = imageStorage; this.imageStorage = imageStorage;
this.storedFileRepo = storedFileRepo;
this.fileStorage = fileStorage;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev"; this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
} }
@@ -140,10 +147,12 @@ public class ExportService {
.map(this::toRandomTableDto).toList(); .map(this::toRandomTableDto).toList();
List<ContentExport.ImageDto> images = imageRepo.findAll().stream() List<ContentExport.ImageDto> images = imageRepo.findAll().stream()
.map(this::toImageDto).toList(); .map(this::toImageDto).toList();
List<ContentExport.StoredFileDto> storedFiles = storedFileRepo.findAll().stream()
.map(this::toStoredFileDto).toList();
return new ContentExport(manifest, gameSystems, lores, loreNodes, templates, return new ContentExport(manifest, gameSystems, lores, loreNodes, templates,
pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies, pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies,
itemCatalogs, randomTables, images); itemCatalogs, randomTables, images, storedFiles);
} }
/** /**
@@ -184,6 +193,24 @@ public class ExportService {
zip.closeEntry(); zip.closeEntry();
} }
} }
// Binaires fichiers (battlemaps : media + sidecar) : ceux references par
// les scenes. Stockes a part sous "files/<storageKey>".
Set<String> referencedFiles = collectReferencedFileStorageKeys(export);
Set<String> 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) { } catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du zip d'export", e); throw new UncheckedIOException("Echec de la generation du zip d'export", e);
} }
@@ -198,15 +225,12 @@ public class ExportService {
Set<String> keys = new LinkedHashSet<>(); Set<String> keys = new LinkedHashSet<>();
for (ContentExport.ArcDto a : export.arcs()) { for (ContentExport.ArcDto a : export.arcs()) {
addAll(keys, a.illustrationImageIds()); addAll(keys, a.illustrationImageIds());
addAll(keys, a.mapImageIds());
} }
for (ContentExport.ChapterDto c : export.chapters()) { for (ContentExport.ChapterDto c : export.chapters()) {
addAll(keys, c.illustrationImageIds()); addAll(keys, c.illustrationImageIds());
addAll(keys, c.mapImageIds());
} }
for (ContentExport.SceneDto s : export.scenes()) { for (ContentExport.SceneDto s : export.scenes()) {
addAll(keys, s.illustrationImageIds()); addAll(keys, s.illustrationImageIds());
addAll(keys, s.mapImageIds());
} }
for (ContentExport.CharacterDto c : export.characters()) { for (ContentExport.CharacterDto c : export.characters()) {
add(keys, c.portraitImageId()); add(keys, c.portraitImageId());
@@ -229,6 +253,29 @@ public class ExportService {
return keys; 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<String> collectReferencedFileStorageKeys(ContentExport export) {
java.util.Map<String, String> keyById = new java.util.HashMap<>();
for (ContentExport.StoredFileDto f : export.storedFiles()) {
if (f.id() != null) keyById.put(f.id().toString(), f.storageKey());
}
Set<String> 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<String> keys, java.util.Map<String, String> 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<String> keys, String key) { private void add(Set<String> keys, String key) {
if (key != null && !key.isBlank()) keys.add(key); if (key != null && !key.isBlank()) keys.add(key);
} }
@@ -282,14 +329,14 @@ public class ExportService {
e.getType() != null ? e.getType().name() : null, e.getType() != null ? e.getType().name() : null,
e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(), e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(),
e.getRewards(), e.getResolution(), e.getRelatedPageIds(), e.getRewards(), e.getResolution(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getMapImageIds()); e.getIllustrationImageIds());
} }
private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) { private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) {
return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(), return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(),
e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(), e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(),
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(), e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
e.getRelatedPageIds(), e.getIllustrationImageIds(), e.getMapImageIds()); e.getRelatedPageIds(), e.getIllustrationImageIds());
} }
private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) { private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) {
@@ -298,7 +345,8 @@ public class ExportService {
e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(), e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(),
e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(), e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(),
e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(), 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) { private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) {
@@ -347,4 +395,9 @@ public class ExportService {
return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(), return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey()); e.getSizeBytes(), e.getStorageKey());
} }
private ContentExport.StoredFileDto toStoredFileDto(StoredFileJpaEntity e) {
return new ContentExport.StoredFileDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey());
}
} }

View File

@@ -62,6 +62,7 @@ public class ImportService {
private final ItemCatalogJpaRepository itemCatalogRepo; private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo; private final RandomTableJpaRepository randomTableRepo;
private final ImageImporter imageImporter; private final ImageImporter imageImporter;
private final StoredFileImporter storedFileImporter;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ImportService(GameSystemJpaRepository gameSystemRepo, public ImportService(GameSystemJpaRepository gameSystemRepo,
@@ -79,6 +80,7 @@ public class ImportService {
ItemCatalogJpaRepository itemCatalogRepo, ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo, RandomTableJpaRepository randomTableRepo,
ImageImporter imageImporter, ImageImporter imageImporter,
StoredFileImporter storedFileImporter,
ObjectMapper objectMapper) { ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo; this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo; this.loreRepo = loreRepo;
@@ -95,6 +97,7 @@ public class ImportService {
this.itemCatalogRepo = itemCatalogRepo; this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo; this.randomTableRepo = randomTableRepo;
this.imageImporter = imageImporter; this.imageImporter = imageImporter;
this.storedFileImporter = storedFileImporter;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
} }
@@ -106,8 +109,9 @@ public class ImportService {
ImportResult.Builder result = new ImportResult.Builder(); 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); imageImporter.importImages(export, archive.imageBinaries(), result);
storedFileImporter.importFiles(export, archive.fileBinaries(), result);
// 3. Insertion top-down + maps de remapping. // 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>(); Map<Long, Long> gameSystemMap = new HashMap<>();
@@ -223,7 +227,6 @@ public class ImportService {
e.setResolution(d.resolution()); e.setResolution(d.resolution());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds()); e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
arcMap.put(d.id(), arcRepo.save(e).getId()); arcMap.put(d.id(), arcRepo.save(e).getId());
} }
result.count("arcs", arcMap.size()); result.count("arcs", arcMap.size());
@@ -299,7 +302,6 @@ public class ImportService {
e.setNarrativeStakes(d.narrativeStakes()); e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds()); e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
chapterMap.put(d.id(), chapterRepo.save(e).getId()); chapterMap.put(d.id(), chapterRepo.save(e).getId());
} }
result.count("chapters", chapterMap.size()); result.count("chapters", chapterMap.size());
@@ -373,7 +375,10 @@ public class ImportService {
e.setEnemyIds(d.enemyIds()); // remappe plus bas e.setEnemyIds(d.enemyIds()); // remappe plus bas
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds()); 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.setBranches(d.branches()); // remappe plus bas
e.setRooms(d.rooms()); // Rooms: UUID, non remappes e.setRooms(d.rooms()); // Rooms: UUID, non remappes
sceneMap.put(d.id(), sceneRepo.save(e).getId()); sceneMap.put(d.id(), sceneRepo.save(e).getId());
@@ -462,8 +467,10 @@ public class ImportService {
// ----- Lecture de l'archive ----- // ----- Lecture de l'archive -----
/** Contenu déballé d'un zip d'import : le {@code data.json} parsé + les binaires d'images. */ /** Contenu déballé d'un zip d'import : {@code data.json} + binaires images + fichiers. */
private record ParsedArchive(ContentExport export, Map<String, byte[]> imageBinaries) { private record ParsedArchive(ContentExport export,
Map<String, byte[]> imageBinaries,
Map<String, byte[]> fileBinaries) {
} }
/** /**
@@ -473,6 +480,7 @@ public class ImportService {
private ParsedArchive parseArchive(InputStream zipStream) { private ParsedArchive parseArchive(InputStream zipStream) {
ContentExport export = null; ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
Map<String, byte[]> fileBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) { try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry; ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
@@ -484,6 +492,11 @@ public class ImportService {
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext"). // c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length()); String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip)); 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(); zip.closeEntry();
} }
@@ -493,7 +506,7 @@ public class ImportService {
if (export == null) { if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable"); throw new IllegalArgumentException("Archive invalide : data.json introuvable");
} }
return new ParsedArchive(export, imageBinaries); return new ParsedArchive(export, imageBinaries, fileBinaries);
} }
// ----- Helpers divers ----- // ----- Helpers divers -----

View File

@@ -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}).
* <p>
* 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<String, byte[]> fileBinaries,
ImportResult.Builder result) {
Map<String, ContentExport.StoredFileDto> 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<String, byte[]> 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 <T> List<T> nullSafe(List<T> 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;
}
}

View File

@@ -35,7 +35,8 @@ public record ContentExport(
List<EnemyDto> enemies, List<EnemyDto> enemies,
List<ItemCatalogDto> itemCatalogs, List<ItemCatalogDto> itemCatalogs,
List<RandomTableDto> randomTables, List<RandomTableDto> randomTables,
List<ImageDto> images List<ImageDto> images,
List<StoredFileDto> storedFiles
) { ) {
/** /**
@@ -124,8 +125,7 @@ public record ContentExport(
String rewards, String rewards,
String resolution, String resolution,
List<String> relatedPageIds, List<String> relatedPageIds,
List<String> illustrationImageIds, List<String> illustrationImageIds
List<String> mapImageIds
) {} ) {}
public record ChapterDto( public record ChapterDto(
@@ -144,8 +144,7 @@ public record ContentExport(
String playerObjectives, String playerObjectives,
String narrativeStakes, String narrativeStakes,
List<String> relatedPageIds, List<String> relatedPageIds,
List<String> illustrationImageIds, List<String> illustrationImageIds
List<String> mapImageIds
) {} ) {}
public record SceneDto( public record SceneDto(
@@ -166,7 +165,8 @@ public record ContentExport(
List<String> enemyIds, List<String> enemyIds,
List<String> relatedPageIds, List<String> relatedPageIds,
List<String> illustrationImageIds, List<String> illustrationImageIds,
List<String> mapImageIds, String battlemapMediaFileId,
String battlemapDataFileId,
List<SceneBranch> branches, List<SceneBranch> branches,
List<Room> rooms List<Room> rooms
) {} ) {}
@@ -262,4 +262,17 @@ public record ContentExport(
long sizeBytes, long sizeBytes,
String storageKey String storageKey
) {} ) {}
/**
* Metadonnees d'un fichier generique (battlemap : media + sidecar JSON).
* Le binaire voyage a part dans le zip sous {@code files/<storageKey>}.
* 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
) {}
} }

View File

@@ -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}).
* <p>
* 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<String, Integer> counts
) {}
/** Contenu de data.json : campagne + listes a plat + index des assets. */
public record Data(
String formatVersion,
Campaign campaign,
List<Arc> arcs,
List<Quest> quests,
List<Scene> scenes,
List<Persona> npcs,
List<Persona> enemies,
List<Asset> 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<String> illustrationAssetIds
) {}
public record Quest(
String id, String arcId, String name, String description, int order, String icon,
String playerObjectives, String narrativeStakes, String gmNotes,
List<Map<String, Object>> prerequisites, List<String> 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<String> enemyIds,
List<String> illustrationAssetIds, Battlemap battlemap,
List<Branch> branches, List<Room> 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<String> enemyIds,
String loot, String traps, String gmNotes, Integer floor, int order,
List<String> illustrationAssetIds, Battlemap battlemap, List<RoomBranch> 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<Field> fields
) {}
/** Champ de fiche resolu : {type, label} + selon le type value | entries | assetIds. */
public record Field(String type, String label, String value, List<Entry> entries, List<String> assetIds) {}
public record Entry(String label, String value) {}
public record Asset(String id, String kind, String path, String filename, String mime, long sizeBytes) {}
}

View File

@@ -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<BinaryRef> 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<TemplateField> npcTemplate = resolveTemplate(campaign.getGameSystemId(), true);
List<TemplateField> enemyTemplate = resolveTemplate(campaign.getGameSystemId(), false);
AssetRegistry assets = new AssetRegistry();
// Arcs -> Quetes -> Scenes (a plat + refs parent, tries par order).
List<FoundryBundle.Arc> arcs = new ArrayList<>();
List<FoundryBundle.Quest> quests = new ArrayList<>();
List<FoundryBundle.Scene> scenes = new ArrayList<>();
List<ArcJpaEntity> 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<FoundryBundle.Persona> 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<FoundryBundle.Persona> 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<String, Integer> 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<String> 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<FoundryBundle.Branch> branches(List<SceneBranch> branches) {
if (branches == null) return List.of();
List<FoundryBundle.Branch> out = new ArrayList<>();
for (SceneBranch b : branches) out.add(new FoundryBundle.Branch(b.label(), b.targetSceneId(), b.condition()));
return out;
}
private List<FoundryBundle.Room> rooms(List<Room> rooms, AssetRegistry assets) {
if (rooms == null) return List.of();
List<FoundryBundle.Room> out = new ArrayList<>();
for (Room r : rooms) {
List<FoundryBundle.RoomBranch> 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<Map<String, Object>> prerequisites(List<Prerequisite> prereqs) {
if (prereqs == null || prereqs.isEmpty()) return List.of();
List<Map<String, Object>> 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<TemplateField> 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<FoundryBundle.Field> fields(List<TemplateField> template,
Map<String, String> values,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<String>> imageValues,
AssetRegistry assets) {
List<FoundryBundle.Field> out = new ArrayList<>();
// Repli : pas de template -> paires brutes label=cle.
if (template == null || template.isEmpty()) {
if (values != null) {
for (Map.Entry<String, String> 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<String, String> inner = keyValueValues != null ? keyValueValues.get(f.getName()) : null;
List<String> labels = f.getLabels();
if (inner != null && labels != null) {
List<FoundryBundle.Entry> 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<String> ids = imageValues != null ? imageValues.get(f.getName()) : null;
List<String> 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<String, FoundryBundle.Asset> byId = new LinkedHashMap<>();
private final List<BinaryRef> binaries = new ArrayList<>();
/** Enregistre une image par son ID LoreMind, retourne l'assetId ("img-<id>") 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<String> images(List<String> imageIds) {
if (imageIds == null) return List.of();
List<String> 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-<id>") 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<FoundryBundle.Asset> assets() { return new ArrayList<>(byId.values()); }
List<BinaryRef> binaries() { return binaries; }
}
// ----- Helpers -----
private static <T> List<T> sortByOrder(List<T> list, java.util.function.ToIntFunction<T> order) {
List<T> copy = new ArrayList<>(list);
copy.sort(Comparator.comparingInt(order));
return copy;
}
private static List<String> copy(List<String> 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) : "";
}
}

View File

@@ -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).
* <p>
* 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<StoredFileDTO> 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<StoredFileDTO> getMetadata(@PathVariable String id) {
return fileService.getById(id)
.map(f -> ResponseEntity.ok(toDTO(f)))
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{id}/content")
public ResponseEntity<InputStreamResource> getContent(@PathVariable String id) {
Optional<StoredFile> 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<Void> 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;
}
}

View File

@@ -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).
* <p>
* {@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<StreamingResponseBody> 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;
}
}

View File

@@ -35,7 +35,4 @@ public class ArcDTO {
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */ /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
} }

View File

@@ -46,7 +46,4 @@ public class ChapterDTO {
/** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */ /** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans. */
private List<String> mapImageIds = new ArrayList<>();
} }

View File

@@ -39,8 +39,11 @@ public class SceneDTO {
/** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */ /** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */
private List<String> illustrationImageIds = new ArrayList<>(); private List<String> illustrationImageIds = new ArrayList<>();
/** IDs des images utilisees comme cartes / plans (outil de table). */ /** Battlemap Foundry : ID du fichier media (image/video). Null = pas de carte. */
private List<String> mapImageIds = new ArrayList<>(); 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. */ /** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
private List<SceneBranchDTO> branches = new ArrayList<>(); private List<SceneBranchDTO> branches = new ArrayList<>();

View File

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

View File

@@ -34,7 +34,6 @@ public class ArcMapper {
dto.setResolution(arc.getResolution()); dto.setResolution(arc.getResolution());
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds())); dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds())); dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
dto.setMapImageIds(copyList(arc.getMapImageIds()));
return dto; return dto;
} }
@@ -58,7 +57,6 @@ public class ArcMapper {
.resolution(dto.getResolution()) .resolution(dto.getResolution())
.relatedPageIds(copyList(dto.getRelatedPageIds())) .relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build(); .build();
} }

View File

@@ -41,7 +41,6 @@ public class ChapterMapper {
dto.setNarrativeStakes(chapter.getNarrativeStakes()); dto.setNarrativeStakes(chapter.getNarrativeStakes());
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds())); dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds())); dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
dto.setMapImageIds(copyList(chapter.getMapImageIds()));
return dto; return dto;
} }
@@ -61,7 +60,6 @@ public class ChapterMapper {
.narrativeStakes(dto.getNarrativeStakes()) .narrativeStakes(dto.getNarrativeStakes())
.relatedPageIds(copyList(dto.getRelatedPageIds())) .relatedPageIds(copyList(dto.getRelatedPageIds()))
.illustrationImageIds(copyList(dto.getIllustrationImageIds())) .illustrationImageIds(copyList(dto.getIllustrationImageIds()))
.mapImageIds(copyList(dto.getMapImageIds()))
.build(); .build();
} }

View File

@@ -49,9 +49,8 @@ public class SceneMapper {
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
? new ArrayList<>(scene.getIllustrationImageIds()) ? new ArrayList<>(scene.getIllustrationImageIds())
: new ArrayList<>()); : new ArrayList<>());
dto.setMapImageIds(scene.getMapImageIds() != null dto.setBattlemapMediaFileId(scene.getBattlemapMediaFileId());
? new ArrayList<>(scene.getMapImageIds()) dto.setBattlemapDataFileId(scene.getBattlemapDataFileId());
: new ArrayList<>());
dto.setBranches(toBranchDTOs(scene.getBranches())); dto.setBranches(toBranchDTOs(scene.getBranches()));
dto.setRooms(toRoomDTOs(scene.getRooms())); dto.setRooms(toRoomDTOs(scene.getRooms()));
return dto; return dto;
@@ -86,9 +85,8 @@ public class SceneMapper {
.illustrationImageIds(dto.getIllustrationImageIds() != null .illustrationImageIds(dto.getIllustrationImageIds() != null
? new ArrayList<>(dto.getIllustrationImageIds()) ? new ArrayList<>(dto.getIllustrationImageIds())
: new ArrayList<>()) : new ArrayList<>())
.mapImageIds(dto.getMapImageIds() != null .battlemapMediaFileId(dto.getBattlemapMediaFileId())
? new ArrayList<>(dto.getMapImageIds()) .battlemapDataFileId(dto.getBattlemapDataFileId())
: new ArrayList<>())
.branches(toBranchDomain(dto.getBranches())) .branches(toBranchDomain(dto.getBranches()))
.rooms(toRoomDomain(dto.getRooms())) .rooms(toRoomDomain(dto.getRooms()))
.build(); .build();

View File

@@ -86,10 +86,11 @@ minio.access-key=${MINIO_ACCESS_KEY:minioadmin}
minio.secret-key=${MINIO_SECRET_KEY:minioadmin} minio.secret-key=${MINIO_SECRET_KEY:minioadmin}
minio.bucket=${MINIO_BUCKET:loremind-images} minio.bucket=${MINIO_BUCKET:loremind-images}
# Limites d'upload multipart (MB) : images ET PDF de regles. Releve a 64 Mo # Limites d'upload multipart (MB) : images, PDF de regles ET battlemaps video.
# pour accepter les livres de regles illustres (le Brain plafonne aussi a 60 Mo). # Releve a 128 Mo pour accepter les cartes animees (.mp4) sorties de Dungeon
spring.servlet.multipart.max-file-size=64MB # Alchemist & co (le Brain plafonne de son cote a 60 Mo pour les PDF).
spring.servlet.multipart.max-request-size=64MB 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 # Mode demo : masque Settings/Export cote front et bloque les PUT /api/settings
# cote serveur. Activer via DEMO_MODE=true sur les deploiements publics. # cote serveur. Activer via DEMO_MODE=true sur les deploiements publics.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

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

View File

@@ -26,10 +26,8 @@ class ArcTest {
assertNotNull(arc.getRelatedPageIds(), "relatedPageIds ne doit jamais etre null"); assertNotNull(arc.getRelatedPageIds(), "relatedPageIds ne doit jamais etre null");
assertNotNull(arc.getIllustrationImageIds(), "illustrationImageIds 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.getRelatedPageIds().isEmpty());
assertTrue(arc.getIllustrationImageIds().isEmpty()); assertTrue(arc.getIllustrationImageIds().isEmpty());
assertTrue(arc.getMapImageIds().isEmpty());
} }
@Test @Test
@@ -37,12 +35,10 @@ class ArcTest {
Arc arc = Arc.builder() Arc arc = Arc.builder()
.relatedPageIds(List.of("page-a", "page-b")) .relatedPageIds(List.of("page-a", "page-b"))
.illustrationImageIds(List.of("img-1")) .illustrationImageIds(List.of("img-1"))
.mapImageIds(List.of("map-1", "map-2", "map-3"))
.build(); .build();
assertEquals(2, arc.getRelatedPageIds().size()); assertEquals(2, arc.getRelatedPageIds().size());
assertEquals(1, arc.getIllustrationImageIds().size()); assertEquals(1, arc.getIllustrationImageIds().size());
assertEquals(3, arc.getMapImageIds().size());
} }
@Test @Test

View File

@@ -26,10 +26,8 @@ class ChapterTest {
assertNotNull(chapter.getRelatedPageIds()); assertNotNull(chapter.getRelatedPageIds());
assertNotNull(chapter.getIllustrationImageIds()); assertNotNull(chapter.getIllustrationImageIds());
assertNotNull(chapter.getMapImageIds());
assertTrue(chapter.getRelatedPageIds().isEmpty()); assertTrue(chapter.getRelatedPageIds().isEmpty());
assertTrue(chapter.getIllustrationImageIds().isEmpty()); assertTrue(chapter.getIllustrationImageIds().isEmpty());
assertTrue(chapter.getMapImageIds().isEmpty());
} }
@Test @Test
@@ -37,12 +35,10 @@ class ChapterTest {
Chapter chapter = Chapter.builder() Chapter chapter = Chapter.builder()
.relatedPageIds(List.of("page-x")) .relatedPageIds(List.of("page-x"))
.illustrationImageIds(List.of("img-1", "img-2")) .illustrationImageIds(List.of("img-1", "img-2"))
.mapImageIds(List.of("map-dungeon"))
.build(); .build();
assertEquals(1, chapter.getRelatedPageIds().size()); assertEquals(1, chapter.getRelatedPageIds().size());
assertEquals(2, chapter.getIllustrationImageIds().size()); assertEquals(2, chapter.getIllustrationImageIds().size());
assertEquals(1, chapter.getMapImageIds().size());
} }
@Test @Test

View File

@@ -6,13 +6,14 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests unitaires du domaine pour Scene. * Tests unitaires du domaine pour Scene.
* Scene est la plus riche en champs : on valide les quatre collections * Scene est la plus riche en champs : on valide les collections @Builder.Default
* @Builder.Default (relatedPageIds, illustrationImageIds, mapImageIds, branches) * (relatedPageIds, illustrationImageIds, branches), la battlemap (paire media +
* et la preservation de l'ensemble des champs narratifs. * sidecar, nullable) et la preservation de l'ensemble des champs narratifs.
*/ */
class SceneTest { class SceneTest {
@@ -27,12 +28,24 @@ class SceneTest {
assertNotNull(scene.getRelatedPageIds()); assertNotNull(scene.getRelatedPageIds());
assertNotNull(scene.getIllustrationImageIds()); assertNotNull(scene.getIllustrationImageIds());
assertNotNull(scene.getMapImageIds());
assertNotNull(scene.getBranches(), "branches ne doit jamais etre null — une scene sans branche est une feuille"); assertNotNull(scene.getBranches(), "branches ne doit jamais etre null — une scene sans branche est une feuille");
assertTrue(scene.getRelatedPageIds().isEmpty()); assertTrue(scene.getRelatedPageIds().isEmpty());
assertTrue(scene.getIllustrationImageIds().isEmpty()); assertTrue(scene.getIllustrationImageIds().isEmpty());
assertTrue(scene.getMapImageIds().isEmpty());
assertTrue(scene.getBranches().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 @Test

View File

@@ -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.
* <p>
* 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");
}
}
}

View File

@@ -19,8 +19,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/** /**
* Tests d'integration pour PostgresArcRepository. * Tests d'integration pour PostgresArcRepository.
* Valide la persistance des 3 collections JSONB (relatedPageIds, * Valide la persistance des collections JSONB (relatedPageIds,
* illustrationImageIds, mapImageIds) et des 5 champs narratifs enrichis. * illustrationImageIds) et des 5 champs narratifs enrichis.
*/ */
@SpringBootTest @SpringBootTest
@Transactional @Transactional
@@ -44,7 +44,6 @@ class PostgresArcRepositoryTest {
.themes("trahison").stakes("survie").gmNotes("secret").rewards("artefact").resolution("couronnement") .themes("trahison").stakes("survie").gmNotes("secret").rewards("artefact").resolution("couronnement")
.relatedPageIds(List.of("page-1")) .relatedPageIds(List.of("page-1"))
.illustrationImageIds(List.of("img-a", "img-b")) .illustrationImageIds(List.of("img-a", "img-b"))
.mapImageIds(List.of("map-1"))
.build(); .build();
Arc saved = repository.save(arc); Arc saved = repository.save(arc);
@@ -56,7 +55,6 @@ class PostgresArcRepositoryTest {
assertEquals("secret", r.getGmNotes()); assertEquals("secret", r.getGmNotes());
assertEquals(List.of("page-1"), r.getRelatedPageIds()); assertEquals(List.of("page-1"), r.getRelatedPageIds());
assertEquals(2, r.getIllustrationImageIds().size()); assertEquals(2, r.getIllustrationImageIds().size());
assertEquals(List.of("map-1"), r.getMapImageIds());
} }
@Test @Test
@@ -86,6 +84,5 @@ class PostgresArcRepositoryTest {
assertNotNull(r.getRelatedPageIds()); assertNotNull(r.getRelatedPageIds());
assertTrue(r.getRelatedPageIds().isEmpty()); assertTrue(r.getRelatedPageIds().isEmpty());
assertTrue(r.getIllustrationImageIds().isEmpty()); assertTrue(r.getIllustrationImageIds().isEmpty());
assertTrue(r.getMapImageIds().isEmpty());
} }
} }

View File

@@ -43,7 +43,6 @@ class PostgresChapterRepositoryTest {
.gmNotes("note secrete").playerObjectives("trouver l'indice").narrativeStakes("si echec allie meurt") .gmNotes("note secrete").playerObjectives("trouver l'indice").narrativeStakes("si echec allie meurt")
.relatedPageIds(List.of("page-x")) .relatedPageIds(List.of("page-x"))
.illustrationImageIds(List.of("img-1")) .illustrationImageIds(List.of("img-1"))
.mapImageIds(List.of("map-donjon"))
.build(); .build();
Chapter saved = repository.save(chapter); Chapter saved = repository.save(chapter);
@@ -54,7 +53,6 @@ class PostgresChapterRepositoryTest {
assertEquals("note secrete", r.getGmNotes()); assertEquals("note secrete", r.getGmNotes());
assertEquals("trouver l'indice", r.getPlayerObjectives()); assertEquals("trouver l'indice", r.getPlayerObjectives());
assertEquals(List.of("page-x"), r.getRelatedPageIds()); assertEquals(List.of("page-x"), r.getRelatedPageIds());
assertEquals(List.of("map-donjon"), r.getMapImageIds());
} }
@Test @Test

View File

@@ -54,7 +54,7 @@ class PostgresSceneRepositoryTest {
.choicesConsequences("Si attaque -> gardes").combatDifficulty("facile").enemies("3 brigands") .choicesConsequences("Si attaque -> gardes").combatDifficulty("facile").enemies("3 brigands")
.relatedPageIds(List.of("page-aubergiste")) .relatedPageIds(List.of("page-aubergiste"))
.illustrationImageIds(List.of("img-1", "img-2")) .illustrationImageIds(List.of("img-1", "img-2"))
.mapImageIds(List.of("plan-taverne")) .battlemapMediaFileId("100").battlemapDataFileId("101")
.build(); .build();
Scene saved = repository.save(scene); Scene saved = repository.save(scene);
@@ -65,7 +65,8 @@ class PostgresSceneRepositoryTest {
assertEquals("Taverne du Dragon d'Or", r.getLocation()); assertEquals("Taverne du Dragon d'Or", r.getLocation());
assertEquals("Piege cache", r.getGmSecretNotes()); assertEquals("Piege cache", r.getGmSecretNotes());
assertEquals(2, r.getIllustrationImageIds().size()); assertEquals(2, r.getIllustrationImageIds().size());
assertEquals(List.of("plan-taverne"), r.getMapImageIds()); assertEquals("100", r.getBattlemapMediaFileId());
assertEquals("101", r.getBattlemapDataFileId());
} }
@Test @Test

View File

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

View File

@@ -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<FoundryBundle.Field> 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"));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,261 @@
#!/usr/bin/env bash
#
# Construit l'application de BUREAU Linux de DM Loremind sous forme d'AppImage,
# sans Docker. Equivalent Linux de installers/desktop/build-windows.ps1.
#
# Pipeline complet "local-first" :
# 1. Build du front Angular (web/ -> web/dist/web)
# 2. Prep du Brain (Python standalone) (brain/ -> dist-embed-linux : python relocatable + deps + sources)
# 3. Build du Core en fat jar + front (core/ -> target/*.jar, profil Maven "desktop")
# 4. Assemblage de la charge utile (jar + brain) dans un dossier d'entree jpackage
# 5. jpackage --type app-image -> image applicative + JRE embarque
# 6. appimagetool -> DM_Loremind-x86_64.AppImage (1 fichier, toutes distros)
#
# L'app resultante se lance d'un double-clic (chmod +x puis ./*.AppImage) : le Core
# demarre en profil Spring "local" (H2 fichier + stockage filesystem) et lance lui-meme
# le Brain en sidecar. Aucune dependance externe (ni Docker, ni Java, ni Python).
#
# PREREQUIS (machine de BUILD Linux uniquement, PAS chez l'utilisateur final) :
# - JDK 21+ avec jpackage dans le PATH (Temurin OK).
# - Node.js + npm (build Angular) ; Maven via le wrapper du repo.
# - curl, tar, et FUSE *ou* (sur CI sans FUSE) on lance appimagetool en
# --appimage-extract-and-run (gere automatiquement ci-dessous).
# - Internet (telecharge python-build-standalone + appimagetool).
#
# IMPORTANT : jpackage NE cross-compile PAS. Ce script DOIT tourner sur Linux
# (machine, WSL, ou runner ubuntu-latest). Il ne produira rien d'utile sous Windows.
#
# Projet : DM Loremind — assistant pour Maitres de Jeu de JDR — Licence AGPL-3.0
set -euo pipefail
# --- Args ------------------------------------------------------------------
VERSION=""
SKIP_FRONT=0; SKIP_BRAIN=0; SKIP_JAR=0
while [[ $# -gt 0 ]]; do
case "$1" in
--version) VERSION="$2"; shift 2 ;;
--skip-front) SKIP_FRONT=1; shift ;;
--skip-brain) SKIP_BRAIN=1; shift ;;
--skip-jar) SKIP_JAR=1; shift ;;
*) echo "Option inconnue : $1" >&2; exit 2 ;;
esac
done
step() { printf '\033[36m==> %s\033[0m\n' "$1"; }
ok() { printf '\033[32m OK %s\033[0m\n' "$1"; }
err() { printf '\033[31m XX %s\033[0m\n' "$1" >&2; }
# --- Chemins ---------------------------------------------------------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
WEB_DIR="$REPO_ROOT/web"
BRAIN_DIR="$REPO_ROOT/brain"
CORE_DIR="$REPO_ROOT/core"
STAGE_DIR="$CORE_DIR/target/dist-input" # charge utile jpackage
OUT_DIR="$CORE_DIR/target/dist-out" # image applicative + AppImage produits
BRAIN_EMBED="$BRAIN_DIR/dist-embed-linux" # staging du brain empaquete (Linux)
ICON_PNG="$SCRIPT_DIR/app-icon.png" # icone de l'app (jpackage Linux exige un PNG)
APP_NAME="DM Loremind"
# python-build-standalone (Astral) : equivalent Linux du Python embeddable de
# python.org (qui n'existe que sous Windows). Build "install_only" = Python complet
# RELOCATABLE (pip inclus), donc on installe les deps directement dedans.
PY_MINOR="3.12" # doit matcher python:3.12 du Docker
# --- Version (numerique) ---------------------------------------------------
if [[ -z "$VERSION" ]]; then
# Viser la version DU PROJET (artifactId loremind-core), pas le <version> du parent.
VERSION="$(grep -oPz '<artifactId>loremind-core</artifactId>\s*<version>\K[0-9]+\.[0-9]+\.[0-9]+' \
"$CORE_DIR/pom.xml" | tr -d '\0' | head -1 || true)"
[[ -z "$VERSION" ]] && VERSION="0.0.0"
fi
echo
printf '\033[35m============================================================\033[0m\n'
printf '\033[35m DM Loremind - Build AppImage Linux (v%s)\033[0m\n' "$VERSION"
printf '\033[35m============================================================\033[0m\n'
# --- Verif outils ----------------------------------------------------------
command -v jpackage >/dev/null 2>&1 || { err "jpackage introuvable dans le PATH. Installez un JDK 21+ (Temurin)."; exit 1; }
# --- 1. Front Angular ------------------------------------------------------
if [[ $SKIP_FRONT -eq 0 ]]; then
step "Build du front Angular"
( cd "$WEB_DIR"
[[ -d node_modules ]] || npm ci
npm run build )
ok "Front construit (web/dist/web)"
else step "Front : saute (--skip-front)"; fi
# --- 2. Brain (Python standalone relocatable, PAS d'exe gele) --------------
if [[ $SKIP_BRAIN -eq 0 ]]; then
step "Preparation du Brain (python-build-standalone $PY_MINOR)"
rm -rf "$BRAIN_EMBED"; mkdir -p "$BRAIN_EMBED"
# a) Resoudre l'asset install_only linux-gnu pour $PY_MINOR dans la derniere
# release PBS (la date du tag change a chaque release -> on la decouvre).
step " Resolution de l'archive python-build-standalone"
# URL EPINGLEE de secours (pas d'API) : version figee comme le build Windows,
# utilisee si la resolution dynamique echoue (rate limit, API HS, regex sans match).
PY_PIN='3.12.8'
PBS_DATE='20241219'
pbs_fallback="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_DATE}/cpython-${PY_PIN}+${PBS_DATE}-x86_64-unknown-linux-gnu-install_only.tar.gz"
# Appel API AUTHENTIFIE si un token est dispo (le runner partage est rate-limite
# a 60 req/h en anonyme -> 403 ; avec token : 1000/h). Tolerant : tout echec
# bascule sur l'URL epinglee (pas d'abort silencieux du a set -e).
auth=()
[[ -n "${GITHUB_TOKEN:-}" ]] && auth=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
api_json="$(curl -fsSL "${auth[@]}" \
https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest 2>/dev/null || true)"
# NB: dans browser_download_url, le '+' (version+date) est URL-encode en %2B
# -> on accepte les deux. On vise le x86_64 BASELINE (pas _v2/_v3/_v4, qui
# exigent un CPU plus recent) et install_only (pas _stripped).
asset_url="$(printf '%s' "$api_json" \
| grep -m1 -oE "https://[^\"]*cpython-${PY_MINOR//./\\.}\.[0-9]+(%2B|\+)[0-9]+-x86_64-unknown-linux-gnu-install_only\.tar\.gz" \
|| true)"
if [[ -z "$asset_url" ]]; then
echo " (resolution API indisponible -> URL epinglee de secours)"
asset_url="$pbs_fallback"
fi
echo " Telechargement $asset_url"
curl -fsSL "$asset_url" -o "$BRAIN_EMBED/python.tar.gz"
# L'archive install_only s'extrait en un dossier 'python/' (bin/python3, lib/...).
tar -xzf "$BRAIN_EMBED/python.tar.gz" -C "$BRAIN_EMBED"
rm -f "$BRAIN_EMBED/python.tar.gz"
PYBIN="$BRAIN_EMBED/python/bin/python3"
[[ -x "$PYBIN" ]] || { err "python3 introuvable apres extraction ($PYBIN)."; exit 1; }
# b) Installer les deps DANS le python standalone (site-packages interne).
# Python complet => pip fonctionne directement, pas de bricolage ._pth/--target.
"$PYBIN" -m pip install --upgrade pip >/dev/null
"$PYBIN" -m pip install -r "$BRAIN_DIR/requirements.txt"
# c) Copier les sources du Brain + le point d'entree.
cp -r "$BRAIN_DIR/app" "$BRAIN_EMBED/app"
cp "$BRAIN_DIR/run_local.py" "$BRAIN_EMBED/run_local.py"
# d) OCR (Tesseract) : pas bundle pour l'instant sous Linux (libs partagees
# lourdes a embarquer proprement). Degradation gracieuse : si l'utilisateur a
# 'tesseract-ocr' (apt/dnf), pytesseract le trouve sur le PATH et l'OCR des
# scans marche ; sinon PDF born-digital OK, scans signales. Cf. run_local.py.
ok "Brain prepare (brain/dist-embed-linux : python standalone + deps + sources)"
else step "Brain : saute (--skip-brain)"; fi
# --- 3. Core (fat jar avec front embarque) ---------------------------------
if [[ $SKIP_JAR -eq 0 ]]; then
step "Build du Core (fat jar, profil desktop)"
( cd "$CORE_DIR"
chmod +x ./mvnw
# -Pdesktop : copie web/dist/web dans le jar (classpath:/static).
./mvnw -q -Pdesktop -DskipTests clean package )
ok "Core construit (core/target)"
else step "Core : saute (--skip-jar)"; fi
# --- 4. Assemblage de la charge utile --------------------------------------
step "Assemblage de la charge utile jpackage"
rm -rf "$STAGE_DIR"; mkdir -p "$STAGE_DIR"
# Le jar repackage Spring Boot (executable) — on ignore le *.jar.original.
jar="$(find "$CORE_DIR/target" -maxdepth 1 -name 'loremind-core-*.jar' ! -name '*.original' | head -1)"
[[ -z "$jar" ]] && { err "Jar introuvable dans core/target. Relancez sans --skip-jar."; exit 1; }
cp "$jar" "$STAGE_DIR/loremind-core.jar"
# Le Brain (python standalone + deps + sources) -> stage/brain/
[[ -d "$BRAIN_EMBED/python" ]] || { err "Brain introuvable (brain/dist-embed-linux). Relancez sans --skip-brain."; exit 1; }
mkdir -p "$STAGE_DIR/brain"
cp -r "$BRAIN_EMBED/." "$STAGE_DIR/brain/"
# Splash : copie dans la charge utile -> atterrit dans $APPDIR/splash.png (cf. -splash plus bas).
[[ -f "$SCRIPT_DIR/splash.png" ]] && cp "$SCRIPT_DIR/splash.png" "$STAGE_DIR/splash.png"
ok "Charge utile prete ($STAGE_DIR)"
# --- 5. jpackage -> app-image ----------------------------------------------
step "Generation de l'image applicative (app-image) via jpackage"
[[ -f "$ICON_PNG" ]] || { err "Icone manquante : $ICON_PNG (PNG requis sous Linux)."; exit 1; }
rm -rf "$OUT_DIR"; mkdir -p "$OUT_DIR"
# $APPDIR : substitue par jpackage AU LANCEMENT par le dossier applicatif
# (lib/app), qui contient le jar ET le dossier brain copie depuis --input.
# Le Brain se lance via python3 + run_local.py. brain.sidecar.command est une
# LISTE : les deux chemins separes par une virgule (aucun chemin Linux n'en
# contient) sont bindes en List<String> par Spring. NB: $APPDIR doit rester
# LITTERAL ici (quote simple) — c'est jpackage, pas bash, qui le resout.
BRAIN_CMD='$APPDIR/brain/python/bin/python3,$APPDIR/brain/run_local.py'
jpackage \
--type app-image \
--name "$APP_NAME" \
--app-version "$VERSION" \
--vendor 'IGML Creation' \
--icon "$ICON_PNG" \
--input "$STAGE_DIR" \
--main-jar loremind-core.jar \
--main-class org.springframework.boot.loader.launch.JarLauncher \
--dest "$OUT_DIR" \
--java-options '-Dspring.profiles.active=local' \
--java-options "-Dbrain.sidecar.command=$BRAIN_CMD" \
--java-options '-splash:$APPDIR/splash.png'
APPIMG_SRC="$OUT_DIR/$APP_NAME" # dossier produit par jpackage (avec espace)
[[ -d "$APPIMG_SRC" ]] || { err "app-image jpackage introuvable ($APPIMG_SRC)."; exit 1; }
ok "Image applicative prete ($APPIMG_SRC)"
# --- 6. AppImage via appimagetool ------------------------------------------
step "Empaquetage AppImage (toutes distros)"
APPDIR="$OUT_DIR/AppDir"
rm -rf "$APPDIR"; mkdir -p "$APPDIR/usr"
# a) L'app-image jpackage va sous AppDir/usr/ (le lanceur bin/* resout lib/ en
# relatif via ../lib -> reste coherent).
cp -r "$APPIMG_SRC/." "$APPDIR/usr/"
# b) Icone a la racine de l'AppDir (nom = Icon= du .desktop, SANS extension).
cp "$ICON_PNG" "$APPDIR/dm-loremind.png"
mkdir -p "$APPDIR/usr/share/icons/hicolor/256x256/apps"
cp "$ICON_PNG" "$APPDIR/usr/share/icons/hicolor/256x256/apps/dm-loremind.png"
# c) Fichier .desktop (a la racine + copie standard).
cat > "$APPDIR/dm-loremind.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=DM Loremind
Comment=Assistant pour Maitres de Jeu de JDR
Exec=DM Loremind
Icon=dm-loremind
Categories=Game;Utility;
Terminal=false
EOF
mkdir -p "$APPDIR/usr/share/applications"
cp "$APPDIR/dm-loremind.desktop" "$APPDIR/usr/share/applications/dm-loremind.desktop"
# d) AppRun : point d'entree de l'AppImage -> lance le binaire jpackage.
cat > "$APPDIR/AppRun" <<'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "$0")")"
exec "$HERE/usr/bin/DM Loremind" "$@"
EOF
chmod +x "$APPDIR/AppRun"
# e) appimagetool (telecharge si absent). --appimage-extract-and-run : evite
# d'exiger FUSE (absent des runners CI).
# /!\ HORS de $OUT_DIR : sinon ce .AppImage (l'outil) serait pris pour un livrable
# et attache a la release a cote du vrai DM_Loremind-*.AppImage.
TOOL="$CORE_DIR/target/appimagetool-x86_64.AppImage"
if [[ ! -x "$TOOL" ]]; then
curl -fsSL -o "$TOOL" \
https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x "$TOOL"
fi
# APPIMAGE_EXTRACT_AND_RUN : permet d'EXECUTER l'AppImage appimagetool SANS FUSE
# (absent des runners CI) — il se self-extrait au lieu de se monter via fuse.
OUT_FILE="$OUT_DIR/DM_Loremind-${VERSION}-x86_64.AppImage"
ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --appimage-extract-and-run "$APPDIR" "$OUT_FILE"
chmod +x "$OUT_FILE"
echo
printf '\033[32m============================================================\033[0m\n'
printf '\033[32m AppImage genere !\033[0m\n'
printf '\033[32m %s\033[0m\n' "$OUT_FILE"
printf '\033[32m============================================================\033[0m\n'

View File

@@ -57,6 +57,9 @@ $BrainDir = Join-Path $RepoRoot 'brain'
$CoreDir = Join-Path $RepoRoot 'core' $CoreDir = Join-Path $RepoRoot 'core'
$StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage $StageDir = Join-Path $CoreDir 'target\dist-input' # charge utile jpackage
$OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit $OutDir = Join-Path $CoreDir 'target\dist-out' # .msi produit
$IconFile = Join-Path $PSScriptRoot 'app-icon.ico' # icone de l'app (.msi + raccourcis)
$WixDir = Join-Path $PSScriptRoot 'wix' # main.wxs surcharge (case "Lancer" en fin d'install)
$SplashFile = Join-Path $PSScriptRoot 'splash.png' # ecran de demarrage (affiche par la JVM via -splash)
# --- Version (numerique pour MSI) ------------------------------------------ # --- Version (numerique pour MSI) ------------------------------------------
if (-not $Version) { if (-not $Version) {
@@ -190,6 +193,9 @@ if (-not (Test-Path $BrainEmbed)) { Write-Err "Brain introuvable (brain/dist-emb
$stageBrain = Join-Path $StageDir 'brain' $stageBrain = Join-Path $StageDir 'brain'
New-Item -ItemType Directory -Force -Path $stageBrain | Out-Null New-Item -ItemType Directory -Force -Path $stageBrain | Out-Null
Copy-Item (Join-Path $BrainEmbed '*') $stageBrain -Recurse Copy-Item (Join-Path $BrainEmbed '*') $stageBrain -Recurse
# Splash : copie dans la charge utile -> atterrit dans $APPDIR\splash.png (cf. -splash plus bas).
if (Test-Path $SplashFile) { Copy-Item $SplashFile (Join-Path $StageDir 'splash.png') }
Write-Ok "Charge utile prete ($StageDir)" Write-Ok "Charge utile prete ($StageDir)"
# --- 5. jpackage -> .msi --------------------------------------------------- # --- 5. jpackage -> .msi ---------------------------------------------------
@@ -213,20 +219,32 @@ $brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py'
# #
# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent # Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent
# etre rediriges vers un fichier via une option ulterieure si besoin de debug. # etre rediriges vers un fichier via une option ulterieure si besoin de debug.
# Icone de l'application (.msi, raccourcis bureau/menu). Doit etre un .ico Windows
# multi-resolution (16/32/48/256). Absente -> jpackage retomberait sur l'icone Java
# par defaut : on echoue tot avec un message clair plutot que de livrer ca.
if (-not (Test-Path $IconFile)) {
Write-Err "Icone manquante : $IconFile"
Write-Err "Depose l'icone de l'app (image DM, .ico multi-resolution) a cet emplacement."
exit 1
}
jpackage ` jpackage `
--type msi ` --type msi `
--name LoreMind ` --name 'DM Loremind' `
--app-version $Version ` --app-version $Version `
--vendor 'IGML Creation' ` --vendor 'IGML Creation' `
--icon $IconFile `
--resource-dir $WixDir `
--input $StageDir ` --input $StageDir `
--main-jar loremind-core.jar ` --main-jar loremind-core.jar `
--main-class org.springframework.boot.loader.launch.JarLauncher ` --main-class org.springframework.boot.loader.launch.JarLauncher `
--dest $OutDir ` --dest $OutDir `
--java-options '-Dspring.profiles.active=local' ` --java-options '-Dspring.profiles.active=local' `
--java-options "-Dbrain.sidecar.command=$brainCmd" ` --java-options "-Dbrain.sidecar.command=$brainCmd" `
--java-options '-splash:$APPDIR\splash.png' `
--win-per-user-install ` --win-per-user-install `
--win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' ` --win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' `
--win-menu --win-menu-group 'LoreMind' ` --win-menu --win-menu-group 'DM Loremind' `
--win-shortcut --win-dir-chooser --win-shortcut --win-dir-chooser
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
main.wxs SURCHARGE (option resource-dir de jpackage) : copie du template du
JDK 21, plus une case "Lancer DM Loremind" sur l'ecran de fin (ExitDialog).
A garder synchronise avec le template du JDK du build (JDK 21). Pour re-extraire
la base : jimage extract (include regex main.wxs) depuis $JAVA_HOME/lib/modules.
Les seuls ajouts maison sont marques [DM Loremind].
Lancement : installeur per-user, donc contexte utilisateur ; une custom action
Directory + ExeCommand suffit (pas besoin de WixUtilExtension).
-->
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<?ifdef JpIsSystemWide ?>
<?define JpInstallScope="perMachine"?>
<?else?>
<?define JpInstallScope="perUser"?>
<?endif?>
<?define JpProductLanguage=1033 ?>
<?define JpInstallerVersion=200 ?>
<?define JpCompressedMsi=yes ?>
<?include $(var.JpConfigDir)/overrides.wxi ?>
<?ifdef JpAllowUpgrades ?>
<?define JpUpgradeVersionOnlyDetectUpgrade="no"?>
<?else?>
<?define JpUpgradeVersionOnlyDetectUpgrade="yes"?>
<?endif?>
<?ifdef JpAllowDowngrades ?>
<?define JpUpgradeVersionOnlyDetectDowngrade="no"?>
<?else?>
<?define JpUpgradeVersionOnlyDetectDowngrade="yes"?>
<?endif?>
<Product
Id="$(var.JpProductCode)"
Name="$(var.JpAppName)"
Language="$(var.JpProductLanguage)"
Version="$(var.JpAppVersion)"
Manufacturer="$(var.JpAppVendor)"
UpgradeCode="$(var.JpProductUpgradeCode)">
<Package
Description="$(var.JpAppDescription)"
Manufacturer="$(var.JpAppVendor)"
InstallerVersion="$(var.JpInstallerVersion)"
Compressed="$(var.JpCompressedMsi)"
InstallScope="$(var.JpInstallScope)" Platform="x64"
/>
<Media Id="1" Cabinet="Data.cab" EmbedCab="yes" />
<Upgrade Id="$(var.JpProductUpgradeCode)">
<UpgradeVersion
OnlyDetect="$(var.JpUpgradeVersionOnlyDetectUpgrade)"
Property="JP_UPGRADABLE_FOUND"
Maximum="$(var.JpAppVersion)"
MigrateFeatures="yes"
IncludeMaximum="$(var.JpUpgradeVersionOnlyDetectUpgrade)" />
<UpgradeVersion
OnlyDetect="$(var.JpUpgradeVersionOnlyDetectDowngrade)"
Property="JP_DOWNGRADABLE_FOUND"
Minimum="$(var.JpAppVersion)"
MigrateFeatures="yes"
IncludeMinimum="$(var.JpUpgradeVersionOnlyDetectDowngrade)" />
</Upgrade>
<?ifndef JpAllowUpgrades ?>
<CustomAction Id="JpDisallowUpgrade" Error="!(loc.DisallowUpgradeErrorMessage)" />
<?endif?>
<?ifndef JpAllowDowngrades ?>
<CustomAction Id="JpDisallowDowngrade" Error="!(loc.DowngradeErrorMessage)" />
<?endif?>
<Binary Id="JpCaDll" SourceFile="wixhelper.dll"/>
<CustomAction Id="JpFindRelatedProducts" BinaryKey="JpCaDll" DllEntry="FindRelatedProductsEx" />
<!-- Standard required root -->
<Directory Id="TARGETDIR" Name="SourceDir"/>
<Feature Id="DefaultFeature" Title="!(loc.MainFeatureTitle)" Level="1">
<ComponentGroupRef Id="Shortcuts"/>
<ComponentGroupRef Id="Files"/>
<ComponentGroupRef Id="FileAssociations"/>
</Feature>
<CustomAction Id="JpSetARPINSTALLLOCATION" Property="ARPINSTALLLOCATION" Value="[INSTALLDIR]" />
<CustomAction Id="JpSetARPCOMMENTS" Property="ARPCOMMENTS" Value="$(var.JpAppDescription)" />
<CustomAction Id="JpSetARPCONTACT" Property="ARPCONTACT" Value="$(var.JpAppVendor)" />
<CustomAction Id="JpSetARPSIZE" Property="ARPSIZE" Value="$(var.JpAppSizeKb)" />
<?ifdef JpHelpURL ?>
<CustomAction Id="JpSetARPHELPLINK" Property="ARPHELPLINK" Value="$(var.JpHelpURL)" />
<?endif?>
<?ifdef JpAboutURL ?>
<CustomAction Id="JpSetARPURLINFOABOUT" Property="ARPURLINFOABOUT" Value="$(var.JpAboutURL)" />
<?endif?>
<?ifdef JpUpdateURL ?>
<CustomAction Id="JpSetARPURLUPDATEINFO" Property="ARPURLUPDATEINFO" Value="$(var.JpUpdateURL)" />
<?endif?>
<?ifdef JpIcon ?>
<Property Id="ARPPRODUCTICON" Value="JpARPPRODUCTICON"/>
<Icon Id="JpARPPRODUCTICON" SourceFile="$(var.JpIcon)"/>
<?endif?>
<UIRef Id="JpUI"/>
<!-- ===================== [DM Loremind] ===================== -->
<!-- Case « Lancer l'application » sur l'ExitDialog standard de WixUI.
WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT : libellé de la case (la rend visible).
WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 : cochée par défaut. -->
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Lancer $(var.JpAppName)" />
<Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />
<!-- Lance le launcher jpackage ([INSTALLDIR]<AppName>.exe). asyncNoWait : on
n'attend pas la fin de l'app. Contexte utilisateur (install per-user). -->
<CustomAction Id="JpLaunchApplication"
Directory="INSTALLDIR"
ExeCommand="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot;"
Return="asyncNoWait"
Impersonate="yes" />
<UI>
<Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="JpLaunchApplication" Order="1">WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed</Publish>
</UI>
<!-- =================== fin [DM Loremind] =================== -->
<InstallExecuteSequence>
<Custom Action="JpSetARPINSTALLLOCATION" After="CostFinalize">Not Installed</Custom>
<Custom Action="JpSetARPCOMMENTS" After="CostFinalize">Not Installed</Custom>
<Custom Action="JpSetARPCONTACT" After="CostFinalize">Not Installed</Custom>
<Custom Action="JpSetARPSIZE" After="CostFinalize">Not Installed</Custom>
<?ifdef JpHelpURL ?>
<Custom Action="JpSetARPHELPLINK" After="CostFinalize">Not Installed</Custom>
<?endif?>
<?ifdef JpAboutURL ?>
<Custom Action="JpSetARPURLINFOABOUT" After="CostFinalize">Not Installed</Custom>
<?endif?>
<?ifdef JpUpdateURL ?>
<Custom Action="JpSetARPURLUPDATEINFO" After="CostFinalize">Not Installed</Custom>
<?endif?>
<?ifndef JpAllowUpgrades ?>
<Custom Action="JpDisallowUpgrade" After="JpFindRelatedProducts">JP_UPGRADABLE_FOUND</Custom>
<?endif?>
<?ifndef JpAllowDowngrades ?>
<Custom Action="JpDisallowDowngrade" After="JpFindRelatedProducts">JP_DOWNGRADABLE_FOUND</Custom>
<?endif?>
<RemoveExistingProducts Before="CostInitialize"/>
<Custom Action="JpFindRelatedProducts" After="FindRelatedProducts"/>
</InstallExecuteSequence>
<InstallUISequence>
<Custom Action="JpFindRelatedProducts" After="FindRelatedProducts"/>
</InstallUISequence>
</Product>
</Wix>

View File

@@ -1,7 +1,7 @@
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
Installeur officiel de LoreMindMJ pour Windows 10/11. Installeur officiel de DM Loremind pour Windows 10/11.
.DESCRIPTION .DESCRIPTION
Script d'installation pas-a-pas qui : Script d'installation pas-a-pas qui :
@@ -18,7 +18,7 @@
Le code source de ce script est public et auditable a l'adresse indiquee dans .LINK. Le code source de ce script est public et auditable a l'adresse indiquee dans .LINK.
.PARAMETER InstallDir .PARAMETER InstallDir
Dossier d'installation. Defaut : %LOCALAPPDATA%\LoreMind Dossier d'installation. Defaut : %LOCALAPPDATA%\DM Loremind
.PARAMETER ComposeUrl .PARAMETER ComposeUrl
URL du fichier docker-compose.yml a recuperer. Defaut : version officielle du depot. URL du fichier docker-compose.yml a recuperer. Defaut : version officielle du depot.
@@ -39,7 +39,7 @@
.NOTES .NOTES
Auteur : ietm64 Auteur : ietm64
Licence : AGPL-3.0 Licence : AGPL-3.0
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR Projet : DM Loremind - assistant pour Maitres de Jeu de JDR
Version : 0.8.3 Version : 0.8.3
.LINK .LINK
@@ -48,7 +48,7 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$InstallDir = "$env:LOCALAPPDATA\LoreMind", [string]$InstallDir = "$env:LOCALAPPDATA\DM Loremind",
[string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml", [string]$ComposeUrl = "https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/docker-compose.yml",
[int]$WebPort = 8081, [int]$WebPort = 8081,
[switch]$NonInteractive [switch]$NonInteractive
@@ -142,7 +142,7 @@ if (-not (Test-Admin)) {
Write-Host "" Write-Host ""
Write-Host "============================================================" Write-Host "============================================================"
Write-Host " LoreMindMJ - Installeur Windows" -ForegroundColor Magenta Write-Host " DM Loremind - Installeur Windows" -ForegroundColor Magenta
Write-Host "============================================================" Write-Host "============================================================"
Write-Host "" Write-Host ""
@@ -250,7 +250,7 @@ if ($llmProvider -eq 'onemin' -and -not $NonInteractive) {
# pare-feu pour que Docker puisse l'atteindre sans exposer le port. # pare-feu pour que Docker puisse l'atteindre sans exposer le port.
# 2. Embarque : Ollama tourne dans un conteneur Docker dedie (profile local-ollama). # 2. Embarque : Ollama tourne dans un conteneur Docker dedie (profile local-ollama).
# 3. Aucun : on n'installe rien tout de suite. L'utilisateur configurera # 3. Aucun : on n'installe rien tout de suite. L'utilisateur configurera
# Ollama plus tard via la page Parametres de LoreMind. # Ollama plus tard via la page Parametres de DM Loremind.
$ollamaMode = 'embedded' # valeurs : 'host' | 'embedded' | 'none' $ollamaMode = 'embedded' # valeurs : 'host' | 'embedded' | 'none'
$ollamaBaseUrl = 'http://ollama:11434' $ollamaBaseUrl = 'http://ollama:11434'
if ($llmProvider -eq 'ollama') { if ($llmProvider -eq 'ollama') {
@@ -298,7 +298,7 @@ if ($llmProvider -eq 'ollama') {
# sera installe plus tard sur l'hote. L'utilisateur peut aussi changer # sera installe plus tard sur l'hote. L'utilisateur peut aussi changer
# l'URL via la page Parametres pour pointer vers un Ollama distant. # l'URL via la page Parametres pour pointer vers un Ollama distant.
$ollamaBaseUrl = 'http://host.docker.internal:11434' $ollamaBaseUrl = 'http://host.docker.internal:11434'
Write-Warn2 "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind." Write-Warn2 "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de DM Loremind."
} }
} }
@@ -404,7 +404,7 @@ if ($ollamaMode -eq 'embedded' -and $llmProvider -eq 'ollama') {
$url = "http://localhost:$WebPort" $url = "http://localhost:$WebPort"
Write-Host "" Write-Host ""
Write-Host "============================================================" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green
Write-Host " LoreMindMJ est lance !" -ForegroundColor Green Write-Host " DM Loremind est lance !" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green
Write-Host " URL : $url" Write-Host " URL : $url"
Write-Host " Identifiant : $adminUser" Write-Host " Identifiant : $adminUser"

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ========================================================================== # ==========================================================================
# Installeur LoreMindMJ pour Linux (Debian/Ubuntu/Fedora/Arch) # Installeur DM Loremind pour Linux (Debian/Ubuntu/Fedora/Arch)
# Usage : # Usage :
# curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash # curl -fsSL https://raw.githubusercontent.com/IGMLcreation/LoreMind/main/installers/install.sh | bash
# ========================================================================== # ==========================================================================
@@ -71,7 +71,7 @@ install_docker() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo echo
echo "============================================================" echo "============================================================"
echo -e " ${c_cyan}LoreMindMJ - Installeur Linux${c_off}" echo -e " ${c_cyan}DM Loremind - Installeur Linux${c_off}"
echo "============================================================" echo "============================================================"
echo echo
@@ -169,7 +169,7 @@ if [ "$LLM_PROVIDER" = "ollama" ]; then
# sera installe plus tard sur l'hote. L'utilisateur peut aussi # sera installe plus tard sur l'hote. L'utilisateur peut aussi
# changer l'URL via la page Parametres pour un Ollama distant. # changer l'URL via la page Parametres pour un Ollama distant.
OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434" OLLAMA_BASE_URL_VAL="http://host.docker.internal:11434"
warn "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de LoreMind." warn "Aucun Ollama ne sera installe pour le moment. Configurez-le plus tard via la page Parametres de DM Loremind."
;; ;;
esac esac
fi fi
@@ -268,7 +268,7 @@ fi
URL="http://localhost:${WEB_PORT}" URL="http://localhost:${WEB_PORT}"
echo echo
echo -e "${c_green}============================================================${c_off}" echo -e "${c_green}============================================================${c_off}"
echo -e "${c_green} LoreMindMJ est lance !${c_off}" echo -e "${c_green} DM Loremind est lance !${c_off}"
echo -e "${c_green}============================================================${c_off}" echo -e "${c_green}============================================================${c_off}"
echo " URL : $URL" echo " URL : $URL"
echo " Identifiant : $ADMIN_USERNAME" echo " Identifiant : $ADMIN_USERNAME"

View File

@@ -1,10 +1,10 @@
#Requires -Version 5.1 #Requires -Version 5.1
<# <#
.SYNOPSIS .SYNOPSIS
Configuration securisee d'Ollama hote pour LoreMindMJ (Windows). Configuration securisee d'Ollama hote pour DM Loremind (Windows).
.DESCRIPTION .DESCRIPTION
But : permettre au conteneur Docker LoreMind d'atteindre l'Ollama installe But : permettre au conteneur Docker DM Loremind d'atteindre l'Ollama installe
sur l'hote, SANS exposer Ollama sur le LAN ni Internet. sur l'hote, SANS exposer Ollama sur le LAN ni Internet.
Strategie (specifique a Docker Desktop / WSL2 sur Windows) : Strategie (specifique a Docker Desktop / WSL2 sur Windows) :

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================ # ============================================================================
# LoreMindMJ - Configuration securisee d'Ollama hote (Linux) # DM Loremind - Configuration securisee d'Ollama hote (Linux)
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# But : permettre au conteneur Docker de LoreMind d'atteindre l'Ollama # But : permettre au conteneur Docker de LoreMind d'atteindre l'Ollama
# installe sur l'hote, SANS l'exposer sur le LAN ni Internet. # installe sur l'hote, SANS l'exposer sur le LAN ni Internet.

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.3", "version": "1.0.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.3", "version": "1.0.0-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^21.2.16", "@angular/animations": "^21.2.16",
"@angular/common": "^21.2.16", "@angular/common": "^21.2.16",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.16.3", "version": "1.0.0-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -38,18 +38,6 @@
<small class="field-hint">{{ 'arcEdit.illustrationsHint' | translate }}</small> <small class="field-hint">{{ 'arcEdit.illustrationsHint' | translate }}</small>
</div> </div>
<!-- Cartes & plans -->
<div class="field">
<label>{{ 'arcEdit.mapsLabel' | translate }}</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">{{ 'arcEdit.mapsHint' | translate }}</small>
</div>
<div class="field"> <div class="field">
<label for="arc-edit-name">{{ 'arcEdit.nameLabel' | translate }}</label> <label for="arc-edit-name">{{ 'arcEdit.nameLabel' | translate }}</label>
<input <input

View File

@@ -71,8 +71,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */ /** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
/** IDs des images utilisees comme cartes / plans (outil de table). */
mapImageIds: string[] = [];
constructor( constructor(
private fb: FormBuilder, private fb: FormBuilder,
@@ -140,7 +138,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
this.relatedPageIds = [...(arc.relatedPageIds ?? [])]; this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
this.selectedIcon = arc.icon ?? null; this.selectedIcon = arc.icon ?? null;
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
this.mapImageIds = [...(arc.mapImageIds ?? [])];
this.pageTitleService.set(arc.name); this.pageTitleService.set(arc.name);
this.form.patchValue({ this.form.patchValue({
name: arc.name, name: arc.name,
@@ -172,7 +169,6 @@ export class ArcEditComponent implements OnInit, OnDestroy {
resolution: this.form.value.resolution, resolution: this.form.value.resolution,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
icon: this.selectedIcon icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),

View File

@@ -29,13 +29,6 @@
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
} }
<!-- Cartes & plans -->
@if ((arc.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'arcView.mapsTitle' | translate }}</h2>
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut. <!-- Vue Hub (scénario) : liste les quêtes et leurs conditions, sans statut.
Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. --> Le statut effectif et les actions de jeu vivent dans l'écran d'une Partie. -->
@if (arc.type === 'HUB') { @if (arc.type === 'HUB') {

View File

@@ -28,6 +28,10 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn-secondary" (click)="exportFoundry()" [disabled]="exportingFoundry">
<lucide-icon [img]="Download" [size]="14"></lucide-icon>
{{ (exportingFoundry ? 'campaignDetail.foundryExporting' : 'campaignDetail.foundryExport') | translate }}
</button>
<button type="button" class="btn-secondary" (click)="startEdit()"> <button type="button" class="btn-secondary" (click)="startEdit()">
<lucide-icon [img]="Pencil" [size]="14"></lucide-icon> <lucide-icon [img]="Pencil" [size]="14"></lucide-icon>
{{ 'common.edit' | translate }} {{ 'common.edit' | translate }}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Swords, Plus, Globe, Pencil, Trash2, Dices, Drama, Check, Play, Upload, Sparkles, Download } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
@@ -45,6 +45,10 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
readonly Play = Play; readonly Play = Play;
readonly Upload = Upload; readonly Upload = Upload;
readonly Sparkles = Sparkles; readonly Sparkles = Sparkles;
readonly Download = Download;
/** Export Foundry en cours (anti double-clic). */
exportingFoundry = false;
campaign: Campaign | null = null; campaign: Campaign | null = null;
arcs: Arc[] = []; arcs: Arc[] = [];
@@ -251,6 +255,33 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
} }
/** Télécharge le bundle Foundry de la campagne via un lien temporaire. */
exportFoundry(): void {
if (!this.campaign?.id || this.exportingFoundry) return;
this.exportingFoundry = true;
this.campaignService.exportFoundry(this.campaign.id).subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'foundry-export.zip';
// Certains navigateurs ignorent un click() sur un <a> détaché : on l'attache
// au DOM le temps du déclenchement, puis on nettoie.
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Révocation différée : libère l'URL sans annuler le téléchargement en cours.
setTimeout(() => URL.revokeObjectURL(url), 1000);
this.exportingFoundry = false;
},
error: (err) => {
// Ne pas avaler l'erreur en silence : visible en console pour diagnostic.
console.error('Échec de l\'export Foundry', err);
this.exportingFoundry = false;
}
});
}
openArc(arc: Arc): void { openArc(arc: Arc): void {
if (!this.campaign || !arc.id) return; if (!this.campaign || !arc.id) return;
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]); this.router.navigate(['/campaigns', this.campaign.id, 'arcs', arc.id]);

View File

@@ -55,17 +55,6 @@
<small class="field-hint">{{ 'chapterEdit.illustrationsHint' | translate }}</small> <small class="field-hint">{{ 'chapterEdit.illustrationsHint' | translate }}</small>
</div> </div>
<!-- Cartes & plans -->
<div class="field">
<label>{{ 'chapterEdit.maps' | translate }}</label>
<app-image-gallery
[imageIds]="mapImageIds"
[editable]="true"
[layout]="'MAPS'"
(imageIdsChange)="mapImageIds = $event">
</app-image-gallery>
<small class="field-hint">{{ 'chapterEdit.mapsHint' | translate }}</small>
</div>
<div class="field"> <div class="field">
<label for="chapter-edit-name">{{ 'chapterEdit.nameLabel' | translate }}</label> <label for="chapter-edit-name">{{ 'chapterEdit.nameLabel' | translate }}</label>

View File

@@ -69,7 +69,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
loreId: string | null = null; loreId: string | null = null;
relatedPageIds: string[] = []; relatedPageIds: string[] = [];
illustrationImageIds: string[] = []; illustrationImageIds: string[] = [];
mapImageIds: string[] = [];
/** Prérequis (donnée de scénario). */ /** Prérequis (donnée de scénario). */
prerequisites: Prerequisite[] = []; prerequisites: Prerequisite[] = [];
@@ -149,7 +148,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])]; this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
this.selectedIcon = chapter.icon ?? null; this.selectedIcon = chapter.icon ?? null;
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])]; this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
this.prerequisites = [...(chapter.prerequisites ?? [])]; this.prerequisites = [...(chapter.prerequisites ?? [])];
@@ -194,7 +192,6 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
prerequisites: this.prerequisites, prerequisites: this.prerequisites,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds,
icon: this.selectedIcon icon: this.selectedIcon
}).subscribe({ }).subscribe({
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]), next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),

View File

@@ -64,13 +64,6 @@
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
} }
<!-- Cartes & plans -->
@if ((chapter.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'chapterView.maps' | translate }}</h2>
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📖</span> {{ 'chapterView.synopsis' | translate }}</h2> <h2 class="view-section-title"><span class="view-section-icon">📖</span> {{ 'chapterView.synopsis' | translate }}</h2>
@if (chapter.description?.trim()) { @if (chapter.description?.trim()) {

View File

@@ -38,16 +38,40 @@
<small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small> <small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small>
</div> </div>
<!-- Cartes & plans (galerie editable, rendu maps) --> <!-- Battlemap Foundry : media + sidecar JSON Universal VTT (non affichee, export only) -->
<div class="field"> <div class="field">
<label>{{ 'sceneEdit.mapsLabel' | translate }}</label> <label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
<app-image-gallery <small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
[imageIds]="mapImageIds"
[editable]="true" <div class="battlemap-slot">
[layout]="'MAPS'" <span class="battlemap-slot-label">{{ 'sceneEdit.battlemapMedia' | translate }}</span>
(imageIdsChange)="mapImageIds = $event"> @if (battlemapMediaFileId) {
</app-image-gallery> <span class="battlemap-file">{{ battlemapMediaName || ('sceneEdit.battlemapAttached' | translate) }}</span>
<small class="field-hint">{{ 'sceneEdit.mapsHint' | translate }}</small> <button type="button" class="battlemap-remove" (click)="removeBattlemapMedia()">
{{ 'sceneEdit.battlemapRemove' | translate }}
</button>
} @else {
<label class="battlemap-pick">
{{ (battlemapUploadingMedia ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
<input type="file" accept="image/*,video/mp4,video/webm" hidden (change)="onBattlemapMediaSelected($event)" />
</label>
}
</div>
<div class="battlemap-slot">
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapData' | translate }}</span>
@if (battlemapDataFileId) {
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
<button type="button" class="battlemap-remove" (click)="removeBattlemapData()">
{{ 'sceneEdit.battlemapRemove' | translate }}
</button>
} @else {
<label class="battlemap-pick">
{{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
<input type="file" accept=".json,.dd2vtt,.uvtt,application/json" hidden (change)="onBattlemapDataSelected($event)" />
</label>
}
</div>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -3,6 +3,44 @@
max-width: 760px; 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. // Header local : titre à gauche, actions (Assistant IA) à droite.
.page-header { .page-header {
display: flex; display: flex;

View File

@@ -7,6 +7,7 @@ import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular'; import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { StoredFileService } from '../../../services/stored-file.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
import { NpcService } from '../../../services/npc.service'; import { NpcService } from '../../../services/npc.service';
import { RandomTableService } from '../../../services/random-table.service'; import { RandomTableService } from '../../../services/random-table.service';
@@ -70,7 +71,14 @@ export class SceneEditComponent implements OnInit, OnDestroy {
availableEnemies: Enemy[] = []; availableEnemies: Enemy[] = [];
enemyIds: string[] = []; enemyIds: string[] = [];
illustrationImageIds: 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. */ /** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
siblingScenes: Scene[] = []; siblingScenes: Scene[] = [];
@@ -87,6 +95,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private campaignService: CampaignService, private campaignService: CampaignService,
private storedFileService: StoredFileService,
private characterService: CharacterService, private characterService: CharacterService,
private npcService: NpcService, private npcService: NpcService,
private randomTableService: RandomTableService, private randomTableService: RandomTableService,
@@ -161,7 +170,18 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.enemyIds = [...(scene.enemyIds ?? [])]; this.enemyIds = [...(scene.enemyIds ?? [])];
this.selectedIcon = scene.icon ?? null; this.selectedIcon = scene.icon ?? null;
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])]; 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.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
this.branches = (scene.branches ?? []).map(b => ({ ...b })); this.branches = (scene.branches ?? []).map(b => ({ ...b }));
this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] })); this.rooms = (scene.rooms ?? []).map(r => ({ ...r, branches: [...(r.branches ?? [])] }));
@@ -200,7 +220,8 @@ export class SceneEditComponent implements OnInit, OnDestroy {
enemyIds: this.enemyIds, enemyIds: this.enemyIds,
relatedPageIds: this.relatedPageIds, relatedPageIds: this.relatedPageIds,
illustrationImageIds: this.illustrationImageIds, illustrationImageIds: this.illustrationImageIds,
mapImageIds: this.mapImageIds, battlemapMediaFileId: this.battlemapMediaFileId,
battlemapDataFileId: this.battlemapDataFileId,
branches: this.branches, branches: this.branches,
rooms: this.rooms, rooms: this.rooms,
icon: this.selectedIcon icon: this.selectedIcon
@@ -253,6 +274,53 @@ export class SceneEditComponent implements OnInit, OnDestroy {
this.branches[index].condition = value; 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 { ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant // Volontairement vide : la sidebar reste prise en charge par le composant
// suivant (autre sous-route ou le composant detail parent) qui appellera // suivant (autre sous-route ou le composant detail parent) qui appellera

View File

@@ -27,13 +27,6 @@
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery> <app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
</section> </section>
} }
<!-- Cartes & plans -->
@if ((scene.mapImageIds?.length ?? 0) > 0) {
<section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> {{ 'sceneView.mapsSectionTitle' | translate }}</h2>
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
</section>
}
<!-- Description courte --> <!-- Description courte -->
<section class="view-section"> <section class="view-section">
<h2 class="view-section-title"><span class="view-section-icon">📝</span> {{ 'sceneView.descriptionSectionTitle' | translate }}</h2> <h2 class="view-section-title"><span class="view-section-icon">📝</span> {{ 'sceneView.descriptionSectionTitle' | translate }}</h2>

View File

@@ -68,9 +68,6 @@ export interface Arc {
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */ /** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
illustrationImageIds?: string[]; 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) // Payload pour la création d'un Arc (pas d'id)
@@ -90,7 +87,6 @@ export interface ArcCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
export interface Chapter { export interface Chapter {
@@ -122,7 +118,6 @@ export interface Chapter {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
export interface ChapterCreate { export interface ChapterCreate {
@@ -140,7 +135,6 @@ export interface ChapterCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[];
} }
/** /**
@@ -238,7 +232,13 @@ export interface Scene {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: 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). */ /** Sorties narratives (graphe intra-chapitre). */
branches?: SceneBranch[]; branches?: SceneBranch[];
@@ -268,7 +268,8 @@ export interface SceneCreate {
relatedPageIds?: string[]; relatedPageIds?: string[];
illustrationImageIds?: string[]; illustrationImageIds?: string[];
mapImageIds?: string[]; battlemapMediaFileId?: string | null;
battlemapDataFileId?: string | null;
branches?: SceneBranch[]; branches?: SceneBranch[];
rooms?: Room[]; rooms?: Room[];
} }

View File

@@ -42,6 +42,11 @@ export class CampaignService {
return this.http.get<Campaign>(`${this.apiUrl}/${id}`); return this.http.get<Campaign>(`${this.apiUrl}/${id}`);
} }
/** Télécharge le bundle d'export Foundry de la campagne (.zip). */
exportFoundry(id: string): Observable<Blob> {
return this.http.get(`${this.apiUrl}/${id}/foundry-export`, { responseType: 'blob' });
}
createCampaign(campaign: CampaignCreate): Observable<Campaign> { createCampaign(campaign: CampaignCreate): Observable<Campaign> {
return this.http.post<Campaign>(this.apiUrl, campaign); return this.http.post<Campaign>(this.apiUrl, campaign);
} }

View File

@@ -77,15 +77,13 @@ export class LanguageService {
} }
private resolveInitialLang(): string { private resolveInitialLang(): string {
// 1) Choix mémorisé (sélecteur in-app) → prioritaire.
const stored = this.read(); const stored = this.read();
if (stored && this.languages.some((l) => l.code === stored)) { if (stored && this.languages.some((l) => l.code === stored)) {
return stored; return stored;
} }
const browser = this.translate.getBrowserLang(); // 2) Sinon, détection du système : français → fr, toute autre langue → en.
if (browser && this.languages.some((l) => l.code === browser)) { return this.translate.getBrowserLang() === 'fr' ? 'fr' : 'en';
return browser;
}
return this.defaultLang;
} }
private read(): string | null { private read(): string | null {

View File

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

View File

@@ -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<StoredFile> {
const form = new FormData();
form.append('file', file);
return this.http.post<StoredFile>(this.apiUrl, form);
}
getById(id: string): Observable<StoredFile> {
return this.http.get<StoredFile>(`${this.apiUrl}/${id}`);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** URL absolue du binaire (peu utile : la battlemap n'est pas affichee). */
contentUrl(id: string): string {
return `${this.apiUrl}/${id}/content`;
}
}

View File

@@ -2,8 +2,8 @@
<div class="sidebar-header"> <div class="sidebar-header">
<div class="logo"> <div class="logo">
<span class="logo-icon"></span> <img class="logo-img" src="assets/logo.png" alt="DM Loremind" />
<span class="logo-text">LoreMind</span> <span class="logo-text">DM Loremind</span>
</div> </div>
<p class="logo-subtitle">{{ 'sidebar.subtitle' | translate }}</p> <p class="logo-subtitle">{{ 'sidebar.subtitle' | translate }}</p>
</div> </div>
@@ -52,12 +52,6 @@
<lucide-icon [img]="Dices" [size]="16"></lucide-icon> <lucide-icon [img]="Dices" [size]="16"></lucide-icon>
<span>{{ 'sidebar.gameSystems' | translate }}</span> <span>{{ 'sidebar.gameSystems' | translate }}</span>
</button> </button>
@if (!config.demoMode) {
<button class="tool-btn">
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>{{ 'sidebar.vttExport' | translate }}</span>
</button>
}
@if (!config.demoMode) { @if (!config.demoMode) {
<button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')"> <button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<lucide-icon [img]="Settings" [size]="16"></lucide-icon> <lucide-icon [img]="Settings" [size]="16"></lucide-icon>

View File

@@ -18,6 +18,7 @@
} }
.logo-icon { font-size: 1.2rem; color: #6c63ff; } .logo-icon { font-size: 1.2rem; color: #6c63ff; }
.logo-img { width: 1.75rem; height: 1.75rem; border-radius: 6px; object-fit: contain; flex: 0 0 auto; }
.logo-text { font-size: 1.25rem; font-weight: 700; color: white; } .logo-text { font-size: 1.25rem; font-weight: 700; color: white; }
.logo-subtitle { .logo-subtitle {

View File

@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { LucideAngularModule, Search, Download, Settings, ArrowLeft, Dices } from 'lucide-angular'; import { LucideAngularModule, Search, Settings, ArrowLeft, Dices } from 'lucide-angular';
import { LayoutService } from '../services/layout.service'; import { LayoutService } from '../services/layout.service';
import { GlobalSearchService } from '../services/global-search.service'; import { GlobalSearchService } from '../services/global-search.service';
import { ConfigService } from '../services/config.service'; import { ConfigService } from '../services/config.service';
@@ -21,7 +21,6 @@ export class SidebarComponent implements OnInit {
currentRoute = ''; currentRoute = '';
readonly Search = Search; readonly Search = Search;
readonly Download = Download;
readonly Settings = Settings; readonly Settings = Settings;
readonly ArrowLeft = ArrowLeft; readonly ArrowLeft = ArrowLeft;
readonly Dices = Dices; readonly Dices = Dices;

View File

@@ -588,6 +588,8 @@
"newPlaythrough": "New playthrough", "newPlaythrough": "New playthrough",
"noPlaythrough": "No playthroughs. Create one to start a session with a table.", "noPlaythrough": "No playthroughs. Create one to start a session with a table.",
"defaultPlaythroughName": "New playthrough {{n}}", "defaultPlaythroughName": "New playthrough {{n}}",
"foundryExport": "Export to Foundry",
"foundryExporting": "Exporting…",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",
@@ -909,8 +911,14 @@
"aiAssistantTitle": "Open the AI Assistant to discuss this scene", "aiAssistantTitle": "Open the AI Assistant to discuss this scene",
"illustrationsLabel": "Illustrations", "illustrationsLabel": "Illustrations",
"illustrationsHint": "NPC portraits, visual mood, evocative scenes...", "illustrationsHint": "NPC portraits, visual mood, evocative scenes...",
"mapsLabel": "Maps & plans", "battlemapLabel": "Battlemap (Foundry export)",
"mapsHint": "Location plans, tactical maps, diagrams usable at the table.", "battlemapHint": "Battle map to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Not displayed in the app.",
"battlemapMedia": "Media (image / video)",
"battlemapData": "Data (.json / .dd2vtt)",
"battlemapChoose": "Choose a file",
"battlemapUploading": "Uploading...",
"battlemapAttached": "File attached",
"battlemapRemove": "Remove",
"nameLabel": "Scene title *", "nameLabel": "Scene title *",
"namePlaceholder": "E.g.: Arrival at the village", "namePlaceholder": "E.g.: Arrival at the village",
"descriptionLabel": "Short description *", "descriptionLabel": "Short description *",

View File

@@ -588,6 +588,8 @@
"newPlaythrough": "Nouvelle partie", "newPlaythrough": "Nouvelle partie",
"noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.", "noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.",
"defaultPlaythroughName": "Nouvelle partie {{n}}", "defaultPlaythroughName": "Nouvelle partie {{n}}",
"foundryExport": "Exporter pour Foundry",
"foundryExporting": "Export…",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",
@@ -909,8 +911,14 @@
"aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène",
"illustrationsLabel": "Illustrations", "illustrationsLabel": "Illustrations",
"illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...",
"mapsLabel": "Cartes & plans", "battlemapLabel": "Battlemap (export Foundry)",
"mapsHint": "Plans du lieu, cartes tactiques, schemas utilisables a la table.", "battlemapHint": "Carte de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Non affichee dans l'appli.",
"battlemapMedia": "Media (image / video)",
"battlemapData": "Donnees (.json / .dd2vtt)",
"battlemapChoose": "Choisir un fichier",
"battlemapUploading": "Envoi...",
"battlemapAttached": "Fichier attache",
"battlemapRemove": "Retirer",
"nameLabel": "Titre de la scène *", "nameLabel": "Titre de la scène *",
"namePlaceholder": "Ex: Arrivée au village", "namePlaceholder": "Ex: Arrivée au village",
"descriptionLabel": "Description courte *", "descriptionLabel": "Description courte *",

View File

@@ -58,6 +58,8 @@
"newPlaythrough": "New playthrough", "newPlaythrough": "New playthrough",
"noPlaythrough": "No playthroughs. Create one to start a session with a table.", "noPlaythrough": "No playthroughs. Create one to start a session with a table.",
"defaultPlaythroughName": "New playthrough {{n}}", "defaultPlaythroughName": "New playthrough {{n}}",
"foundryExport": "Export to Foundry",
"foundryExporting": "Exporting…",
"gameSystemChange": { "gameSystemChange": {
"title": "Change the game system?", "title": "Change the game system?",
"message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.", "message": "You are about to change the game system of this campaign. This also changes the template for PC and NPC sheets.",

View File

@@ -58,6 +58,8 @@
"newPlaythrough": "Nouvelle partie", "newPlaythrough": "Nouvelle partie",
"noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.", "noPlaythrough": "Aucune partie. Créez-en une pour démarrer une session avec une table.",
"defaultPlaythroughName": "Nouvelle partie {{n}}", "defaultPlaythroughName": "Nouvelle partie {{n}}",
"foundryExport": "Exporter pour Foundry",
"foundryExporting": "Export…",
"gameSystemChange": { "gameSystemChange": {
"title": "Changer le système de jeu ?", "title": "Changer le système de jeu ?",
"message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.", "message": "Vous êtes sur le point de changer le système de jeu de cette campagne. Cela change également le template des fiches de PJ et PNJ.",

View File

@@ -17,8 +17,14 @@
"aiAssistantTitle": "Open the AI Assistant to discuss this scene", "aiAssistantTitle": "Open the AI Assistant to discuss this scene",
"illustrationsLabel": "Illustrations", "illustrationsLabel": "Illustrations",
"illustrationsHint": "NPC portraits, visual mood, evocative scenes...", "illustrationsHint": "NPC portraits, visual mood, evocative scenes...",
"mapsLabel": "Maps & plans", "battlemapLabel": "Battlemap (Foundry export)",
"mapsHint": "Location plans, tactical maps, diagrams usable at the table.", "battlemapHint": "Battle map to export to Foundry: media (image/video) + Universal VTT .json/.dd2vtt file (Dungeon Alchemist, Dungeondraft...). Not displayed in the app.",
"battlemapMedia": "Media (image / video)",
"battlemapData": "Data (.json / .dd2vtt)",
"battlemapChoose": "Choose a file",
"battlemapUploading": "Uploading...",
"battlemapAttached": "File attached",
"battlemapRemove": "Remove",
"nameLabel": "Scene title *", "nameLabel": "Scene title *",
"namePlaceholder": "E.g.: Arrival at the village", "namePlaceholder": "E.g.: Arrival at the village",
"descriptionLabel": "Short description *", "descriptionLabel": "Short description *",

View File

@@ -17,8 +17,14 @@
"aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène", "aiAssistantTitle": "Ouvrir l'Assistant IA pour dialoguer autour de cette scène",
"illustrationsLabel": "Illustrations", "illustrationsLabel": "Illustrations",
"illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...", "illustrationsHint": "Portraits des PNJ, ambiance visuelle, scenes evocatrices...",
"mapsLabel": "Cartes & plans", "battlemapLabel": "Battlemap (export Foundry)",
"mapsHint": "Plans du lieu, cartes tactiques, schemas utilisables a la table.", "battlemapHint": "Carte de combat a exporter vers Foundry : media (image/video) + fichier .json/.dd2vtt Universal VTT (Dungeon Alchemist, Dungeondraft...). Non affichee dans l'appli.",
"battlemapMedia": "Media (image / video)",
"battlemapData": "Donnees (.json / .dd2vtt)",
"battlemapChoose": "Choisir un fichier",
"battlemapUploading": "Envoi...",
"battlemapAttached": "Fichier attache",
"battlemapRemove": "Retirer",
"nameLabel": "Titre de la scène *", "nameLabel": "Titre de la scène *",
"namePlaceholder": "Ex: Arrivée au village", "namePlaceholder": "Ex: Arrivée au village",
"descriptionLabel": "Description courte *", "descriptionLabel": "Description courte *",

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
web/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -2,7 +2,7 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>LoreMind</title> <title>DM Loremind</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">