Compare commits
10 Commits
v0.17.0
...
18f5a260b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 18f5a260b0 | |||
| c0e1da78c8 | |||
| 5bc038acd8 | |||
| f347dcd357 | |||
| 46fea8d53c | |||
| e05b26563f | |||
| dc66199177 | |||
| b133d0f16d | |||
| 024d37dea6 | |||
| 997aadf5b5 |
80
.github/workflows/desktop-release.yml
vendored
80
.github/workflows/desktop-release.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -128,3 +128,4 @@ installers/desktop/README.md
|
|||||||
web/coverage/
|
web/coverage/
|
||||||
brain/htmlcov/
|
brain/htmlcov/
|
||||||
brain/.coverage
|
brain/.coverage
|
||||||
|
foundry-module/
|
||||||
|
|||||||
36
README.fr.md
36
README.fr.md
@@ -1,4 +1,4 @@
|
|||||||
# LoreMind
|
# DM Loremind
|
||||||
|
|
||||||
[English](README.md) · **Français**
|
[English](README.md) · **Français**
|
||||||
|
|
||||||
@@ -10,33 +10,45 @@
|
|||||||
[](https://www.patreon.com/c/IGMLCreation)
|
[](https://www.patreon.com/c/IGMLCreation)
|
||||||
[](https://discord.gg/cPpFzCjEzQ)
|
[](https://discord.gg/cPpFzCjEzQ)
|
||||||
|
|
||||||
## Découvrir LoreMind en vidéo
|
## Découvrir DM Loremind en vidéo
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Ce que ça fait
|
## 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.
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -1,4 +1,4 @@
|
|||||||
# LoreMind
|
# DM Loremind
|
||||||
|
|
||||||
**English** · [Français](README.fr.md)
|
**English** · [Français](README.fr.md)
|
||||||
|
|
||||||
@@ -10,15 +10,15 @@
|
|||||||
[](https://www.patreon.com/c/IGMLCreation)
|
[](https://www.patreon.com/c/IGMLCreation)
|
||||||
[](https://discord.gg/cPpFzCjEzQ)
|
[](https://discord.gg/cPpFzCjEzQ)
|
||||||
|
|
||||||
## See LoreMind in action
|
## See DM Loremind in action
|
||||||
|
|
||||||
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
[](https://www.youtube.com/watch?v=llJkmlotbB8)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## What it does
|
## 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.
|
||||||
|
|||||||
@@ -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.17.0",
|
version="1.0.0-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.17.0</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
44
core/src/main/java/com/loremind/domain/files/StoredFile.java
Normal file
44
core/src/main/java/com/loremind/domain/files/StoredFile.java
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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<>())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -----
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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) : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
installers/desktop/app-icon.png
Normal file
BIN
installers/desktop/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
261
installers/desktop/build-linux.sh
Normal file
261
installers/desktop/build-linux.sh
Normal 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'
|
||||||
@@ -58,6 +58,8 @@ $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)
|
$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) {
|
||||||
@@ -191,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 ---------------------------------------------------
|
||||||
@@ -229,12 +234,14 @@ jpackage `
|
|||||||
--app-version $Version `
|
--app-version $Version `
|
||||||
--vendor 'IGML Creation' `
|
--vendor 'IGML Creation' `
|
||||||
--icon $IconFile `
|
--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 'DM Loremind' `
|
--win-menu --win-menu-group 'DM Loremind' `
|
||||||
|
|||||||
BIN
installers/desktop/splash.png
Normal file
BIN
installers/desktop/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
164
installers/desktop/wix/main.wxs
Normal file
164
installers/desktop/wix/main.wxs
Normal 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=""[INSTALLDIR]$(var.JpAppName).exe""
|
||||||
|
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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) :
|
||||||
|
|||||||
@@ -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
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.17.0",
|
"version": "1.0.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.17.0",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.17.0",
|
"version": "1.0.0-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
13
web/src/app/services/stored-file.model.ts
Normal file
13
web/src/app/services/stored-file.model.ts
Normal 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;
|
||||||
|
}
|
||||||
38
web/src/app/services/stored-file.service.ts
Normal file
38
web/src/app/services/stored-file.service.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 *",
|
||||||
|
|||||||
@@ -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 *",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 *",
|
||||||
|
|||||||
@@ -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 *",
|
||||||
|
|||||||
Reference in New Issue
Block a user