diff --git a/core/src/main/java/com/loremind/LoreMindApplication.java b/core/src/main/java/com/loremind/LoreMindApplication.java
index c5e6d5d..244fe5a 100644
--- a/core/src/main/java/com/loremind/LoreMindApplication.java
+++ b/core/src/main/java/com/loremind/LoreMindApplication.java
@@ -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);
}
diff --git a/core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java b/core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java
index eaeb644..548f485 100644
--- a/core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java
+++ b/core/src/main/java/com/loremind/domain/images/ports/ImageStorage.java
@@ -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).
+ *
+ * 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);
diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java
index fe9eaa5..79306cd 100644
--- a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java
+++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopSingleInstance.java
@@ -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);
diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java
new file mode 100644
index 0000000..8a89e96
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java
@@ -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).
+ *
+ * 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.
+ *
+ * - {@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.
+ *
+ * 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:).
+ # 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");
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java b/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java
index 68193f1..4dc2f1b 100644
--- a/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java
+++ b/core/src/main/java/com/loremind/infrastructure/desktop/SystemTrayManager.java
@@ -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);
diff --git a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java
index 95ba61e..51f1664 100644
--- a/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java
+++ b/core/src/main/java/com/loremind/infrastructure/persistence/jpa/ImageJpaRepository.java
@@ -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 {
+
+ /** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */
+ Optional findByStorageKey(String storageKey);
}
diff --git a/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java
index 532659f..50f1341 100644
--- a/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java
+++ b/core/src/main/java/com/loremind/infrastructure/storage/FilesystemImageStorageAdapter.java
@@ -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);
diff --git a/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java b/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java
index ee0c2f8..3cc7751 100644
--- a/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java
+++ b/core/src/main/java/com/loremind/infrastructure/storage/MinioImageStorageAdapter.java
@@ -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 {
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java
new file mode 100644
index 0000000..f64035a
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java
@@ -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.
+ *
+ * Construit un {@link ContentExport} a partir des entites JPA puis le serialise
+ * dans un .zip contenant :
+ *
+ *
{@code manifest.json} — metadonnees (version de format, version app, date)
+ *
{@code data.json} — tout le contenu (pretty-printed)
+ *
{@code images/} — un binaire par image referencee
+ *
+ * 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 gameSystems = gameSystemRepo.findAll().stream()
+ .map(this::toGameSystemDto).toList();
+ List lores = loreRepo.findAll().stream()
+ .map(this::toLoreDto).toList();
+ List loreNodes = loreNodeRepo.findAll().stream()
+ .map(this::toLoreNodeDto).toList();
+ List templates = templateRepo.findAll().stream()
+ .map(this::toTemplateDto).toList();
+ List pages = pageRepo.findAll().stream()
+ .map(this::toPageDto).toList();
+ List campaigns = campaignRepo.findAll().stream()
+ .map(this::toCampaignDto).toList();
+ List arcs = arcRepo.findAll().stream()
+ .map(this::toArcDto).toList();
+ List chapters = chapterRepo.findAll().stream()
+ .map(this::toChapterDto).toList();
+ List scenes = sceneRepo.findAll().stream()
+ .map(this::toSceneDto).toList();
+ List characters = characterRepo.findAll().stream()
+ .map(this::toCharacterDto).toList();
+ List npcs = npcRepo.findAll().stream()
+ .map(this::toNpcDto).toList();
+ List enemies = enemyRepo.findAll().stream()
+ .map(this::toEnemyDto).toList();
+ List itemCatalogs = itemCatalogRepo.findAll().stream()
+ .map(this::toItemCatalogDto).toList();
+ List randomTables = randomTableRepo.findAll().stream()
+ .map(this::toRandomTableDto).toList();
+ List 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.
+ *
+ * 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 referenced = collectReferencedStorageKeys(export);
+ Set 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 collectReferencedStorageKeys(ContentExport export) {
+ Set keys = new LinkedHashSet<>();
+ for (ContentExport.ArcDto a : export.arcs()) {
+ addAll(keys, a.illustrationImageIds());
+ addAll(keys, a.mapImageIds());
+ }
+ for (ContentExport.ChapterDto c : export.chapters()) {
+ addAll(keys, c.illustrationImageIds());
+ addAll(keys, c.mapImageIds());
+ }
+ for (ContentExport.SceneDto s : export.scenes()) {
+ addAll(keys, s.illustrationImageIds());
+ addAll(keys, s.mapImageIds());
+ }
+ for (ContentExport.CharacterDto c : export.characters()) {
+ add(keys, c.portraitImageId());
+ 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 keys, String key) {
+ if (key != null && !key.isBlank()) keys.add(key);
+ }
+
+ private void addAll(Set keys, List list) {
+ if (list != null) list.forEach(k -> add(keys, k));
+ }
+
+ private void addImageValues(Set keys, java.util.Map> 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 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 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());
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportResult.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportResult.java
new file mode 100644
index 0000000..de0d99f
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportResult.java
@@ -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 created, int imagesUploaded, int imagesReused) {
+
+ public static class Builder {
+ private final Map 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);
+ }
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
new file mode 100644
index 0000000..6f3b76b
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java
@@ -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.
+ *
+ * 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.
+ *
+ * Algorithme :
+ *
+ *
Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.
+ *
Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ;
+ * skip si une ImageJpaEntity avec cette cle existe deja.
+ *
Insere top-down en construisant les maps de remapping par type.
+ *
2e passe : remappe les references qui pointent vers des types inserees
+ * plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.
+ *
+ * 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 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 gameSystemMap = new HashMap<>();
+ Map loreMap = new HashMap<>();
+ Map loreNodeMap = new HashMap<>();
+ Map templateMap = new HashMap<>();
+ Map pageMap = new HashMap<>();
+ Map campaignMap = new HashMap<>();
+ Map arcMap = new HashMap<>();
+ Map chapterMap = new HashMap<>();
+ Map npcMap = new HashMap<>();
+ Map enemyMap = new HashMap<>();
+ Map characterMap = new HashMap<>();
+ Map 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 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 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 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 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 imageBinaries,
+ ImportResult.Builder result) {
+ // Index des metadonnees d'image par cle (depuis le data.json).
+ Map metaByKey = new HashMap<>();
+ for (ContentExport.ImageDto img : nullSafe(export.images())) {
+ if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
+ }
+
+ for (Map.Entry 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 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 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 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 remapStringList(Map map, List ids) {
+ if (ids == null) return null;
+ List out = new ArrayList<>(ids.size());
+ for (String id : ids) out.add(remapStringId(map, id));
+ return out;
+ }
+
+ private List remapPrerequisites(Map chapterMap, List prereqs) {
+ if (prereqs == null) return null;
+ List 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 remapBranches(Map sceneMap, List branches) {
+ if (branches == null) return null;
+ List 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 List nullSafe(List 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";
+ }
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java
new file mode 100644
index 0000000..c4cdd94
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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 gameSystems,
+ List lores,
+ List loreNodes,
+ List templates,
+ List pages,
+ List campaigns,
+ List arcs,
+ List chapters,
+ List scenes,
+ List characters,
+ List npcs,
+ List enemies,
+ List itemCatalogs,
+ List randomTables,
+ List 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 characterTemplate,
+ List npcTemplate,
+ List 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 fields
+ ) {}
+
+ public record PageDto(
+ Long id,
+ Long loreId,
+ Long nodeId,
+ Long templateId,
+ String title,
+ Map values,
+ Map> imageValues,
+ Map> keyValueValues,
+ Map>> tableValues,
+ String notes,
+ List tags,
+ List 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 relatedPageIds,
+ List illustrationImageIds,
+ List 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 relatedPageIds,
+ List illustrationImageIds,
+ List 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 enemyIds,
+ List relatedPageIds,
+ List illustrationImageIds,
+ List mapImageIds,
+ List branches,
+ List rooms
+ ) {}
+
+ public record CharacterDto(
+ Long id,
+ String name,
+ String portraitImageId,
+ String headerImageId,
+ Map values,
+ Map> imageValues,
+ Map> keyValueValues,
+ Long campaignId,
+ Long playthroughId,
+ int order
+ ) {}
+
+ public record NpcDto(
+ Long id,
+ String name,
+ String portraitImageId,
+ String headerImageId,
+ Map values,
+ Map> imageValues,
+ Map> keyValueValues,
+ Long campaignId,
+ List relatedPageIds,
+ String folder,
+ int order
+ ) {}
+
+ public record EnemyDto(
+ Long id,
+ String name,
+ String level,
+ String folder,
+ String portraitImageId,
+ String headerImageId,
+ Map values,
+ Map> imageValues,
+ Map> keyValueValues,
+ Long campaignId,
+ int order
+ ) {}
+
+ public record ItemCatalogDto(
+ Long id,
+ String name,
+ String description,
+ String icon,
+ Long campaignId,
+ int order,
+ List 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 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/}. La cle est PRESERVEE telle quelle a l'import.
+ */
+ public record ImageDto(
+ Long id,
+ String filename,
+ String contentType,
+ long sizeBytes,
+ String storageKey
+ ) {}
+}
diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java
new file mode 100644
index 0000000..087d833
--- /dev/null
+++ b/core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java
@@ -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).
+ *
+ * - {@code GET /api/admin/data/export} : telecharge un .zip portable.
+ * - {@code POST /api/admin/data/import} : importe un .zip en mode FUSION.
+ *
+ * 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 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 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");
+ }
+ }
+}
diff --git a/core/src/main/resources/application-local.properties b/core/src/main/resources/application-local.properties
index 8f2da0c..0dd22f6 100644
--- a/core/src/main/resources/application-local.properties
+++ b/core/src/main/resources/application-local.properties
@@ -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}
diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts
index a404900..e6a6573 100644
--- a/web/src/app/services/settings.service.ts
+++ b/web/src/app/services/settings.service.ts
@@ -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;
+ 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(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 {
+ 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 {
+ const form = new FormData();
+ form.append('file', file);
+ return this.http.post('/api/admin/data/import', form, this.authOptions);
+ }
+
listOllamaModels(): Observable<{ models: string[] }> {
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
}
diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html
index bec2234..6c8907b 100644
--- a/web/src/app/settings/settings.component.html
+++ b/web/src/app/settings/settings.component.html
@@ -112,7 +112,7 @@
@if (settings.onemin_api_key_set) {
@@ -149,7 +149,7 @@
@if (settings.openrouter_api_key_set) {
@@ -183,7 +183,7 @@
@if (settings.mistral_api_key_set) {
@@ -213,7 +213,7 @@
@if (settings.gemini_api_key_set) {
@@ -285,7 +285,7 @@
{{ 'settings.apiKey.configured' | translate }}
}
-
@if (settings.mistral_api_key_set) {