From 72fe5e62151407b46359946c33e5ce4469f688b1 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Thu, 18 Jun 2026 09:49:34 +0200 Subject: [PATCH] =?UTF-8?q?Mise=20en=20place=20de=20l'import=20/=20export?= =?UTF-8?q?=20des=20donn=C3=A9es=20pour=20pouvoir=20sauvegarder=20les=20lo?= =?UTF-8?q?res=20/=20campagnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/loremind/LoreMindApplication.java | 7 + .../domain/images/ports/ImageStorage.java | 15 + .../desktop/DesktopSingleInstance.java | 43 +- .../desktop/DesktopUserConfig.java | 166 +++++ .../desktop/SystemTrayManager.java | 16 + .../persistence/jpa/ImageJpaRepository.java | 6 +- .../FilesystemImageStorageAdapter.java | 13 + .../storage/MinioImageStorageAdapter.java | 16 + .../transfer/ExportService.java | 350 ++++++++++ .../infrastructure/transfer/ImportResult.java | 29 + .../transfer/ImportService.java | 616 ++++++++++++++++++ .../transfer/dto/ContentExport.java | 265 ++++++++ .../controller/DataTransferController.java | 85 +++ .../resources/application-local.properties | 19 +- web/src/app/services/settings.service.ts | 22 + web/src/app/settings/settings.component.html | 37 +- web/src/app/settings/settings.component.ts | 56 +- web/src/assets/i18n/en.json | 12 + web/src/assets/i18n/fr.json | 12 + 19 files changed, 1775 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/com/loremind/infrastructure/desktop/DesktopUserConfig.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ExportService.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ImportResult.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/ImportService.java create mode 100644 core/src/main/java/com/loremind/infrastructure/transfer/dto/ContentExport.java create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/DataTransferController.java 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 : + *

+ * 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 : + *

    + *
  1. Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.
  2. + *
  3. Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ; + * skip si une ImageJpaEntity avec cette cle existe deja.
  4. + *
  5. Insere top-down en construisant les maps de remapping par type.
  6. + *
  7. 2e passe : remappe les references qui pointent vers des types inserees + * plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.
  8. + *
+ * 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) {

+

{{ 'settings.data.title' | translate }}

+

+
+ + + +
+ @if (dataError) { +
+ + {{ dataError }} +
+ } + @if (dataMessage) { +
+ + {{ dataMessage }} +
+ } +
+ @if (settings) {