Compare commits

...

5 Commits

Author SHA1 Message Date
eb78a75621 Ajout du binaire tesseract pour la reconnaissance OCR des PDF pour l'installation local
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m10s
Build & Push Images / build (core) (push) Successful in 3m8s
Build & Push Images / build (web) (push) Successful in 1m41s
Build & Push Images / build-switcher (push) Successful in 39s
Correction d'un problème d'écrasement de BDD à la réinstallation
2026-06-18 10:28:31 +02:00
f04ecf1021 Passage v0.16.0
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m14s
Build & Push Images / build (core) (push) Successful in 3m16s
Build & Push Images / build (web) (push) Successful in 1m57s
Build & Push Images / build-switcher (push) Successful in 40s
2026-06-18 09:52:53 +02:00
72fe5e6215 Mise en place de l'import / export des données pour pouvoir sauvegarder les lores / campagnes 2026-06-18 09:49:34 +02:00
7dfa9c3655 Mise à jour v0.15.1
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m43s
Build & Push Images / build-switcher (push) Successful in 41s
Build & Push Images / build (core) (push) Successful in 3m10s
2026-06-17 18:24:51 +02:00
7aa174d75a Mise à jour du workflow pour forcer le declenchement à la main si par malheur il ne tourne pas à l'avenir automatiquement 2026-06-17 18:24:18 +02:00
27 changed files with 1876 additions and 23 deletions

View File

@@ -22,6 +22,14 @@ name: Desktop installers
on:
push:
tags: ['v*']
# Declenchement MANUEL depuis l'onglet Actions ("Run workflow"). Utile quand un
# tag a ete pousse AVANT que le workflow existe sur GitHub (ne se redeclenche
# pas tout seul), ou pour rejouer un build. Saisir la version SANS le "v".
workflow_dispatch:
inputs:
version:
description: "Version a builder (doit correspondre a un tag existant, ex: 0.15.0 ou 0.15.0-beta)"
required: true
permissions:
contents: write # requis pour creer la Release et y attacher le .msi
@@ -30,7 +38,12 @@ jobs:
windows:
runs-on: windows-latest
steps:
# En declenchement manuel, on checkout le TAG correspondant a la version
# saisie (sinon checkout prendrait la branche par defaut). En push de tag,
# on prend la ref poussee.
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
# Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
- name: Set up JDK 21
@@ -57,24 +70,44 @@ jobs:
shell: pwsh
run: choco install wixtoolset -y --no-progress
# Version de l'installeur = version du tag (numerique, sans suffixe -beta).
- name: Derive version from tag
# Tesseract OCR : non preinstalle sur windows-latest. Requis pour que
# build-windows.ps1 embarque l'OCR des PDF scannes (sinon il skip en
# degradation gracieuse). Installe dans %ProgramFiles%\Tesseract-OCR.
- name: Install Tesseract OCR
shell: pwsh
run: choco install tesseract -y --no-progress
# Version de l'installeur = version du tag (push) OU de l'input (manuel).
# Sorties : version (numerique X.Y.Z pour le MSI), tag (vX.Y.Z[-beta]),
# isbeta (true/false) — independant du nom de ref (qui est une branche en manuel).
- name: Derive version
id: ver
shell: pwsh
run: |
$full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1
$num = ($full -split '-')[0] # ex: 0.15.0
"version=$num" >> $env:GITHUB_OUTPUT
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
$raw = '${{ inputs.version }}'
} else {
$raw = '${{ github.ref_name }}'
}
$raw = $raw -replace '^v','' # 0.15.0 ou 0.15.0-beta
$num = ($raw -split '-')[0] # 0.15.0
$isbeta = if ($raw -like '*-beta*') { 'true' } else { 'false' }
"version=$num" >> $env:GITHUB_OUTPUT
"tag=v$raw" >> $env:GITHUB_OUTPUT
"isbeta=$isbeta" >> $env:GITHUB_OUTPUT
- name: Build Windows installer
shell: pwsh
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
# STABLE uniquement : Release GitHub publique avec le .msi.
# tag_name explicite : en declenchement manuel, github.ref est une branche,
# donc on cible le tag derive (la release est attachee au bon tag).
- name: Publish installer to GitHub Release (stable)
if: ${{ !contains(github.ref_name, '-beta') }}
if: ${{ steps.ver.outputs.isbeta == 'false' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.ver.outputs.tag }}
files: core/target/dist-out/*.msi
fail_on_unmatched_files: true
generate_release_notes: true
@@ -82,7 +115,7 @@ jobs:
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
- name: Upload installer as private artifact (beta)
if: ${{ contains(github.ref_name, '-beta') }}
if: ${{ steps.ver.outputs.isbeta == 'true' }}
uses: actions/upload-artifact@v4
with:
name: loremind-beta-${{ steps.ver.outputs.version }}-msi

View File

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

View File

@@ -16,7 +16,21 @@ ailleurs, sous ~/.loremind/brain, pour y ecrire le dossier data/).
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
# OCR : si un Tesseract est bundlé à côté (mode desktop), on y pointe pytesseract
# 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
# reste désactivé en dégradation gracieuse (PDF born-digital OK, scans signalés).
_TESS = os.path.join(_HERE, "tesseract", "tesseract.exe")
if os.path.exists(_TESS):
os.environ.setdefault("TESSDATA_PREFIX", os.path.join(_HERE, "tesseract"))
try:
import pytesseract
pytesseract.pytesseract.tesseract_cmd = _TESS
except ImportError:
pass
import uvicorn # noqa: E402

View File

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

View File

@@ -1,6 +1,7 @@
package com.loremind;
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
import com.loremind.infrastructure.desktop.DesktopUserConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -30,6 +31,12 @@ public class LoreMindApplication {
// ce qui leverait HeadlessException — on le desactive ici. En mode
// serveur/Docker, on reste en headless (defaut), aucun impact.
app.setHeadless(false);
// Config utilisateur editable (~/.loremind/loremind.properties) : creee
// au 1er lancement (port + identifiants admin). Puis resolution du port :
// celui configure s'il est libre, sinon un port libre (evite l'echec de
// demarrage si 8080 est deja pris). Publie server.port + ~/.loremind/.port.
DesktopUserConfig.ensureExists();
DesktopUserConfig.resolveAndPublishPort();
}
app.run(args);
}

View File

@@ -26,6 +26,21 @@ public interface ImageStorage {
*/
String upload(String filename, String contentType, InputStream data, long sizeBytes);
/**
* Stocke un flux binaire SOUS UNE CLE IMPOSEE (pas de generation).
* <p>
* Utilise par l'import de contenu pour reinjecter une image sous sa cle
* d'origine, garantissant que les references {@code storageKey} portees par
* les entites restent valides apres un transfert inter-instance.
* Ecrase si la cle existe deja.
*
* @param storageKey cle opaque exacte sous laquelle stocker (ex: images/UUID.ext)
* @param contentType MIME type
* @param data flux binaire a stocker
* @param sizeBytes taille en octets (requis par certains backends comme S3)
*/
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);

View File

@@ -80,9 +80,9 @@ public final class DesktopSingleInstance {
}
}
/** Ouvre le navigateur par defaut sur l'URL de l'application locale. */
/** Ouvre le navigateur par defaut sur l'URL de l'application locale (port reel). */
public static void openAppInBrowser() {
openUrl("http://localhost:" + System.getProperty("server.port", "8080") + "/");
openUrl("http://localhost:" + DesktopUserConfig.runningPort() + "/");
}
/** Ouvre le navigateur par defaut sur une URL quelconque (sans dependance AWT). */
@@ -105,6 +105,45 @@ public final class DesktopSingleInstance {
}
}
/** Ouvre un dossier dans le gestionnaire de fichiers du systeme. */
public static void openFolder(Path dir) {
try {
Files.createDirectories(dir);
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
pb = new ProcessBuilder("explorer.exe", dir.toString());
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", dir.toString());
} else {
pb = new ProcessBuilder("xdg-open", dir.toString());
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Ouverture du dossier impossible : " + e.getMessage());
}
}
/** Ouvre un fichier texte dans l'editeur par defaut (Bloc-notes sous Windows). */
public static void openInEditor(Path file) {
try {
String os = System.getProperty("os.name", "").toLowerCase();
ProcessBuilder pb;
if (os.contains("win")) {
// notepad : toujours present, ouvre proprement un .properties
// (dont l'association par defaut n'est pas garantie).
pb = new ProcessBuilder("notepad.exe", file.toString());
} else if (os.contains("mac")) {
pb = new ProcessBuilder("open", "-t", file.toString());
} else {
pb = new ProcessBuilder("xdg-open", file.toString());
}
pb.start();
} catch (IOException e) {
System.err.println("[Desktop] Ouverture du fichier impossible : " + e.getMessage());
}
}
private static Path loremindHome() {
String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home);

View File

@@ -0,0 +1,166 @@
package com.loremind.infrastructure.desktop;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
/**
* Configuration UTILISATEUR du mode bureau, dans un fichier éditable
* {@code ~/.loremind/loremind.properties} (port HTTP, identifiants admin).
* <p>
* Pourquoi un fichier et pas l'UI : le port et le mot de passe admin sont requis
* AVANT que le serveur (et donc l'UI web) ne démarre — impossible de les régler
* depuis l'app elle-même. Un fichier texte simple, lu au démarrage, est le
* pattern standard d'une app de bureau. Les modifs prennent effet au prochain
* lancement.
* <p>
* - {@code server.port} : lu ici (résolution du port) ET par Spring.
* - {@code admin.username} / {@code admin.password} : chargés par Spring via
* {@code spring.config.import} (cf. application-local.properties) — ils
* surchargent les défauts du profil local.
* <p>
* Repli de port : si le port configuré est occupé, on en choisit un libre
* automatiquement (évite un échec de démarrage cryptique sur conflit de port)
* et on écrit le port réellement utilisé dans {@code ~/.loremind/.port} pour que
* l'ouverture du navigateur (instance gagnante OU 2e double-clic) cible la bonne URL.
*/
public final class DesktopUserConfig {
private DesktopUserConfig() {}
private static final String DEFAULT_TEMPLATE = """
# ============================================================
# Configuration locale de LoreMind (mode bureau)
# ------------------------------------------------------------
# Modifiez ces valeurs puis RELANCEZ LoreMind pour appliquer.
# ============================================================
# Port HTTP local de l'application (http://localhost:<port>).
# Si ce port est deja occupe par une autre application, LoreMind
# choisira automatiquement un autre port libre au demarrage.
server.port=8080
# Identifiants de la page Parametres (acces admin).
# Accessible uniquement en local sur cette machine.
admin.username=admin
admin.password=admin
""";
/** Chemin du fichier de config utilisateur (pour l'ouvrir depuis le menu systray). */
public static Path getConfigFile() {
return configFile();
}
/** Dossier de données/config de l'instance ({@code ~/.loremind}). */
public static Path getHomeDir() {
return loremindHome();
}
/** Crée le fichier de config avec des valeurs par défaut commentées s'il n'existe pas. */
public static void ensureExists() {
Path file = configFile();
if (Files.exists(file)) {
return;
}
try {
Files.createDirectories(file.getParent());
Files.writeString(file, DEFAULT_TEMPLATE);
System.out.println("[Desktop] Config utilisateur creee : " + file);
} catch (IOException e) {
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage()
+ " — defauts utilises (port 8080, admin/admin).");
}
}
/**
* Résout le port à utiliser : le port configuré s'il est libre, sinon un port
* libre choisi automatiquement. Publie le résultat dans la propriété système
* {@code server.port} (que Spring lira en priorité) et dans {@code ~/.loremind/.port}.
*
* @return le port effectivement retenu.
*/
public static int resolveAndPublishPort() {
int wanted = configuredPort();
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
if (chosen != wanted) {
System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + ".");
}
System.setProperty("server.port", String.valueOf(chosen));
try {
Files.writeString(portFile(), String.valueOf(chosen));
} catch (IOException e) {
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ").");
}
return chosen;
}
/**
* Port sur lequel l'application répond réellement (pour ouvrir le navigateur).
* Ordre : propriété système (instance gagnante) → fichier .port (2e double-clic)
* → port configuré → 8080.
*/
public static int runningPort() {
String sys = System.getProperty("server.port");
if (sys != null && !sys.isBlank()) {
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
}
try {
Path p = portFile();
if (Files.exists(p)) {
return Integer.parseInt(Files.readString(p).trim());
}
} catch (IOException | NumberFormatException ignored) { /* fallthrough */ }
return configuredPort();
}
/** Port lu dans le fichier de config utilisateur (défaut 8080). */
private static int configuredPort() {
Path file = configFile();
if (Files.exists(file)) {
Properties props = new Properties();
try (InputStream in = Files.newInputStream(file)) {
props.load(in);
String v = props.getProperty("server.port");
if (v != null && !v.isBlank()) {
return Integer.parseInt(v.trim());
}
} catch (IOException | NumberFormatException ignored) { /* défaut */ }
}
return 8080;
}
private static boolean isPortFree(int port) {
try (ServerSocket s = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1"))) {
s.setReuseAddress(true);
return true;
} catch (IOException e) {
return false;
}
}
private static int findFreePort(int fallbackIfNone) {
try (ServerSocket s = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
return s.getLocalPort();
} catch (IOException e) {
return fallbackIfNone; // tres improbable ; on retente le port voulu
}
}
private static Path configFile() {
return loremindHome().resolve("loremind.properties");
}
private static Path portFile() {
return loremindHome().resolve(".port");
}
private static Path loremindHome() {
String home = System.getProperty("loremind.home");
if (home != null && !home.isBlank()) return Path.of(home);
return Path.of(System.getProperty("user.home"), ".loremind");
}
}

View File

@@ -69,6 +69,22 @@ public class SystemTrayManager {
popup.addSeparator();
// Reglages : ouvre loremind.properties (port, identifiants admin) dans
// l'editeur. Les changements prennent effet au prochain demarrage.
MenuItem editConfig = new MenuItem("Modifier la configuration (port, identifiants…)");
editConfig.addActionListener(e -> {
DesktopUserConfig.ensureExists();
DesktopSingleInstance.openInEditor(DesktopUserConfig.getConfigFile());
});
popup.add(editConfig);
// Acces au dossier de donnees (~/.loremind : base, images, config…).
MenuItem openFolder = new MenuItem("Ouvrir le dossier LoreMind");
openFolder.addActionListener(e -> DesktopSingleInstance.openFolder(DesktopUserConfig.getHomeDir()));
popup.add(openFolder);
popup.addSeparator();
MenuItem quit = new MenuItem("Quitter LoreMind");
quit.addActionListener(e -> quit());
popup.add(quit);

View File

@@ -4,10 +4,14 @@ import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* Repository Spring Data JPA pour ImageJpaEntity.
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
*/
@Repository
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
/** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */
Optional<ImageJpaEntity> findByStorageKey(String storageKey);
}

View File

@@ -11,6 +11,7 @@ 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;
/**
@@ -59,6 +60,18 @@ public class FilesystemImageStorageAdapter implements ImageStorage {
}
}
@Override
public void store(String storageKey, String contentType, InputStream data, long sizeBytes) {
Path target = resolveKey(storageKey);
try {
Files.createDirectories(target.getParent());
// Ecrase si la cle existe deja (contrat de store-avec-cle).
Files.copy(data, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new UncheckedIOException("Echec de l'ecriture de l'image sur disque (cle imposee) : " + target, e);
}
}
@Override
public InputStream download(String storageKey) {
Path source = resolveKey(storageKey);

View File

@@ -54,6 +54,22 @@ public class MinioImageStorageAdapter implements ImageStorage {
}
}
@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)
.build()
);
} catch (Exception e) {
throw new RuntimeException("Echec du store MinIO (cle imposee) : " + e.getMessage(), e);
}
}
@Override
public InputStream download(String storageKey) {
try {

View File

@@ -0,0 +1,350 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
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.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Service d'EXPORT du "contenu" en format logique (JSON) portable.
* <p>
* Construit un {@link ContentExport} a partir des entites JPA puis le serialise
* dans un .zip contenant :
* <ul>
* <li>{@code manifest.json} — metadonnees (version de format, version app, date)</li>
* <li>{@code data.json} — tout le contenu (pretty-printed)</li>
* <li>{@code images/<storageKey>} — un binaire par image referencee</li>
* </ul>
* Volontairement DECOUPLE de la base : c'est un export logique, pas un dump,
* pour fonctionner entre Postgres (Docker) et H2 (local).
*/
@Service
public class ExportService {
private static final int FORMAT_VERSION = 1;
// Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME
// format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final CharacterJpaRepository characterRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
private final ObjectMapper objectMapper;
private final String appVersion;
public ExportService(GameSystemJpaRepository gameSystemRepo,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
SceneJpaRepository sceneRepo,
CharacterJpaRepository characterRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ImageJpaRepository imageRepo,
ImageStorage imageStorage,
ObjectMapper objectMapper,
@Nullable BuildProperties buildProperties) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.characterRepo = characterRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
this.objectMapper = objectMapper;
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
}
/**
* Charge toutes les entites du perimetre et les mappe vers les DTO plats.
*
* @param exportedAt horodatage ISO stampe par la couche appelante (controller)
*/
public ContentExport buildExport(String exportedAt) {
ContentExport.Manifest manifest =
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt);
List<ContentExport.GameSystemDto> gameSystems = gameSystemRepo.findAll().stream()
.map(this::toGameSystemDto).toList();
List<ContentExport.LoreDto> lores = loreRepo.findAll().stream()
.map(this::toLoreDto).toList();
List<ContentExport.LoreNodeDto> loreNodes = loreNodeRepo.findAll().stream()
.map(this::toLoreNodeDto).toList();
List<ContentExport.TemplateDto> templates = templateRepo.findAll().stream()
.map(this::toTemplateDto).toList();
List<ContentExport.PageDto> pages = pageRepo.findAll().stream()
.map(this::toPageDto).toList();
List<ContentExport.CampaignDto> campaigns = campaignRepo.findAll().stream()
.map(this::toCampaignDto).toList();
List<ContentExport.ArcDto> arcs = arcRepo.findAll().stream()
.map(this::toArcDto).toList();
List<ContentExport.ChapterDto> chapters = chapterRepo.findAll().stream()
.map(this::toChapterDto).toList();
List<ContentExport.SceneDto> scenes = sceneRepo.findAll().stream()
.map(this::toSceneDto).toList();
List<ContentExport.CharacterDto> characters = characterRepo.findAll().stream()
.map(this::toCharacterDto).toList();
List<ContentExport.NpcDto> npcs = npcRepo.findAll().stream()
.map(this::toNpcDto).toList();
List<ContentExport.EnemyDto> enemies = enemyRepo.findAll().stream()
.map(this::toEnemyDto).toList();
List<ContentExport.ItemCatalogDto> itemCatalogs = itemCatalogRepo.findAll().stream()
.map(this::toItemCatalogDto).toList();
List<ContentExport.RandomTableDto> randomTables = randomTableRepo.findAll().stream()
.map(this::toRandomTableDto).toList();
List<ContentExport.ImageDto> images = imageRepo.findAll().stream()
.map(this::toImageDto).toList();
return new ContentExport(manifest, gameSystems, lores, loreNodes, templates,
pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies,
itemCatalogs, randomTables, images);
}
/**
* Serialise un export dans le flux fourni au format .zip.
* <p>
* Les binaires d'images ne sont ecrits que pour les storageKeys REFERENCES
* par les entites exportees (illustration/map/portrait/header/imageValues),
* pas pour toute la table images — on evite de trimballer des orphelins.
*/
public void writeZip(ContentExport export, OutputStream out) {
try (ZipOutputStream zip = new ZipOutputStream(out)) {
// manifest.json
zip.putNextEntry(new ZipEntry("manifest.json"));
zip.write(objectMapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(export.manifest()));
zip.closeEntry();
// data.json
zip.putNextEntry(new ZipEntry("data.json"));
zip.write(objectMapper.copy()
.enable(SerializationFeature.INDENT_OUTPUT)
.writeValueAsBytes(export));
zip.closeEntry();
// Binaires images : uniquement ceux reellement references.
Set<String> referenced = collectReferencedStorageKeys(export);
Set<String> written = new LinkedHashSet<>();
for (String key : referenced) {
if (key == null || key.isBlank() || !written.add(key)) {
continue;
}
try (InputStream data = imageStorage.download(key)) {
if (data == null) {
continue; // cle orpheline : on ignore silencieusement
}
zip.putNextEntry(new ZipEntry("images/" + key));
data.transferTo(zip);
zip.closeEntry();
}
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de la generation du zip d'export", e);
}
}
/**
* Collecte tous les storageKeys references par le contenu exporte :
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
* (portrait + header + imageValues), Page (imageValues).
*/
private Set<String> collectReferencedStorageKeys(ContentExport export) {
Set<String> keys = new LinkedHashSet<>();
for (ContentExport.ArcDto a : export.arcs()) {
addAll(keys, a.illustrationImageIds());
addAll(keys, a.mapImageIds());
}
for (ContentExport.ChapterDto c : export.chapters()) {
addAll(keys, c.illustrationImageIds());
addAll(keys, c.mapImageIds());
}
for (ContentExport.SceneDto s : export.scenes()) {
addAll(keys, s.illustrationImageIds());
addAll(keys, s.mapImageIds());
}
for (ContentExport.CharacterDto c : export.characters()) {
add(keys, c.portraitImageId());
add(keys, c.headerImageId());
addImageValues(keys, c.imageValues());
}
for (ContentExport.NpcDto n : export.npcs()) {
add(keys, n.portraitImageId());
add(keys, n.headerImageId());
addImageValues(keys, n.imageValues());
}
for (ContentExport.EnemyDto e : export.enemies()) {
add(keys, e.portraitImageId());
add(keys, e.headerImageId());
addImageValues(keys, e.imageValues());
}
for (ContentExport.PageDto p : export.pages()) {
addImageValues(keys, p.imageValues());
}
return keys;
}
private void add(Set<String> keys, String key) {
if (key != null && !key.isBlank()) keys.add(key);
}
private void addAll(Set<String> keys, List<String> list) {
if (list != null) list.forEach(k -> add(keys, k));
}
private void addImageValues(Set<String> keys, java.util.Map<String, List<String>> imageValues) {
if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l));
}
// ----- Mappers entite -> DTO -----
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(),
e.getRulesMarkdown(), e.getCharacterTemplate(), e.getNpcTemplate(),
e.getEnemyTemplate(), e.getAuthor(), e.isPublic());
}
private ContentExport.LoreDto toLoreDto(LoreJpaEntity e) {
return new ContentExport.LoreDto(e.getId(), e.getName(), e.getDescription(),
e.getNodeCount(), e.getPageCount());
}
private ContentExport.LoreNodeDto toLoreNodeDto(LoreNodeJpaEntity e) {
return new ContentExport.LoreNodeDto(e.getId(), e.getName(), e.getIcon(),
e.getParentId(), e.getLoreId());
}
private ContentExport.TemplateDto toTemplateDto(TemplateJpaEntity e) {
return new ContentExport.TemplateDto(e.getId(), e.getLoreId(), e.getName(),
e.getDescription(), e.getDefaultNodeId(), e.getFields());
}
private ContentExport.PageDto toPageDto(PageJpaEntity e) {
return new ContentExport.PageDto(e.getId(), e.getLoreId(), e.getNodeId(),
e.getTemplateId(), e.getTitle(), e.getValues(), e.getImageValues(),
e.getKeyValueValues(), e.getTableValues(), e.getNotes(), e.getTags(),
e.getRelatedPageIds());
}
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(),
e.getArcsCount(), e.getLoreId(), e.getGameSystemId());
}
private ContentExport.ArcDto toArcDto(ArcJpaEntity e) {
return new ContentExport.ArcDto(e.getId(), e.getName(), e.getDescription(),
e.getCampaignId(), e.getOrder(),
e.getType() != null ? e.getType().name() : null,
e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(),
e.getRewards(), e.getResolution(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getMapImageIds());
}
private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) {
return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(),
e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(),
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
e.getRelatedPageIds(), e.getIllustrationImageIds(), e.getMapImageIds());
}
private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) {
return new ContentExport.SceneDto(e.getId(), e.getName(), e.getDescription(),
e.getChapterId(), e.getOrder(), e.getIcon(), e.getLocation(),
e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(),
e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(),
e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(),
e.getIllustrationImageIds(), e.getMapImageIds(), e.getBranches(), e.getRooms());
}
private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) {
return new ContentExport.CharacterDto(e.getId(), e.getName(), e.getPortraitImageId(),
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
e.getCampaignId(), e.getPlaythroughId(), e.getOrder());
}
private ContentExport.NpcDto toNpcDto(NpcJpaEntity e) {
return new ContentExport.NpcDto(e.getId(), e.getName(), e.getPortraitImageId(),
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
e.getCampaignId(), e.getRelatedPageIds(), e.getFolder(), e.getOrder());
}
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(),
e.getKeyValueValues(), e.getCampaignId(), e.getOrder());
}
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {
List<ContentExport.CatalogItemDto> items = new ArrayList<>();
if (e.getItems() != null) {
for (CatalogItemJpaEntity i : e.getItems()) {
items.add(new ContentExport.CatalogItemDto(i.getId(), i.getName(),
i.getPrice(), i.getCategory(), i.getDescription(), i.getPosition()));
}
}
return new ContentExport.ItemCatalogDto(e.getId(), e.getName(), e.getDescription(),
e.getIcon(), e.getCampaignId(), e.getOrder(), items);
}
private ContentExport.RandomTableDto toRandomTableDto(RandomTableJpaEntity e) {
List<ContentExport.RandomTableEntryDto> entries = new ArrayList<>();
if (e.getEntries() != null) {
for (RandomTableEntryJpaEntity en : e.getEntries()) {
entries.add(new ContentExport.RandomTableEntryDto(en.getId(), en.getMinRoll(),
en.getMaxRoll(), en.getLabel(), en.getDetail(), en.getPosition()));
}
}
return new ContentExport.RandomTableDto(e.getId(), e.getName(), e.getDescription(),
e.getDiceFormula(), e.getIcon(), e.getCampaignId(), e.getOrder(), entries);
}
private ContentExport.ImageDto toImageDto(ImageJpaEntity e) {
return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(),
e.getSizeBytes(), e.getStorageKey());
}
}

View File

@@ -0,0 +1,29 @@
package com.loremind.infrastructure.transfer;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Resume d'un import : nombre d'entites creees par type, + nombre d'images
* reuploadees / reutilisees.
*/
public record ImportResult(Map<String, Integer> created, int imagesUploaded, int imagesReused) {
public static class Builder {
private final Map<String, Integer> created = new LinkedHashMap<>();
private int imagesUploaded;
private int imagesReused;
public void count(String type, int n) {
created.merge(type, n, Integer::sum);
}
public void imageUploaded() { imagesUploaded++; }
public void imageReused() { imagesReused++; }
public ImportResult build() {
return new ImportResult(created, imagesUploaded, imagesReused);
}
}
}

View File

@@ -0,0 +1,616 @@
package com.loremind.infrastructure.transfer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.loremind.domain.campaigncontext.Prerequisite;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.images.ports.ImageStorage;
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
import com.loremind.infrastructure.persistence.entity.*;
import com.loremind.infrastructure.persistence.jpa.*;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* Service d'IMPORT de contenu en mode FUSION.
* <p>
* On NE remplace PAS l'existant : chaque entite importee est reinseree avec un
* NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles
* String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports
* dans une meme base sans collision, entre Postgres et H2.
* <p>
* Algorithme :
* <ol>
* <li>Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.</li>
* <li>Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ;
* skip si une ImageJpaEntity avec cette cle existe deja.</li>
* <li>Insere top-down en construisant les maps de remapping par type.</li>
* <li>2e passe : remappe les references qui pointent vers des types inserees
* plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.</li>
* </ol>
* Les references vers un id absent des maps (ex. relatedPageId hors export) sont
* CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter).
*/
@Service
public class ImportService {
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
private final GameSystemJpaRepository gameSystemRepo;
private final LoreJpaRepository loreRepo;
private final LoreNodeJpaRepository loreNodeRepo;
private final TemplateJpaRepository templateRepo;
private final PageJpaRepository pageRepo;
private final CampaignJpaRepository campaignRepo;
private final ArcJpaRepository arcRepo;
private final ChapterJpaRepository chapterRepo;
private final SceneJpaRepository sceneRepo;
private final CharacterJpaRepository characterRepo;
private final NpcJpaRepository npcRepo;
private final EnemyJpaRepository enemyRepo;
private final ItemCatalogJpaRepository itemCatalogRepo;
private final RandomTableJpaRepository randomTableRepo;
private final ImageJpaRepository imageRepo;
private final ImageStorage imageStorage;
private final ObjectMapper objectMapper;
public ImportService(GameSystemJpaRepository gameSystemRepo,
LoreJpaRepository loreRepo,
LoreNodeJpaRepository loreNodeRepo,
TemplateJpaRepository templateRepo,
PageJpaRepository pageRepo,
CampaignJpaRepository campaignRepo,
ArcJpaRepository arcRepo,
ChapterJpaRepository chapterRepo,
SceneJpaRepository sceneRepo,
CharacterJpaRepository characterRepo,
NpcJpaRepository npcRepo,
EnemyJpaRepository enemyRepo,
ItemCatalogJpaRepository itemCatalogRepo,
RandomTableJpaRepository randomTableRepo,
ImageJpaRepository imageRepo,
ImageStorage imageStorage,
ObjectMapper objectMapper) {
this.gameSystemRepo = gameSystemRepo;
this.loreRepo = loreRepo;
this.loreNodeRepo = loreNodeRepo;
this.templateRepo = templateRepo;
this.pageRepo = pageRepo;
this.campaignRepo = campaignRepo;
this.arcRepo = arcRepo;
this.chapterRepo = chapterRepo;
this.sceneRepo = sceneRepo;
this.characterRepo = characterRepo;
this.npcRepo = npcRepo;
this.enemyRepo = enemyRepo;
this.itemCatalogRepo = itemCatalogRepo;
this.randomTableRepo = randomTableRepo;
this.imageRepo = imageRepo;
this.imageStorage = imageStorage;
this.objectMapper = objectMapper;
}
@Transactional
public ImportResult importZip(InputStream zipStream) {
// 1. Parse du zip.
ContentExport export = null;
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
String name = entry.getName();
if ("data.json".equals(name)) {
export = objectMapper.readValue(readAll(zip), ContentExport.class);
} else if (name.startsWith("images/") && !entry.isDirectory()) {
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
String storageKey = name.substring("images/".length());
imageBinaries.put(storageKey, readAll(zip));
}
// manifest.json : ignore a l'import (info seulement).
zip.closeEntry();
}
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
}
if (export == null) {
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
}
ImportResult.Builder result = new ImportResult.Builder();
// 2. Reecriture des images (cle preservee).
importImages(export, imageBinaries, result);
// 3. Insertion top-down + maps de remapping.
Map<Long, Long> gameSystemMap = new HashMap<>();
Map<Long, Long> loreMap = new HashMap<>();
Map<Long, Long> loreNodeMap = new HashMap<>();
Map<Long, Long> templateMap = new HashMap<>();
Map<Long, Long> pageMap = new HashMap<>();
Map<Long, Long> campaignMap = new HashMap<>();
Map<Long, Long> arcMap = new HashMap<>();
Map<Long, Long> chapterMap = new HashMap<>();
Map<Long, Long> npcMap = new HashMap<>();
Map<Long, Long> enemyMap = new HashMap<>();
Map<Long, Long> characterMap = new HashMap<>();
Map<Long, Long> sceneMap = new HashMap<>();
// -- GameSystem
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
GameSystemJpaEntity e = new GameSystemJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setRulesMarkdown(d.rulesMarkdown());
e.setCharacterTemplate(d.characterTemplate());
e.setNpcTemplate(d.npcTemplate());
e.setEnemyTemplate(d.enemyTemplate());
e.setAuthor(d.author());
e.setPublic(d.isPublic());
gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
}
result.count("gameSystems", gameSystemMap.size());
// -- Lore
for (ContentExport.LoreDto d : nullSafe(export.lores())) {
LoreJpaEntity e = new LoreJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setNodeCount(d.nodeCount());
e.setPageCount(d.pageCount());
loreMap.put(d.id(), loreRepo.save(e).getId());
}
result.count("lores", loreMap.size());
// -- LoreNode (parentId remappe en 2e passe)
List<LoreNodeJpaEntity> loreNodesToFix = new ArrayList<>();
for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) {
LoreNodeJpaEntity e = new LoreNodeJpaEntity();
e.setName(d.name());
e.setIcon(d.icon());
e.setParentId(d.parentId()); // remappe plus bas
e.setLoreId(remapRequired(loreMap, d.loreId()));
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
loreNodeMap.put(d.id(), saved.getId());
if (d.parentId() != null) loreNodesToFix.add(saved);
}
result.count("loreNodes", loreNodeMap.size());
// -- Template (defaultNodeId remappe en 2e passe)
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
TemplateJpaEntity e = new TemplateJpaEntity();
e.setLoreId(remapRequired(loreMap, d.loreId()));
e.setName(d.name());
e.setDescription(d.description());
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
e.setFields(d.fields());
templateMap.put(d.id(), templateRepo.save(e).getId());
if (d.defaultNodeId() != null) templatesWithDefaultNode.add(d);
}
result.count("templates", templateMap.size());
// -- Page (relatedPageIds remappe en 2e passe)
for (ContentExport.PageDto d : nullSafe(export.pages())) {
PageJpaEntity e = new PageJpaEntity();
e.setLoreId(remapRequired(loreMap, d.loreId()));
e.setNodeId(remapRequired(loreNodeMap, d.nodeId()));
e.setTemplateId(remapNullable(templateMap, d.templateId()));
e.setTitle(d.title());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setTableValues(d.tableValues());
e.setNotes(d.notes());
e.setTags(d.tags());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
pageMap.put(d.id(), pageRepo.save(e).getId());
}
result.count("pages", pageMap.size());
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
CampaignJpaEntity e = new CampaignJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcsCount(d.arcsCount());
e.setLoreId(d.loreId()); // remappe plus bas
e.setGameSystemId(d.gameSystemId()); // remappe plus bas
campaignMap.put(d.id(), campaignRepo.save(e).getId());
}
result.count("campaigns", campaignMap.size());
// -- Arc (relatedPageIds remappe en 2e passe)
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
ArcJpaEntity e = new ArcJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setOrder(d.order());
e.setType(parseArcType(d.type()));
e.setIcon(d.icon());
e.setThemes(d.themes());
e.setStakes(d.stakes());
e.setGmNotes(d.gmNotes());
e.setRewards(d.rewards());
e.setResolution(d.resolution());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
arcMap.put(d.id(), arcRepo.save(e).getId());
}
result.count("arcs", arcMap.size());
// -- ItemCatalog (+ items en cascade)
int catalogCount = 0, itemCount = 0;
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setIcon(d.icon());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<CatalogItemJpaEntity> items = new ArrayList<>();
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
CatalogItemJpaEntity item = new CatalogItemJpaEntity();
item.setName(i.name());
item.setPrice(i.price());
item.setCategory(i.category());
item.setDescription(i.description());
item.setPosition(i.position());
item.setCatalog(e); // lien parent requis pour la cascade
items.add(item);
itemCount++;
}
e.setItems(items);
itemCatalogRepo.save(e);
catalogCount++;
}
result.count("itemCatalogs", catalogCount);
result.count("catalogItems", itemCount);
// -- RandomTable (+ entries en cascade)
int tableCount = 0, entryCount = 0;
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
RandomTableJpaEntity e = new RandomTableJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setDiceFormula(d.diceFormula());
e.setIcon(d.icon());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setOrder(d.order());
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity();
entryE.setMinRoll(en.minRoll());
entryE.setMaxRoll(en.maxRoll());
entryE.setLabel(en.label());
entryE.setDetail(en.detail());
entryE.setPosition(en.position());
entryE.setRandomTable(e);
entries.add(entryE);
entryCount++;
}
e.setEntries(entries);
randomTableRepo.save(e);
tableCount++;
}
result.count("randomTables", tableCount);
result.count("randomTableEntries", entryCount);
// -- Chapter (prerequisites + relatedPageIds remappes en 2e passe)
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
ChapterJpaEntity e = new ChapterJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setArcId(remapRequired(arcMap, d.arcId()));
e.setOrder(d.order());
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
e.setIcon(d.icon());
e.setGmNotes(d.gmNotes());
e.setPlayerObjectives(d.playerObjectives());
e.setNarrativeStakes(d.narrativeStakes());
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
chapterMap.put(d.id(), chapterRepo.save(e).getId());
}
result.count("chapters", chapterMap.size());
// -- Npc (relatedPageIds remappe en 2e passe)
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
NpcJpaEntity e = new NpcJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setFolder(d.folder());
e.setOrder(d.order());
npcMap.put(d.id(), npcRepo.save(e).getId());
}
result.count("npcs", npcMap.size());
// -- Enemy
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
EnemyJpaEntity e = new EnemyJpaEntity();
e.setName(d.name());
e.setLevel(d.level());
e.setFolder(d.folder());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
e.setOrder(d.order());
enemyMap.put(d.id(), enemyRepo.save(e).getId());
}
result.count("enemies", enemyMap.size());
// -- Character (playthroughId mis a null : hors perimetre)
for (ContentExport.CharacterDto d : nullSafe(export.characters())) {
CharacterJpaEntity e = new CharacterJpaEntity();
e.setName(d.name());
e.setPortraitImageId(d.portraitImageId());
e.setHeaderImageId(d.headerImageId());
e.setValues(d.values());
e.setImageValues(d.imageValues());
e.setKeyValueValues(d.keyValueValues());
e.setCampaignId(remapNullable(campaignMap, d.campaignId()));
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
e.setOrder(d.order());
characterMap.put(d.id(), characterRepo.save(e).getId());
}
result.count("characters", characterMap.size());
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
SceneJpaEntity e = new SceneJpaEntity();
e.setName(d.name());
e.setDescription(d.description());
e.setChapterId(remapRequired(chapterMap, d.chapterId()));
e.setOrder(d.order());
e.setIcon(d.icon());
e.setLocation(d.location());
e.setTiming(d.timing());
e.setAtmosphere(d.atmosphere());
e.setPlayerNarration(d.playerNarration());
e.setGmSecretNotes(d.gmSecretNotes());
e.setChoicesConsequences(d.choicesConsequences());
e.setCombatDifficulty(d.combatDifficulty());
e.setEnemies(d.enemies());
e.setEnemyIds(d.enemyIds()); // remappe plus bas
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
e.setIllustrationImageIds(d.illustrationImageIds());
e.setMapImageIds(d.mapImageIds());
e.setBranches(d.branches()); // remappe plus bas
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
sceneMap.put(d.id(), sceneRepo.save(e).getId());
}
result.count("scenes", sceneMap.size());
// 4. 2e PASSE de remapping.
// LoreNode.parentId
for (LoreNodeJpaEntity e : loreNodesToFix) {
Long newParent = loreNodeMap.get(e.getParentId());
if (newParent != null) {
e.setParentId(newParent);
loreNodeRepo.save(e);
}
}
// Template.defaultNodeId
for (ContentExport.TemplateDto d : templatesWithDefaultNode) {
Long newTemplateId = templateMap.get(d.id());
Long newNode = loreNodeMap.get(d.defaultNodeId());
if (newTemplateId != null && newNode != null) {
templateRepo.findById(newTemplateId).ifPresent(t -> {
t.setDefaultNodeId(newNode);
templateRepo.save(t);
});
}
}
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
for (Long newCampaignId : campaignMap.values()) {
campaignRepo.findById(newCampaignId).ifPresent(c -> {
String newLore = remapStringId(loreMap, c.getLoreId());
String newGs = remapStringId(gameSystemMap, c.getGameSystemId());
c.setLoreId(newLore);
c.setGameSystemId(newGs);
campaignRepo.save(c);
});
}
// Page.relatedPageIds
for (Long newPageId : pageMap.values()) {
pageRepo.findById(newPageId).ifPresent(p -> {
p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds()));
pageRepo.save(p);
});
}
// Arc.relatedPageIds
for (Long newArcId : arcMap.values()) {
arcRepo.findById(newArcId).ifPresent(a -> {
a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds()));
arcRepo.save(a);
});
}
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
for (Long newChapterId : chapterMap.values()) {
chapterRepo.findById(newChapterId).ifPresent(c -> {
c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds()));
c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites()));
chapterRepo.save(c);
});
}
// Npc.relatedPageIds
for (Long newNpcId : npcMap.values()) {
npcRepo.findById(newNpcId).ifPresent(n -> {
n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds()));
npcRepo.save(n);
});
}
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
for (Long newSceneId : sceneMap.values()) {
sceneRepo.findById(newSceneId).ifPresent(s -> {
s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds()));
s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds()));
s.setBranches(remapBranches(sceneMap, s.getBranches()));
sceneRepo.save(s);
});
}
return result.build();
}
// ----- Images -----
private void importImages(ContentExport export,
Map<String, byte[]> imageBinaries,
ImportResult.Builder result) {
// Index des metadonnees d'image par cle (depuis le data.json).
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
for (ContentExport.ImageDto img : nullSafe(export.images())) {
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
}
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
String storageKey = bin.getKey();
byte[] data = bin.getValue();
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
// Image deja presente : on reutilise, pas de reupload (eviter doublon).
result.imageReused();
continue;
}
ContentExport.ImageDto meta = metaByKey.get(storageKey);
String contentType = meta != null && meta.contentType() != null
? meta.contentType() : guessContentType(storageKey);
long size = meta != null ? meta.sizeBytes() : data.length;
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
ImageJpaEntity e = new ImageJpaEntity();
e.setFilename(meta != null && meta.filename() != null
? meta.filename() : fileNameOf(storageKey));
e.setContentType(contentType);
e.setSizeBytes(size);
e.setStorageKey(storageKey);
imageRepo.save(e);
result.imageUploaded();
}
}
// ----- Helpers de remapping -----
/** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */
private Long remapRequired(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'une FK Long nullable : null reste null. */
private Long remapNullable(Map<Long, Long> map, Long oldId) {
if (oldId == null) return null;
return map.getOrDefault(oldId, oldId);
}
/** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */
private String remapStringId(Map<Long, Long> map, String oldId) {
if (oldId == null || oldId.isBlank()) return oldId;
try {
Long newId = map.get(Long.parseLong(oldId.trim()));
return newId != null ? String.valueOf(newId) : oldId;
} catch (NumberFormatException ex) {
return oldId; // pas un Long : on laisse tel quel
}
}
private List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
if (ids == null) return null;
List<String> out = new ArrayList<>(ids.size());
for (String id : ids) out.add(remapStringId(map, id));
return out;
}
private List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
if (prereqs == null) return null;
List<Prerequisite> out = new ArrayList<>(prereqs.size());
for (Prerequisite p : prereqs) {
if (p instanceof Prerequisite.QuestCompleted qc) {
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
} else {
out.add(p); // FlagSet / SessionReached : inchanges
}
}
return out;
}
private List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
if (branches == null) return null;
List<SceneBranch> out = new ArrayList<>(branches.size());
for (SceneBranch b : branches) {
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
}
return out;
}
private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) {
if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR;
try {
return com.loremind.domain.campaigncontext.ArcType.valueOf(type);
} catch (IllegalArgumentException ex) {
return com.loremind.domain.campaigncontext.ArcType.LINEAR;
}
}
// ----- Helpers divers -----
private static <T> List<T> nullSafe(List<T> list) {
return list != null ? list : List.of();
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
in.transferTo(buffer);
return buffer.toByteArray();
}
private static String fileNameOf(String storageKey) {
int slash = storageKey.lastIndexOf('/');
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
}
private static String guessContentType(String storageKey) {
String lower = storageKey.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
return "application/octet-stream";
}
}

View File

@@ -0,0 +1,265 @@
package com.loremind.infrastructure.transfer.dto;
import com.loremind.domain.campaigncontext.Room;
import com.loremind.domain.campaigncontext.SceneBranch;
import com.loremind.domain.shared.template.TemplateField;
import java.util.List;
import java.util.Map;
/**
* Format d'export LOGIQUE (JSON) du "contenu" de LoreMind.
* <p>
* Records plats, miroirs des champs des entites JPA — volontairement DECOUPLES
* des entites pour la stabilite inter-versions (le schema JPA peut bouger sans
* casser un .zip exporte). Chaque record porte son {@code id} d'origine (Long)
* afin que l'import puisse construire les maps de remapping oldId -> newId.
* <p>
* Les sous-objets riches (TemplateField, Prerequisite, SceneBranch, Room)
* reutilisent les types domaine existants : Jackson sait les (de)serialiser
* (records ou Lombok), et on evite de dupliquer la structure.
*/
public record ContentExport(
Manifest manifest,
List<GameSystemDto> gameSystems,
List<LoreDto> lores,
List<LoreNodeDto> loreNodes,
List<TemplateDto> templates,
List<PageDto> pages,
List<CampaignDto> campaigns,
List<ArcDto> arcs,
List<ChapterDto> chapters,
List<SceneDto> scenes,
List<CharacterDto> characters,
List<NpcDto> npcs,
List<EnemyDto> enemies,
List<ItemCatalogDto> itemCatalogs,
List<RandomTableDto> randomTables,
List<ImageDto> images
) {
/**
* Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis
* la couche requete (PAS Instant.now() ici, pour rester deterministe et
* testable).
*/
public record Manifest(
int formatVersion,
String appVersion,
String exportedAt
) {}
public record GameSystemDto(
Long id,
String name,
String description,
String rulesMarkdown,
List<TemplateField> characterTemplate,
List<TemplateField> npcTemplate,
List<TemplateField> enemyTemplate,
String author,
boolean isPublic
) {}
public record LoreDto(
Long id,
String name,
String description,
int nodeCount,
int pageCount
) {}
public record LoreNodeDto(
Long id,
String name,
String icon,
Long parentId,
Long loreId
) {}
public record TemplateDto(
Long id,
Long loreId,
String name,
String description,
Long defaultNodeId,
List<TemplateField> fields
) {}
public record PageDto(
Long id,
Long loreId,
Long nodeId,
Long templateId,
String title,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Map<String, List<Map<String, String>>> tableValues,
String notes,
List<String> tags,
List<String> relatedPageIds
) {}
public record CampaignDto(
Long id,
String name,
String description,
int arcsCount,
String loreId,
String gameSystemId
) {}
public record ArcDto(
Long id,
String name,
String description,
Long campaignId,
int order,
String type,
String icon,
String themes,
String stakes,
String gmNotes,
String rewards,
String resolution,
List<String> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds
) {}
public record ChapterDto(
Long id,
String name,
String description,
Long arcId,
int order,
// Prerequisites en JSON brut (format "kind" du converter JPA) : Prerequisite
// est un type scellé SANS annotations Jackson, donc impossible à (dé)sérialiser
// en polymorphe via l'ObjectMapper standard. On réutilise
// PrerequisiteListJsonConverter (format on-disk stable, identique à la base).
String prerequisitesJson,
String icon,
String gmNotes,
String playerObjectives,
String narrativeStakes,
List<String> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds
) {}
public record SceneDto(
Long id,
String name,
String description,
Long chapterId,
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> relatedPageIds,
List<String> illustrationImageIds,
List<String> mapImageIds,
List<SceneBranch> branches,
List<Room> rooms
) {}
public record CharacterDto(
Long id,
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
Long playthroughId,
int order
) {}
public record NpcDto(
Long id,
String name,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
List<String> relatedPageIds,
String folder,
int order
) {}
public record EnemyDto(
Long id,
String name,
String level,
String folder,
String portraitImageId,
String headerImageId,
Map<String, String> values,
Map<String, List<String>> imageValues,
Map<String, Map<String, String>> keyValueValues,
Long campaignId,
int order
) {}
public record ItemCatalogDto(
Long id,
String name,
String description,
String icon,
Long campaignId,
int order,
List<CatalogItemDto> items
) {}
public record CatalogItemDto(
Long id,
String name,
String price,
String category,
String description,
int position
) {}
public record RandomTableDto(
Long id,
String name,
String description,
String diceFormula,
String icon,
Long campaignId,
int order,
List<RandomTableEntryDto> entries
) {}
public record RandomTableEntryDto(
Long id,
int minRoll,
int maxRoll,
String label,
String detail,
int position
) {}
/**
* Metadonnees d'une image. Le binaire voyage a part dans le zip sous
* {@code images/<storageKey>}. La cle est PRESERVEE telle quelle a l'import.
*/
public record ImageDto(
Long id,
String filename,
String contentType,
long sizeBytes,
String storageKey
) {}
}

View File

@@ -0,0 +1,85 @@
package com.loremind.infrastructure.web.controller;
import com.loremind.infrastructure.transfer.ExportService;
import com.loremind.infrastructure.transfer.ImportResult;
import com.loremind.infrastructure.transfer.ImportService;
import com.loremind.infrastructure.transfer.dto.ContentExport;
import org.springframework.beans.factory.annotation.Value;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Instant;
/**
* Endpoints d'EXPORT / IMPORT du "contenu" (admin).
* <p>
* - {@code GET /api/admin/data/export} : telecharge un .zip portable.<br>
* - {@code POST /api/admin/data/import} : importe un .zip en mode FUSION.
* <p>
* Garde le mode demo coherent avec {@code SettingsController} : ces operations
* sont desactivees en demo (donnees partagees, pas d'ecriture massive).
*/
@RestController
@RequestMapping("/api/admin/data")
public class DataTransferController {
private final ExportService exportService;
private final ImportService importService;
private final boolean demoMode;
public DataTransferController(ExportService exportService,
ImportService importService,
@Value("${app.demo-mode:false}") boolean demoMode) {
this.exportService = exportService;
this.importService = importService;
this.demoMode = demoMode;
}
@GetMapping(value = "/export", produces = "application/zip")
public ResponseEntity<StreamingResponseBody> export() {
guardDemoMode();
// Stamp de l'horodatage cote requete (PAS dans un init statique).
String exportedAt = Instant.now().toString();
ContentExport content = exportService.buildExport(exportedAt);
StreamingResponseBody body = out -> exportService.writeZip(content, out);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"loremind-export.zip\"")
.contentType(MediaType.parseMediaType("application/zip"))
.body(body);
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ImportResult> importData(@RequestParam("file") MultipartFile file) {
guardDemoMode();
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier d'import vide");
}
try {
ImportResult result = importService.importZip(file.getInputStream());
return ResponseEntity.ok(result);
} catch (IOException e) {
throw new UncheckedIOException("Echec de lecture du fichier d'import", e);
}
}
private void guardDemoMode() {
if (demoMode) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Data transfer disabled in demo mode");
}
}
}

View File

@@ -10,6 +10,22 @@
# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) :
# le reste (timeouts, multipart, licensing...) est herite tel quel.
# --- Config utilisateur editable (port + identifiants admin) ----------------
# Charge ~/.loremind/loremind.properties s'il existe (cree au 1er lancement par
# DesktopUserConfig). "optional:" = pas d'erreur s'il manque. Les valeurs y
# definies (server.port, admin.username, admin.password) SURCHARGENT les defauts
# ci-dessous (un import a une priorite superieure au fichier qui l'importe).
# NB : le port effectif est de toute facon fixe par DesktopUserConfig au demarrage
# (propriete systeme server.port, avec repli si le port est occupe).
spring.config.import=optional:file:${user.home}/.loremind/loremind.properties
# --- Serveur : ecoute UNIQUEMENT sur la boucle locale -----------------------
# App de bureau mono-utilisateur : jamais exposee au reseau (securite). Et ca
# rend coherent le test "port libre" de DesktopUserConfig (qui teste 127.0.0.1)
# avec l'adresse de bind reelle de Tomcat -> le repli sur port libre fonctionne
# meme si une autre appli occupe le port sur une autre interface.
server.address=127.0.0.1
# --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL ---------
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres.
@@ -56,7 +72,8 @@ brain.sidecar.command=${BRAIN_SIDECAR_COMMAND:}
# --- Admin (localhost uniquement) ------------------------------------------
# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin.
# Identifiants par defaut surchargables ; non exposes hors de la machine.
# Defauts admin/admin, SURCHARGEABLES par l'utilisateur dans
# ~/.loremind/loremind.properties (cf. spring.config.import ci-dessus).
admin.username=${ADMIN_USERNAME:admin}
admin.password=${ADMIN_PASSWORD:admin}

View File

@@ -137,6 +137,27 @@ if (-not $SkipBrain) {
Copy-Item (Join-Path $BrainDir 'app') (Join-Path $BrainEmbed 'app') -Recurse
Copy-Item (Join-Path $BrainDir 'run_local.py') (Join-Path $BrainEmbed 'run_local.py')
# e) Tesseract (OCR des PDF scannes). Pas de zip portable officiel : on COPIE
# l'install UB-Mannheim (tesseract.exe + DLL), prerequis a installer une
# fois via `choco install tesseract`. Langues fra+eng tirees de tessdata_fast
# (leger, coherent). Si Tesseract n'est pas installe -> skip gracieux :
# l'app reste fonctionnelle, seul l'OCR des scans manquera (cf. run_local.py
# + pdf_extractor.py qui detectent l'absence et degradent proprement).
$tessSrc = Join-Path $env:ProgramFiles 'Tesseract-OCR'
if (Test-Path (Join-Path $tessSrc 'tesseract.exe')) {
$tessDst = Join-Path $BrainEmbed 'tesseract'
Copy-Item $tessSrc $tessDst -Recurse
New-Item -ItemType Directory -Force -Path (Join-Path $tessDst 'tessdata') | Out-Null
foreach ($lang in @('fra','eng')) {
Invoke-WebRequest -UseBasicParsing `
-Uri "https://github.com/tesseract-ocr/tessdata_fast/raw/main/$lang.traineddata" `
-OutFile (Join-Path $tessDst "tessdata\$lang.traineddata")
}
Write-Ok "Tesseract bundle (OCR des scans actif)"
} else {
Write-Host " !! Tesseract introuvable ($tessSrc) -> OCR des scans NON bundle (degradation gracieuse). Pour l'activer : choco install tesseract" -ForegroundColor Yellow
}
Write-Ok "Brain prepare (brain/dist-embed : python embeddable + deps + sources)"
} else { Write-Step "Brain : saute (--SkipBrain)" }
@@ -183,6 +204,13 @@ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
# ne contient de virgule) sont bindes en List<String> par Spring.
$brainCmd = '$APPDIR\brain\python\python.exe,$APPDIR\brain\run_local.py'
# --win-upgrade-uuid : UUID STABLE du produit. Permet a Windows Installer de
# reconnaitre qu'une nouvelle version est une MISE A JOUR du meme produit -> upgrade
# en place propre (pas besoin de desinstaller avant, pas de doublon). Les donnees
# (base H2, images) vivent de toute facon dans ~/.loremind, HORS du dossier
# d'installation, donc jamais touchees par l'installeur.
# /!\ NE JAMAIS CHANGER cet UUID : le modifier casserait la chaine d'upgrade.
#
# Note : pas de --win-console (app de bureau). Les logs Spring/Brain peuvent
# etre rediriges vers un fichier via une option ulterieure si besoin de debug.
jpackage `
@@ -197,6 +225,7 @@ jpackage `
--java-options '-Dspring.profiles.active=local' `
--java-options "-Dbrain.sidecar.command=$brainCmd" `
--win-per-user-install `
--win-upgrade-uuid 'a7c4e1d2-9b3f-4e6a-8d05-1f2c3b4a5e6d' `
--win-menu --win-menu-group 'LoreMind' `
--win-shortcut --win-dir-chooser

View File

@@ -34,6 +34,18 @@ server {
try_files $uri =404;
}
# Fichiers de traduction (assets/i18n/*.json) : nom STABLE (non hashe),
# donc JAMAIS caches — sinon le navigateur ressert un vieux fr.json apres une
# mise a jour et affiche des cles brutes (ex : settings.data.* en clair).
# Aligne sur le mode desktop (LocalWebConfig sert deja l'i18n en no-cache).
# ^~ : ce prefixe l'emporte sur la regex d'assets ci-dessous.
location ^~ /assets/i18n/ {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header Pragma "no-cache" always;
expires 0;
try_files $uri =404;
}
# Assets Angular avec hash dans le nom (main.<hash>.js, etc.) :
# immuables, peuvent etre caches longtemps.
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|webp|ico)$ {

4
web/package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "loremind-web",
"version": "0.15.0",
"version": "0.16.1",
"description": "LoreMind Frontend - Angular",
"scripts": {
"ng": "ng",

View File

@@ -66,6 +66,13 @@ export interface OllamaModelInfo {
context_length: number;
}
/** Resultat d'un import de donnees (compteurs par type + images). */
export interface ImportResult {
created: Record<string, number>;
imagesUploaded: number;
imagesReused: number;
}
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly apiUrl = '/api/settings';
@@ -85,6 +92,21 @@ export class SettingsService {
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
}
// --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
/** Telecharge l'export complet du contenu (zip : data.json + images). */
exportData(): Observable<Blob> {
return this.http.get('/api/admin/data/export',
{ withCredentials: true, responseType: 'blob' });
}
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */
importData(file: File): Observable<ImportResult> {
const form = new FormData();
form.append('file', file);
return this.http.post<ImportResult>('/api/admin/data/import', form, this.authOptions);
}
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}

View File

@@ -112,7 +112,7 @@
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="onemin-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="oneminApiKeyInput"
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
@if (settings.onemin_api_key_set) {
@@ -149,7 +149,7 @@
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="openrouter-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
@if (settings.openrouter_api_key_set) {
@@ -183,7 +183,7 @@
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="mistral-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="mistralApiKeyInput"
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
@if (settings.mistral_api_key_set) {
@@ -213,7 +213,7 @@
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
<input
id="gemini-key"
type="password"
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
[(ngModel)]="geminiApiKeyInput"
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
@if (settings.gemini_api_key_set) {
@@ -285,7 +285,7 @@
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</span>
}
</label>
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
<input id="emb-mistral-key" type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore [(ngModel)]="mistralApiKeyInput"
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
@if (settings.mistral_api_key_set) {
<label class="checkbox">
@@ -372,6 +372,33 @@
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
<app-settings-updates-section></app-settings-updates-section>
<!-- Sauvegarde / Restauration des donnees (export & import) -->
<section class="card">
<h2>{{ 'settings.data.title' | translate }}</h2>
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
<div class="actions">
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
</button>
<button type="button" class="btn-secondary" (click)="importInput.click()" [disabled]="exporting || importing">
<span>{{ (importing ? 'settings.data.importing' : 'settings.data.importBtn') | translate }}</span>
</button>
<input #importInput type="file" accept=".zip,application/zip" hidden (change)="onImportFileSelected($event)">
</div>
@if (dataError) {
<div class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ dataError }}</span>
</div>
}
@if (dataMessage) {
<div class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ dataMessage }}</span>
</div>
}
</section>
@if (settings) {
<div class="actions">
<button class="btn-primary" (click)="save()" [disabled]="saving">

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel } from '../services/settings.service';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service';
import { LayoutService } from '../services/layout.service';
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
@@ -332,6 +332,60 @@ export class SettingsComponent implements OnInit {
this.router.navigate(['/lore']);
}
// --- Sauvegarde / Restauration des donnees --------------------------------
exporting = false;
importing = false;
dataMessage = '';
dataError = '';
/** Telecharge l'export complet (zip) via un lien temporaire. */
exportData(): void {
this.exporting = true;
this.dataMessage = '';
this.dataError = '';
this.settingsService.exportData().subscribe({
next: (blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'loremind-export.zip';
a.click();
URL.revokeObjectURL(url);
this.exporting = false;
},
error: (err) => {
this.dataError = this.extractError(err, this.translate.instant('settings.data.exportError'));
this.exporting = false;
}
});
}
/** Déclenché par le sélecteur de fichier : importe le zip choisi (fusion). */
onImportFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ''; // permet de re-sélectionner le même fichier
if (!file) return;
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
this.importing = true;
this.dataMessage = '';
this.dataError = '';
this.settingsService.importData(file).subscribe({
next: (result: ImportResult) => {
const total = Object.values(result.created).reduce((a, b) => a + b, 0);
this.dataMessage = this.translate.instant('settings.data.importSuccess', { count: total });
this.importing = false;
},
error: (err) => {
this.dataError = this.extractError(err, this.translate.instant('settings.data.importError'));
this.importing = false;
}
});
}
private extractError(err: any, fallback: string): string {
if (err?.error?.detail) return String(err.error.detail);
if (err?.message) return err.message;

View File

@@ -50,6 +50,18 @@
},
"settings": {
"title": "Settings",
"data": {
"title": "Data backup",
"hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.",
"exportBtn": "Export data",
"exporting": "Exporting…",
"importBtn": "Import a file",
"importing": "Importing…",
"importConfirm": "Import this file? Its content will be added to your instance (nothing is deleted).",
"importSuccess": "Import successful: {{count}} item(s) added.",
"exportError": "Export failed.",
"importError": "Import failed (invalid file?)."
},
"language": {
"title": "Interface language",
"hint": "Choose the application display language. The change is applied instantly and remembered on this device."

View File

@@ -50,6 +50,18 @@
},
"settings": {
"title": "Paramètres",
"data": {
"title": "Sauvegarde des données",
"hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.",
"exportBtn": "Exporter les données",
"exporting": "Export en cours…",
"importBtn": "Importer un fichier",
"importing": "Import en cours…",
"importConfirm": "Importer ce fichier ? Le contenu sera ajouté à votre instance (rien n'est supprimé).",
"importSuccess": "Import réussi : {{count}} élément(s) ajouté(s).",
"exportError": "Échec de l'export.",
"importError": "Échec de l'import (fichier invalide ?)."
},
"language": {
"title": "Langue de l'interface",
"hint": "Choix de la langue d'affichage de l'application. Le changement est immédiat et mémorisé sur cet appareil."