Compare commits
4 Commits
v0.15.0
...
f04ecf1021
| Author | SHA1 | Date | |
|---|---|---|---|
| f04ecf1021 | |||
| 72fe5e6215 | |||
| 7dfa9c3655 | |||
| 7aa174d75a |
40
.github/workflows/desktop-release.yml
vendored
40
.github/workflows/desktop-release.yml
vendored
@@ -22,6 +22,14 @@ name: Desktop installers
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
# Declenchement MANUEL depuis l'onglet Actions ("Run workflow"). Utile quand un
|
||||
# tag a ete pousse AVANT que le workflow existe sur GitHub (ne se redeclenche
|
||||
# pas tout seul), ou pour rejouer un build. Saisir la version SANS le "v".
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version a builder (doit correspondre a un tag existant, ex: 0.15.0 ou 0.15.0-beta)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write # requis pour creer la Release et y attacher le .msi
|
||||
@@ -30,7 +38,12 @@ jobs:
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
# En declenchement manuel, on checkout le TAG correspondant a la version
|
||||
# saisie (sinon checkout prendrait la branche par defaut). En push de tag,
|
||||
# on prend la ref poussee.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('v{0}', inputs.version) || github.ref }}
|
||||
|
||||
# Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
|
||||
- name: Set up JDK 21
|
||||
@@ -57,24 +70,37 @@ jobs:
|
||||
shell: pwsh
|
||||
run: choco install wixtoolset -y --no-progress
|
||||
|
||||
# Version de l'installeur = version du tag (numerique, sans suffixe -beta).
|
||||
- name: Derive version from tag
|
||||
# Version de l'installeur = version du tag (push) OU de l'input (manuel).
|
||||
# Sorties : version (numerique X.Y.Z pour le MSI), tag (vX.Y.Z[-beta]),
|
||||
# isbeta (true/false) — independant du nom de ref (qui est une branche en manuel).
|
||||
- name: Derive version
|
||||
id: ver
|
||||
shell: pwsh
|
||||
run: |
|
||||
$full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1
|
||||
$num = ($full -split '-')[0] # ex: 0.15.0
|
||||
"version=$num" >> $env:GITHUB_OUTPUT
|
||||
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
|
||||
$raw = '${{ inputs.version }}'
|
||||
} else {
|
||||
$raw = '${{ github.ref_name }}'
|
||||
}
|
||||
$raw = $raw -replace '^v','' # 0.15.0 ou 0.15.0-beta
|
||||
$num = ($raw -split '-')[0] # 0.15.0
|
||||
$isbeta = if ($raw -like '*-beta*') { 'true' } else { 'false' }
|
||||
"version=$num" >> $env:GITHUB_OUTPUT
|
||||
"tag=v$raw" >> $env:GITHUB_OUTPUT
|
||||
"isbeta=$isbeta" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Build Windows installer
|
||||
shell: pwsh
|
||||
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
|
||||
|
||||
# STABLE uniquement : Release GitHub publique avec le .msi.
|
||||
# tag_name explicite : en declenchement manuel, github.ref est une branche,
|
||||
# donc on cible le tag derive (la release est attachee au bon tag).
|
||||
- name: Publish installer to GitHub Release (stable)
|
||||
if: ${{ !contains(github.ref_name, '-beta') }}
|
||||
if: ${{ steps.ver.outputs.isbeta == 'false' }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
files: core/target/dist-out/*.msi
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
@@ -82,7 +108,7 @@ jobs:
|
||||
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
|
||||
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
|
||||
- name: Upload installer as private artifact (beta)
|
||||
if: ${{ contains(github.ref_name, '-beta') }}
|
||||
if: ${{ steps.ver.outputs.isbeta == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: loremind-beta-${{ steps.ver.outputs.version }}-msi
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="0.15.0",
|
||||
version="0.16.0",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.15.0</version>
|
||||
<version>0.16.0</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,21 @@ public interface ImageStorage {
|
||||
*/
|
||||
String upload(String filename, String contentType, InputStream data, long sizeBytes);
|
||||
|
||||
/**
|
||||
* Stocke un flux binaire SOUS UNE CLE IMPOSEE (pas de generation).
|
||||
* <p>
|
||||
* Utilise par l'import de contenu pour reinjecter une image sous sa cle
|
||||
* d'origine, garantissant que les references {@code storageKey} portees par
|
||||
* les entites restent valides apres un transfert inter-instance.
|
||||
* Ecrase si la cle existe deja.
|
||||
*
|
||||
* @param storageKey cle opaque exacte sous laquelle stocker (ex: images/UUID.ext)
|
||||
* @param contentType MIME type
|
||||
* @param data flux binaire a stocker
|
||||
* @param sizeBytes taille en octets (requis par certains backends comme S3)
|
||||
*/
|
||||
void store(String storageKey, String contentType, InputStream data, long sizeBytes);
|
||||
|
||||
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
|
||||
InputStream download(String storageKey);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.loremind.infrastructure.desktop;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* Configuration UTILISATEUR du mode bureau, dans un fichier éditable
|
||||
* {@code ~/.loremind/loremind.properties} (port HTTP, identifiants admin).
|
||||
* <p>
|
||||
* Pourquoi un fichier et pas l'UI : le port et le mot de passe admin sont requis
|
||||
* AVANT que le serveur (et donc l'UI web) ne démarre — impossible de les régler
|
||||
* depuis l'app elle-même. Un fichier texte simple, lu au démarrage, est le
|
||||
* pattern standard d'une app de bureau. Les modifs prennent effet au prochain
|
||||
* lancement.
|
||||
* <p>
|
||||
* - {@code server.port} : lu ici (résolution du port) ET par Spring.
|
||||
* - {@code admin.username} / {@code admin.password} : chargés par Spring via
|
||||
* {@code spring.config.import} (cf. application-local.properties) — ils
|
||||
* surchargent les défauts du profil local.
|
||||
* <p>
|
||||
* Repli de port : si le port configuré est occupé, on en choisit un libre
|
||||
* automatiquement (évite un échec de démarrage cryptique sur conflit de port)
|
||||
* et on écrit le port réellement utilisé dans {@code ~/.loremind/.port} pour que
|
||||
* l'ouverture du navigateur (instance gagnante OU 2e double-clic) cible la bonne URL.
|
||||
*/
|
||||
public final class DesktopUserConfig {
|
||||
|
||||
private DesktopUserConfig() {}
|
||||
|
||||
private static final String DEFAULT_TEMPLATE = """
|
||||
# ============================================================
|
||||
# Configuration locale de LoreMind (mode bureau)
|
||||
# ------------------------------------------------------------
|
||||
# Modifiez ces valeurs puis RELANCEZ LoreMind pour appliquer.
|
||||
# ============================================================
|
||||
|
||||
# Port HTTP local de l'application (http://localhost:<port>).
|
||||
# Si ce port est deja occupe par une autre application, LoreMind
|
||||
# choisira automatiquement un autre port libre au demarrage.
|
||||
server.port=8080
|
||||
|
||||
# Identifiants de la page Parametres (acces admin).
|
||||
# Accessible uniquement en local sur cette machine.
|
||||
admin.username=admin
|
||||
admin.password=admin
|
||||
""";
|
||||
|
||||
/** Chemin du fichier de config utilisateur (pour l'ouvrir depuis le menu systray). */
|
||||
public static Path getConfigFile() {
|
||||
return configFile();
|
||||
}
|
||||
|
||||
/** Dossier de données/config de l'instance ({@code ~/.loremind}). */
|
||||
public static Path getHomeDir() {
|
||||
return loremindHome();
|
||||
}
|
||||
|
||||
/** Crée le fichier de config avec des valeurs par défaut commentées s'il n'existe pas. */
|
||||
public static void ensureExists() {
|
||||
Path file = configFile();
|
||||
if (Files.exists(file)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.writeString(file, DEFAULT_TEMPLATE);
|
||||
System.out.println("[Desktop] Config utilisateur creee : " + file);
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Impossible de creer " + file + " : " + e.getMessage()
|
||||
+ " — defauts utilises (port 8080, admin/admin).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout le port à utiliser : le port configuré s'il est libre, sinon un port
|
||||
* libre choisi automatiquement. Publie le résultat dans la propriété système
|
||||
* {@code server.port} (que Spring lira en priorité) et dans {@code ~/.loremind/.port}.
|
||||
*
|
||||
* @return le port effectivement retenu.
|
||||
*/
|
||||
public static int resolveAndPublishPort() {
|
||||
int wanted = configuredPort();
|
||||
int chosen = isPortFree(wanted) ? wanted : findFreePort(wanted);
|
||||
if (chosen != wanted) {
|
||||
System.out.println("[Desktop] Port " + wanted + " occupe — repli sur le port libre " + chosen + ".");
|
||||
}
|
||||
System.setProperty("server.port", String.valueOf(chosen));
|
||||
try {
|
||||
Files.writeString(portFile(), String.valueOf(chosen));
|
||||
} catch (IOException e) {
|
||||
System.err.println("[Desktop] Ecriture du port impossible (" + e.getMessage() + ").");
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port sur lequel l'application répond réellement (pour ouvrir le navigateur).
|
||||
* Ordre : propriété système (instance gagnante) → fichier .port (2e double-clic)
|
||||
* → port configuré → 8080.
|
||||
*/
|
||||
public static int runningPort() {
|
||||
String sys = System.getProperty("server.port");
|
||||
if (sys != null && !sys.isBlank()) {
|
||||
try { return Integer.parseInt(sys.trim()); } catch (NumberFormatException ignored) { /* fallthrough */ }
|
||||
}
|
||||
try {
|
||||
Path p = portFile();
|
||||
if (Files.exists(p)) {
|
||||
return Integer.parseInt(Files.readString(p).trim());
|
||||
}
|
||||
} catch (IOException | NumberFormatException ignored) { /* fallthrough */ }
|
||||
return configuredPort();
|
||||
}
|
||||
|
||||
/** Port lu dans le fichier de config utilisateur (défaut 8080). */
|
||||
private static int configuredPort() {
|
||||
Path file = configFile();
|
||||
if (Files.exists(file)) {
|
||||
Properties props = new Properties();
|
||||
try (InputStream in = Files.newInputStream(file)) {
|
||||
props.load(in);
|
||||
String v = props.getProperty("server.port");
|
||||
if (v != null && !v.isBlank()) {
|
||||
return Integer.parseInt(v.trim());
|
||||
}
|
||||
} catch (IOException | NumberFormatException ignored) { /* défaut */ }
|
||||
}
|
||||
return 8080;
|
||||
}
|
||||
|
||||
private static boolean isPortFree(int port) {
|
||||
try (ServerSocket s = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1"))) {
|
||||
s.setReuseAddress(true);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static int findFreePort(int fallbackIfNone) {
|
||||
try (ServerSocket s = new ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))) {
|
||||
return s.getLocalPort();
|
||||
} catch (IOException e) {
|
||||
return fallbackIfNone; // tres improbable ; on retente le port voulu
|
||||
}
|
||||
}
|
||||
|
||||
private static Path configFile() {
|
||||
return loremindHome().resolve("loremind.properties");
|
||||
}
|
||||
|
||||
private static Path portFile() {
|
||||
return loremindHome().resolve(".port");
|
||||
}
|
||||
|
||||
private static Path loremindHome() {
|
||||
String home = System.getProperty("loremind.home");
|
||||
if (home != null && !home.isBlank()) return Path.of(home);
|
||||
return Path.of(System.getProperty("user.home"), ".loremind");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -4,10 +4,14 @@ import com.loremind.infrastructure.persistence.entity.ImageJpaEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Repository Spring Data JPA pour ImageJpaEntity.
|
||||
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
|
||||
*/
|
||||
@Repository
|
||||
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
|
||||
|
||||
/** Recherche par cle de stockage (unique). Utilise par l'import de contenu. */
|
||||
Optional<ImageJpaEntity> findByStorageKey(String storageKey);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
import com.loremind.infrastructure.persistence.jpa.*;
|
||||
import com.loremind.infrastructure.transfer.dto.ContentExport;
|
||||
import org.springframework.boot.info.BuildProperties;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
/**
|
||||
* Service d'EXPORT du "contenu" en format logique (JSON) portable.
|
||||
* <p>
|
||||
* Construit un {@link ContentExport} a partir des entites JPA puis le serialise
|
||||
* dans un .zip contenant :
|
||||
* <ul>
|
||||
* <li>{@code manifest.json} — metadonnees (version de format, version app, date)</li>
|
||||
* <li>{@code data.json} — tout le contenu (pretty-printed)</li>
|
||||
* <li>{@code images/<storageKey>} — un binaire par image referencee</li>
|
||||
* </ul>
|
||||
* Volontairement DECOUPLE de la base : c'est un export logique, pas un dump,
|
||||
* pour fonctionner entre Postgres (Docker) et H2 (local).
|
||||
*/
|
||||
@Service
|
||||
public class ExportService {
|
||||
|
||||
private static final int FORMAT_VERSION = 1;
|
||||
|
||||
// Réutilise le converter JPA pour (dé)sérialiser les prérequis dans le MÊME
|
||||
// format que la base (discriminant "kind"), au lieu de Jackson polymorphe.
|
||||
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
|
||||
|
||||
private final GameSystemJpaRepository gameSystemRepo;
|
||||
private final LoreJpaRepository loreRepo;
|
||||
private final LoreNodeJpaRepository loreNodeRepo;
|
||||
private final TemplateJpaRepository templateRepo;
|
||||
private final PageJpaRepository pageRepo;
|
||||
private final CampaignJpaRepository campaignRepo;
|
||||
private final ArcJpaRepository arcRepo;
|
||||
private final ChapterJpaRepository chapterRepo;
|
||||
private final SceneJpaRepository sceneRepo;
|
||||
private final CharacterJpaRepository characterRepo;
|
||||
private final NpcJpaRepository npcRepo;
|
||||
private final EnemyJpaRepository enemyRepo;
|
||||
private final ItemCatalogJpaRepository itemCatalogRepo;
|
||||
private final RandomTableJpaRepository randomTableRepo;
|
||||
private final ImageJpaRepository imageRepo;
|
||||
private final ImageStorage imageStorage;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String appVersion;
|
||||
|
||||
public ExportService(GameSystemJpaRepository gameSystemRepo,
|
||||
LoreJpaRepository loreRepo,
|
||||
LoreNodeJpaRepository loreNodeRepo,
|
||||
TemplateJpaRepository templateRepo,
|
||||
PageJpaRepository pageRepo,
|
||||
CampaignJpaRepository campaignRepo,
|
||||
ArcJpaRepository arcRepo,
|
||||
ChapterJpaRepository chapterRepo,
|
||||
SceneJpaRepository sceneRepo,
|
||||
CharacterJpaRepository characterRepo,
|
||||
NpcJpaRepository npcRepo,
|
||||
EnemyJpaRepository enemyRepo,
|
||||
ItemCatalogJpaRepository itemCatalogRepo,
|
||||
RandomTableJpaRepository randomTableRepo,
|
||||
ImageJpaRepository imageRepo,
|
||||
ImageStorage imageStorage,
|
||||
ObjectMapper objectMapper,
|
||||
@Nullable BuildProperties buildProperties) {
|
||||
this.gameSystemRepo = gameSystemRepo;
|
||||
this.loreRepo = loreRepo;
|
||||
this.loreNodeRepo = loreNodeRepo;
|
||||
this.templateRepo = templateRepo;
|
||||
this.pageRepo = pageRepo;
|
||||
this.campaignRepo = campaignRepo;
|
||||
this.arcRepo = arcRepo;
|
||||
this.chapterRepo = chapterRepo;
|
||||
this.sceneRepo = sceneRepo;
|
||||
this.characterRepo = characterRepo;
|
||||
this.npcRepo = npcRepo;
|
||||
this.enemyRepo = enemyRepo;
|
||||
this.itemCatalogRepo = itemCatalogRepo;
|
||||
this.randomTableRepo = randomTableRepo;
|
||||
this.imageRepo = imageRepo;
|
||||
this.imageStorage = imageStorage;
|
||||
this.objectMapper = objectMapper;
|
||||
this.appVersion = buildProperties != null ? buildProperties.getVersion() : "dev";
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge toutes les entites du perimetre et les mappe vers les DTO plats.
|
||||
*
|
||||
* @param exportedAt horodatage ISO stampe par la couche appelante (controller)
|
||||
*/
|
||||
public ContentExport buildExport(String exportedAt) {
|
||||
ContentExport.Manifest manifest =
|
||||
new ContentExport.Manifest(FORMAT_VERSION, appVersion, exportedAt);
|
||||
|
||||
List<ContentExport.GameSystemDto> gameSystems = gameSystemRepo.findAll().stream()
|
||||
.map(this::toGameSystemDto).toList();
|
||||
List<ContentExport.LoreDto> lores = loreRepo.findAll().stream()
|
||||
.map(this::toLoreDto).toList();
|
||||
List<ContentExport.LoreNodeDto> loreNodes = loreNodeRepo.findAll().stream()
|
||||
.map(this::toLoreNodeDto).toList();
|
||||
List<ContentExport.TemplateDto> templates = templateRepo.findAll().stream()
|
||||
.map(this::toTemplateDto).toList();
|
||||
List<ContentExport.PageDto> pages = pageRepo.findAll().stream()
|
||||
.map(this::toPageDto).toList();
|
||||
List<ContentExport.CampaignDto> campaigns = campaignRepo.findAll().stream()
|
||||
.map(this::toCampaignDto).toList();
|
||||
List<ContentExport.ArcDto> arcs = arcRepo.findAll().stream()
|
||||
.map(this::toArcDto).toList();
|
||||
List<ContentExport.ChapterDto> chapters = chapterRepo.findAll().stream()
|
||||
.map(this::toChapterDto).toList();
|
||||
List<ContentExport.SceneDto> scenes = sceneRepo.findAll().stream()
|
||||
.map(this::toSceneDto).toList();
|
||||
List<ContentExport.CharacterDto> characters = characterRepo.findAll().stream()
|
||||
.map(this::toCharacterDto).toList();
|
||||
List<ContentExport.NpcDto> npcs = npcRepo.findAll().stream()
|
||||
.map(this::toNpcDto).toList();
|
||||
List<ContentExport.EnemyDto> enemies = enemyRepo.findAll().stream()
|
||||
.map(this::toEnemyDto).toList();
|
||||
List<ContentExport.ItemCatalogDto> itemCatalogs = itemCatalogRepo.findAll().stream()
|
||||
.map(this::toItemCatalogDto).toList();
|
||||
List<ContentExport.RandomTableDto> randomTables = randomTableRepo.findAll().stream()
|
||||
.map(this::toRandomTableDto).toList();
|
||||
List<ContentExport.ImageDto> images = imageRepo.findAll().stream()
|
||||
.map(this::toImageDto).toList();
|
||||
|
||||
return new ContentExport(manifest, gameSystems, lores, loreNodes, templates,
|
||||
pages, campaigns, arcs, chapters, scenes, characters, npcs, enemies,
|
||||
itemCatalogs, randomTables, images);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise un export dans le flux fourni au format .zip.
|
||||
* <p>
|
||||
* Les binaires d'images ne sont ecrits que pour les storageKeys REFERENCES
|
||||
* par les entites exportees (illustration/map/portrait/header/imageValues),
|
||||
* pas pour toute la table images — on evite de trimballer des orphelins.
|
||||
*/
|
||||
public void writeZip(ContentExport export, OutputStream out) {
|
||||
try (ZipOutputStream zip = new ZipOutputStream(out)) {
|
||||
// manifest.json
|
||||
zip.putNextEntry(new ZipEntry("manifest.json"));
|
||||
zip.write(objectMapper.writerWithDefaultPrettyPrinter()
|
||||
.writeValueAsBytes(export.manifest()));
|
||||
zip.closeEntry();
|
||||
|
||||
// data.json
|
||||
zip.putNextEntry(new ZipEntry("data.json"));
|
||||
zip.write(objectMapper.copy()
|
||||
.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
.writeValueAsBytes(export));
|
||||
zip.closeEntry();
|
||||
|
||||
// Binaires images : uniquement ceux reellement references.
|
||||
Set<String> referenced = collectReferencedStorageKeys(export);
|
||||
Set<String> written = new LinkedHashSet<>();
|
||||
for (String key : referenced) {
|
||||
if (key == null || key.isBlank() || !written.add(key)) {
|
||||
continue;
|
||||
}
|
||||
try (InputStream data = imageStorage.download(key)) {
|
||||
if (data == null) {
|
||||
continue; // cle orpheline : on ignore silencieusement
|
||||
}
|
||||
zip.putNextEntry(new ZipEntry("images/" + key));
|
||||
data.transferTo(zip);
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Echec de la generation du zip d'export", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collecte tous les storageKeys references par le contenu exporte :
|
||||
* Arc/Chapter/Scene (illustration + map), Character/Npc/Enemy
|
||||
* (portrait + header + imageValues), Page (imageValues).
|
||||
*/
|
||||
private Set<String> collectReferencedStorageKeys(ContentExport export) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
for (ContentExport.ArcDto a : export.arcs()) {
|
||||
addAll(keys, a.illustrationImageIds());
|
||||
addAll(keys, a.mapImageIds());
|
||||
}
|
||||
for (ContentExport.ChapterDto c : export.chapters()) {
|
||||
addAll(keys, c.illustrationImageIds());
|
||||
addAll(keys, c.mapImageIds());
|
||||
}
|
||||
for (ContentExport.SceneDto s : export.scenes()) {
|
||||
addAll(keys, s.illustrationImageIds());
|
||||
addAll(keys, s.mapImageIds());
|
||||
}
|
||||
for (ContentExport.CharacterDto c : export.characters()) {
|
||||
add(keys, c.portraitImageId());
|
||||
add(keys, c.headerImageId());
|
||||
addImageValues(keys, c.imageValues());
|
||||
}
|
||||
for (ContentExport.NpcDto n : export.npcs()) {
|
||||
add(keys, n.portraitImageId());
|
||||
add(keys, n.headerImageId());
|
||||
addImageValues(keys, n.imageValues());
|
||||
}
|
||||
for (ContentExport.EnemyDto e : export.enemies()) {
|
||||
add(keys, e.portraitImageId());
|
||||
add(keys, e.headerImageId());
|
||||
addImageValues(keys, e.imageValues());
|
||||
}
|
||||
for (ContentExport.PageDto p : export.pages()) {
|
||||
addImageValues(keys, p.imageValues());
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private void add(Set<String> keys, String key) {
|
||||
if (key != null && !key.isBlank()) keys.add(key);
|
||||
}
|
||||
|
||||
private void addAll(Set<String> keys, List<String> list) {
|
||||
if (list != null) list.forEach(k -> add(keys, k));
|
||||
}
|
||||
|
||||
private void addImageValues(Set<String> keys, java.util.Map<String, List<String>> imageValues) {
|
||||
if (imageValues != null) imageValues.values().forEach(l -> addAll(keys, l));
|
||||
}
|
||||
|
||||
// ----- Mappers entite -> DTO -----
|
||||
|
||||
private ContentExport.GameSystemDto toGameSystemDto(GameSystemJpaEntity e) {
|
||||
return new ContentExport.GameSystemDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getRulesMarkdown(), e.getCharacterTemplate(), e.getNpcTemplate(),
|
||||
e.getEnemyTemplate(), e.getAuthor(), e.isPublic());
|
||||
}
|
||||
|
||||
private ContentExport.LoreDto toLoreDto(LoreJpaEntity e) {
|
||||
return new ContentExport.LoreDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getNodeCount(), e.getPageCount());
|
||||
}
|
||||
|
||||
private ContentExport.LoreNodeDto toLoreNodeDto(LoreNodeJpaEntity e) {
|
||||
return new ContentExport.LoreNodeDto(e.getId(), e.getName(), e.getIcon(),
|
||||
e.getParentId(), e.getLoreId());
|
||||
}
|
||||
|
||||
private ContentExport.TemplateDto toTemplateDto(TemplateJpaEntity e) {
|
||||
return new ContentExport.TemplateDto(e.getId(), e.getLoreId(), e.getName(),
|
||||
e.getDescription(), e.getDefaultNodeId(), e.getFields());
|
||||
}
|
||||
|
||||
private ContentExport.PageDto toPageDto(PageJpaEntity e) {
|
||||
return new ContentExport.PageDto(e.getId(), e.getLoreId(), e.getNodeId(),
|
||||
e.getTemplateId(), e.getTitle(), e.getValues(), e.getImageValues(),
|
||||
e.getKeyValueValues(), e.getTableValues(), e.getNotes(), e.getTags(),
|
||||
e.getRelatedPageIds());
|
||||
}
|
||||
|
||||
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
|
||||
return new ContentExport.CampaignDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getArcsCount(), e.getLoreId(), e.getGameSystemId());
|
||||
}
|
||||
|
||||
private ContentExport.ArcDto toArcDto(ArcJpaEntity e) {
|
||||
return new ContentExport.ArcDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getCampaignId(), e.getOrder(),
|
||||
e.getType() != null ? e.getType().name() : null,
|
||||
e.getIcon(), e.getThemes(), e.getStakes(), e.getGmNotes(),
|
||||
e.getRewards(), e.getResolution(), e.getRelatedPageIds(),
|
||||
e.getIllustrationImageIds(), e.getMapImageIds());
|
||||
}
|
||||
|
||||
private ContentExport.ChapterDto toChapterDto(ChapterJpaEntity e) {
|
||||
return new ContentExport.ChapterDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getArcId(), e.getOrder(), PREREQ_CONVERTER.convertToDatabaseColumn(e.getPrerequisites()), e.getIcon(),
|
||||
e.getGmNotes(), e.getPlayerObjectives(), e.getNarrativeStakes(),
|
||||
e.getRelatedPageIds(), e.getIllustrationImageIds(), e.getMapImageIds());
|
||||
}
|
||||
|
||||
private ContentExport.SceneDto toSceneDto(SceneJpaEntity e) {
|
||||
return new ContentExport.SceneDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getChapterId(), e.getOrder(), e.getIcon(), e.getLocation(),
|
||||
e.getTiming(), e.getAtmosphere(), e.getPlayerNarration(),
|
||||
e.getGmSecretNotes(), e.getChoicesConsequences(), e.getCombatDifficulty(),
|
||||
e.getEnemies(), e.getEnemyIds(), e.getRelatedPageIds(),
|
||||
e.getIllustrationImageIds(), e.getMapImageIds(), e.getBranches(), e.getRooms());
|
||||
}
|
||||
|
||||
private ContentExport.CharacterDto toCharacterDto(CharacterJpaEntity e) {
|
||||
return new ContentExport.CharacterDto(e.getId(), e.getName(), e.getPortraitImageId(),
|
||||
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
|
||||
e.getCampaignId(), e.getPlaythroughId(), e.getOrder());
|
||||
}
|
||||
|
||||
private ContentExport.NpcDto toNpcDto(NpcJpaEntity e) {
|
||||
return new ContentExport.NpcDto(e.getId(), e.getName(), e.getPortraitImageId(),
|
||||
e.getHeaderImageId(), e.getValues(), e.getImageValues(), e.getKeyValueValues(),
|
||||
e.getCampaignId(), e.getRelatedPageIds(), e.getFolder(), e.getOrder());
|
||||
}
|
||||
|
||||
private ContentExport.EnemyDto toEnemyDto(EnemyJpaEntity e) {
|
||||
return new ContentExport.EnemyDto(e.getId(), e.getName(), e.getLevel(), e.getFolder(),
|
||||
e.getPortraitImageId(), e.getHeaderImageId(), e.getValues(), e.getImageValues(),
|
||||
e.getKeyValueValues(), e.getCampaignId(), e.getOrder());
|
||||
}
|
||||
|
||||
private ContentExport.ItemCatalogDto toItemCatalogDto(ItemCatalogJpaEntity e) {
|
||||
List<ContentExport.CatalogItemDto> items = new ArrayList<>();
|
||||
if (e.getItems() != null) {
|
||||
for (CatalogItemJpaEntity i : e.getItems()) {
|
||||
items.add(new ContentExport.CatalogItemDto(i.getId(), i.getName(),
|
||||
i.getPrice(), i.getCategory(), i.getDescription(), i.getPosition()));
|
||||
}
|
||||
}
|
||||
return new ContentExport.ItemCatalogDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getIcon(), e.getCampaignId(), e.getOrder(), items);
|
||||
}
|
||||
|
||||
private ContentExport.RandomTableDto toRandomTableDto(RandomTableJpaEntity e) {
|
||||
List<ContentExport.RandomTableEntryDto> entries = new ArrayList<>();
|
||||
if (e.getEntries() != null) {
|
||||
for (RandomTableEntryJpaEntity en : e.getEntries()) {
|
||||
entries.add(new ContentExport.RandomTableEntryDto(en.getId(), en.getMinRoll(),
|
||||
en.getMaxRoll(), en.getLabel(), en.getDetail(), en.getPosition()));
|
||||
}
|
||||
}
|
||||
return new ContentExport.RandomTableDto(e.getId(), e.getName(), e.getDescription(),
|
||||
e.getDiceFormula(), e.getIcon(), e.getCampaignId(), e.getOrder(), entries);
|
||||
}
|
||||
|
||||
private ContentExport.ImageDto toImageDto(ImageJpaEntity e) {
|
||||
return new ContentExport.ImageDto(e.getId(), e.getFilename(), e.getContentType(),
|
||||
e.getSizeBytes(), e.getStorageKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Resume d'un import : nombre d'entites creees par type, + nombre d'images
|
||||
* reuploadees / reutilisees.
|
||||
*/
|
||||
public record ImportResult(Map<String, Integer> created, int imagesUploaded, int imagesReused) {
|
||||
|
||||
public static class Builder {
|
||||
private final Map<String, Integer> created = new LinkedHashMap<>();
|
||||
private int imagesUploaded;
|
||||
private int imagesReused;
|
||||
|
||||
public void count(String type, int n) {
|
||||
created.merge(type, n, Integer::sum);
|
||||
}
|
||||
|
||||
public void imageUploaded() { imagesUploaded++; }
|
||||
|
||||
public void imageReused() { imagesReused++; }
|
||||
|
||||
public ImportResult build() {
|
||||
return new ImportResult(created, imagesUploaded, imagesReused);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
package com.loremind.infrastructure.transfer;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.campaigncontext.Prerequisite;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.images.ports.ImageStorage;
|
||||
import com.loremind.infrastructure.persistence.converter.PrerequisiteListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
import com.loremind.infrastructure.persistence.jpa.*;
|
||||
import com.loremind.infrastructure.transfer.dto.ContentExport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Service d'IMPORT de contenu en mode FUSION.
|
||||
* <p>
|
||||
* On NE remplace PAS l'existant : chaque entite importee est reinseree avec un
|
||||
* NOUVEL id auto-genere, et toutes les references (FK Long et refs faibles
|
||||
* String) sont remappees oldId -> newId. Cela permet d'agreger plusieurs exports
|
||||
* dans une meme base sans collision, entre Postgres et H2.
|
||||
* <p>
|
||||
* Algorithme :
|
||||
* <ol>
|
||||
* <li>Parse le zip : {@code data.json} -> {@link ContentExport}, binaires gardes en memoire.</li>
|
||||
* <li>Reecrit les images sous LEUR CLE D'ORIGINE (pas de remapping de cle) ;
|
||||
* skip si une ImageJpaEntity avec cette cle existe deja.</li>
|
||||
* <li>Insere top-down en construisant les maps de remapping par type.</li>
|
||||
* <li>2e passe : remappe les references qui pointent vers des types inserees
|
||||
* plus tard (parentId, defaultNodeId, refs faibles String) puis re-save.</li>
|
||||
* </ol>
|
||||
* Les references vers un id absent des maps (ex. relatedPageId hors export) sont
|
||||
* CONSERVEES telles quelles (choix : ne pas perdre d'info, ne jamais planter).
|
||||
*/
|
||||
@Service
|
||||
public class ImportService {
|
||||
|
||||
// (Dé)sérialise les prérequis dans le format "kind" du converter JPA (Prerequisite
|
||||
// est scellé, non sérialisable en polymorphe par l'ObjectMapper standard).
|
||||
private static final PrerequisiteListJsonConverter PREREQ_CONVERTER = new PrerequisiteListJsonConverter();
|
||||
|
||||
private final GameSystemJpaRepository gameSystemRepo;
|
||||
private final LoreJpaRepository loreRepo;
|
||||
private final LoreNodeJpaRepository loreNodeRepo;
|
||||
private final TemplateJpaRepository templateRepo;
|
||||
private final PageJpaRepository pageRepo;
|
||||
private final CampaignJpaRepository campaignRepo;
|
||||
private final ArcJpaRepository arcRepo;
|
||||
private final ChapterJpaRepository chapterRepo;
|
||||
private final SceneJpaRepository sceneRepo;
|
||||
private final CharacterJpaRepository characterRepo;
|
||||
private final NpcJpaRepository npcRepo;
|
||||
private final EnemyJpaRepository enemyRepo;
|
||||
private final ItemCatalogJpaRepository itemCatalogRepo;
|
||||
private final RandomTableJpaRepository randomTableRepo;
|
||||
private final ImageJpaRepository imageRepo;
|
||||
private final ImageStorage imageStorage;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ImportService(GameSystemJpaRepository gameSystemRepo,
|
||||
LoreJpaRepository loreRepo,
|
||||
LoreNodeJpaRepository loreNodeRepo,
|
||||
TemplateJpaRepository templateRepo,
|
||||
PageJpaRepository pageRepo,
|
||||
CampaignJpaRepository campaignRepo,
|
||||
ArcJpaRepository arcRepo,
|
||||
ChapterJpaRepository chapterRepo,
|
||||
SceneJpaRepository sceneRepo,
|
||||
CharacterJpaRepository characterRepo,
|
||||
NpcJpaRepository npcRepo,
|
||||
EnemyJpaRepository enemyRepo,
|
||||
ItemCatalogJpaRepository itemCatalogRepo,
|
||||
RandomTableJpaRepository randomTableRepo,
|
||||
ImageJpaRepository imageRepo,
|
||||
ImageStorage imageStorage,
|
||||
ObjectMapper objectMapper) {
|
||||
this.gameSystemRepo = gameSystemRepo;
|
||||
this.loreRepo = loreRepo;
|
||||
this.loreNodeRepo = loreNodeRepo;
|
||||
this.templateRepo = templateRepo;
|
||||
this.pageRepo = pageRepo;
|
||||
this.campaignRepo = campaignRepo;
|
||||
this.arcRepo = arcRepo;
|
||||
this.chapterRepo = chapterRepo;
|
||||
this.sceneRepo = sceneRepo;
|
||||
this.characterRepo = characterRepo;
|
||||
this.npcRepo = npcRepo;
|
||||
this.enemyRepo = enemyRepo;
|
||||
this.itemCatalogRepo = itemCatalogRepo;
|
||||
this.randomTableRepo = randomTableRepo;
|
||||
this.imageRepo = imageRepo;
|
||||
this.imageStorage = imageStorage;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImportResult importZip(InputStream zipStream) {
|
||||
// 1. Parse du zip.
|
||||
ContentExport export = null;
|
||||
Map<String, byte[]> imageBinaries = new LinkedHashMap<>(); // storageKey -> binaire
|
||||
try (ZipInputStream zip = new ZipInputStream(zipStream)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
String name = entry.getName();
|
||||
if ("data.json".equals(name)) {
|
||||
export = objectMapper.readValue(readAll(zip), ContentExport.class);
|
||||
} else if (name.startsWith("images/") && !entry.isDirectory()) {
|
||||
// La cle de stockage est le chemin sans le prefixe "images/" du zip,
|
||||
// c'est-a-dire EXACTEMENT le storageKey d'origine ("images/UUID.ext").
|
||||
String storageKey = name.substring("images/".length());
|
||||
imageBinaries.put(storageKey, readAll(zip));
|
||||
}
|
||||
// manifest.json : ignore a l'import (info seulement).
|
||||
zip.closeEntry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Echec de lecture du zip d'import", e);
|
||||
}
|
||||
if (export == null) {
|
||||
throw new IllegalArgumentException("Archive invalide : data.json introuvable");
|
||||
}
|
||||
|
||||
ImportResult.Builder result = new ImportResult.Builder();
|
||||
|
||||
// 2. Reecriture des images (cle preservee).
|
||||
importImages(export, imageBinaries, result);
|
||||
|
||||
// 3. Insertion top-down + maps de remapping.
|
||||
Map<Long, Long> gameSystemMap = new HashMap<>();
|
||||
Map<Long, Long> loreMap = new HashMap<>();
|
||||
Map<Long, Long> loreNodeMap = new HashMap<>();
|
||||
Map<Long, Long> templateMap = new HashMap<>();
|
||||
Map<Long, Long> pageMap = new HashMap<>();
|
||||
Map<Long, Long> campaignMap = new HashMap<>();
|
||||
Map<Long, Long> arcMap = new HashMap<>();
|
||||
Map<Long, Long> chapterMap = new HashMap<>();
|
||||
Map<Long, Long> npcMap = new HashMap<>();
|
||||
Map<Long, Long> enemyMap = new HashMap<>();
|
||||
Map<Long, Long> characterMap = new HashMap<>();
|
||||
Map<Long, Long> sceneMap = new HashMap<>();
|
||||
|
||||
// -- GameSystem
|
||||
for (ContentExport.GameSystemDto d : nullSafe(export.gameSystems())) {
|
||||
GameSystemJpaEntity e = new GameSystemJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setRulesMarkdown(d.rulesMarkdown());
|
||||
e.setCharacterTemplate(d.characterTemplate());
|
||||
e.setNpcTemplate(d.npcTemplate());
|
||||
e.setEnemyTemplate(d.enemyTemplate());
|
||||
e.setAuthor(d.author());
|
||||
e.setPublic(d.isPublic());
|
||||
gameSystemMap.put(d.id(), gameSystemRepo.save(e).getId());
|
||||
}
|
||||
result.count("gameSystems", gameSystemMap.size());
|
||||
|
||||
// -- Lore
|
||||
for (ContentExport.LoreDto d : nullSafe(export.lores())) {
|
||||
LoreJpaEntity e = new LoreJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setNodeCount(d.nodeCount());
|
||||
e.setPageCount(d.pageCount());
|
||||
loreMap.put(d.id(), loreRepo.save(e).getId());
|
||||
}
|
||||
result.count("lores", loreMap.size());
|
||||
|
||||
// -- LoreNode (parentId remappe en 2e passe)
|
||||
List<LoreNodeJpaEntity> loreNodesToFix = new ArrayList<>();
|
||||
for (ContentExport.LoreNodeDto d : nullSafe(export.loreNodes())) {
|
||||
LoreNodeJpaEntity e = new LoreNodeJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setIcon(d.icon());
|
||||
e.setParentId(d.parentId()); // remappe plus bas
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
LoreNodeJpaEntity saved = loreNodeRepo.save(e);
|
||||
loreNodeMap.put(d.id(), saved.getId());
|
||||
if (d.parentId() != null) loreNodesToFix.add(saved);
|
||||
}
|
||||
result.count("loreNodes", loreNodeMap.size());
|
||||
|
||||
// -- Template (defaultNodeId remappe en 2e passe)
|
||||
List<ContentExport.TemplateDto> templatesWithDefaultNode = new ArrayList<>();
|
||||
for (ContentExport.TemplateDto d : nullSafe(export.templates())) {
|
||||
TemplateJpaEntity e = new TemplateJpaEntity();
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setDefaultNodeId(d.defaultNodeId()); // remappe plus bas
|
||||
e.setFields(d.fields());
|
||||
templateMap.put(d.id(), templateRepo.save(e).getId());
|
||||
if (d.defaultNodeId() != null) templatesWithDefaultNode.add(d);
|
||||
}
|
||||
result.count("templates", templateMap.size());
|
||||
|
||||
// -- Page (relatedPageIds remappe en 2e passe)
|
||||
for (ContentExport.PageDto d : nullSafe(export.pages())) {
|
||||
PageJpaEntity e = new PageJpaEntity();
|
||||
e.setLoreId(remapRequired(loreMap, d.loreId()));
|
||||
e.setNodeId(remapRequired(loreNodeMap, d.nodeId()));
|
||||
e.setTemplateId(remapNullable(templateMap, d.templateId()));
|
||||
e.setTitle(d.title());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setTableValues(d.tableValues());
|
||||
e.setNotes(d.notes());
|
||||
e.setTags(d.tags());
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
pageMap.put(d.id(), pageRepo.save(e).getId());
|
||||
}
|
||||
result.count("pages", pageMap.size());
|
||||
|
||||
// -- Campaign (loreId/gameSystemId String remappes en 2e passe)
|
||||
for (ContentExport.CampaignDto d : nullSafe(export.campaigns())) {
|
||||
CampaignJpaEntity e = new CampaignJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setArcsCount(d.arcsCount());
|
||||
e.setLoreId(d.loreId()); // remappe plus bas
|
||||
e.setGameSystemId(d.gameSystemId()); // remappe plus bas
|
||||
campaignMap.put(d.id(), campaignRepo.save(e).getId());
|
||||
}
|
||||
result.count("campaigns", campaignMap.size());
|
||||
|
||||
// -- Arc (relatedPageIds remappe en 2e passe)
|
||||
for (ContentExport.ArcDto d : nullSafe(export.arcs())) {
|
||||
ArcJpaEntity e = new ArcJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
e.setType(parseArcType(d.type()));
|
||||
e.setIcon(d.icon());
|
||||
e.setThemes(d.themes());
|
||||
e.setStakes(d.stakes());
|
||||
e.setGmNotes(d.gmNotes());
|
||||
e.setRewards(d.rewards());
|
||||
e.setResolution(d.resolution());
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
e.setIllustrationImageIds(d.illustrationImageIds());
|
||||
e.setMapImageIds(d.mapImageIds());
|
||||
arcMap.put(d.id(), arcRepo.save(e).getId());
|
||||
}
|
||||
result.count("arcs", arcMap.size());
|
||||
|
||||
// -- ItemCatalog (+ items en cascade)
|
||||
int catalogCount = 0, itemCount = 0;
|
||||
for (ContentExport.ItemCatalogDto d : nullSafe(export.itemCatalogs())) {
|
||||
ItemCatalogJpaEntity e = new ItemCatalogJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setIcon(d.icon());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
List<CatalogItemJpaEntity> items = new ArrayList<>();
|
||||
for (ContentExport.CatalogItemDto i : nullSafe(d.items())) {
|
||||
CatalogItemJpaEntity item = new CatalogItemJpaEntity();
|
||||
item.setName(i.name());
|
||||
item.setPrice(i.price());
|
||||
item.setCategory(i.category());
|
||||
item.setDescription(i.description());
|
||||
item.setPosition(i.position());
|
||||
item.setCatalog(e); // lien parent requis pour la cascade
|
||||
items.add(item);
|
||||
itemCount++;
|
||||
}
|
||||
e.setItems(items);
|
||||
itemCatalogRepo.save(e);
|
||||
catalogCount++;
|
||||
}
|
||||
result.count("itemCatalogs", catalogCount);
|
||||
result.count("catalogItems", itemCount);
|
||||
|
||||
// -- RandomTable (+ entries en cascade)
|
||||
int tableCount = 0, entryCount = 0;
|
||||
for (ContentExport.RandomTableDto d : nullSafe(export.randomTables())) {
|
||||
RandomTableJpaEntity e = new RandomTableJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setDiceFormula(d.diceFormula());
|
||||
e.setIcon(d.icon());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
List<RandomTableEntryJpaEntity> entries = new ArrayList<>();
|
||||
for (ContentExport.RandomTableEntryDto en : nullSafe(d.entries())) {
|
||||
RandomTableEntryJpaEntity entryE = new RandomTableEntryJpaEntity();
|
||||
entryE.setMinRoll(en.minRoll());
|
||||
entryE.setMaxRoll(en.maxRoll());
|
||||
entryE.setLabel(en.label());
|
||||
entryE.setDetail(en.detail());
|
||||
entryE.setPosition(en.position());
|
||||
entryE.setRandomTable(e);
|
||||
entries.add(entryE);
|
||||
entryCount++;
|
||||
}
|
||||
e.setEntries(entries);
|
||||
randomTableRepo.save(e);
|
||||
tableCount++;
|
||||
}
|
||||
result.count("randomTables", tableCount);
|
||||
result.count("randomTableEntries", entryCount);
|
||||
|
||||
// -- Chapter (prerequisites + relatedPageIds remappes en 2e passe)
|
||||
for (ContentExport.ChapterDto d : nullSafe(export.chapters())) {
|
||||
ChapterJpaEntity e = new ChapterJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setArcId(remapRequired(arcMap, d.arcId()));
|
||||
e.setOrder(d.order());
|
||||
e.setPrerequisites(PREREQ_CONVERTER.convertToEntityAttribute(d.prerequisitesJson())); // remappe plus bas
|
||||
e.setIcon(d.icon());
|
||||
e.setGmNotes(d.gmNotes());
|
||||
e.setPlayerObjectives(d.playerObjectives());
|
||||
e.setNarrativeStakes(d.narrativeStakes());
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
e.setIllustrationImageIds(d.illustrationImageIds());
|
||||
e.setMapImageIds(d.mapImageIds());
|
||||
chapterMap.put(d.id(), chapterRepo.save(e).getId());
|
||||
}
|
||||
result.count("chapters", chapterMap.size());
|
||||
|
||||
// -- Npc (relatedPageIds remappe en 2e passe)
|
||||
for (ContentExport.NpcDto d : nullSafe(export.npcs())) {
|
||||
NpcJpaEntity e = new NpcJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setPortraitImageId(d.portraitImageId());
|
||||
e.setHeaderImageId(d.headerImageId());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
e.setFolder(d.folder());
|
||||
e.setOrder(d.order());
|
||||
npcMap.put(d.id(), npcRepo.save(e).getId());
|
||||
}
|
||||
result.count("npcs", npcMap.size());
|
||||
|
||||
// -- Enemy
|
||||
for (ContentExport.EnemyDto d : nullSafe(export.enemies())) {
|
||||
EnemyJpaEntity e = new EnemyJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setLevel(d.level());
|
||||
e.setFolder(d.folder());
|
||||
e.setPortraitImageId(d.portraitImageId());
|
||||
e.setHeaderImageId(d.headerImageId());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapRequired(campaignMap, d.campaignId()));
|
||||
e.setOrder(d.order());
|
||||
enemyMap.put(d.id(), enemyRepo.save(e).getId());
|
||||
}
|
||||
result.count("enemies", enemyMap.size());
|
||||
|
||||
// -- Character (playthroughId mis a null : hors perimetre)
|
||||
for (ContentExport.CharacterDto d : nullSafe(export.characters())) {
|
||||
CharacterJpaEntity e = new CharacterJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setPortraitImageId(d.portraitImageId());
|
||||
e.setHeaderImageId(d.headerImageId());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setCampaignId(remapNullable(campaignMap, d.campaignId()));
|
||||
e.setPlaythroughId(null); // Playthrough hors perimetre d'export
|
||||
e.setOrder(d.order());
|
||||
characterMap.put(d.id(), characterRepo.save(e).getId());
|
||||
}
|
||||
result.count("characters", characterMap.size());
|
||||
|
||||
// -- Scene (enemyIds + relatedPageIds + branches remappes en 2e passe)
|
||||
for (ContentExport.SceneDto d : nullSafe(export.scenes())) {
|
||||
SceneJpaEntity e = new SceneJpaEntity();
|
||||
e.setName(d.name());
|
||||
e.setDescription(d.description());
|
||||
e.setChapterId(remapRequired(chapterMap, d.chapterId()));
|
||||
e.setOrder(d.order());
|
||||
e.setIcon(d.icon());
|
||||
e.setLocation(d.location());
|
||||
e.setTiming(d.timing());
|
||||
e.setAtmosphere(d.atmosphere());
|
||||
e.setPlayerNarration(d.playerNarration());
|
||||
e.setGmSecretNotes(d.gmSecretNotes());
|
||||
e.setChoicesConsequences(d.choicesConsequences());
|
||||
e.setCombatDifficulty(d.combatDifficulty());
|
||||
e.setEnemies(d.enemies());
|
||||
e.setEnemyIds(d.enemyIds()); // remappe plus bas
|
||||
e.setRelatedPageIds(d.relatedPageIds()); // remappe plus bas
|
||||
e.setIllustrationImageIds(d.illustrationImageIds());
|
||||
e.setMapImageIds(d.mapImageIds());
|
||||
e.setBranches(d.branches()); // remappe plus bas
|
||||
e.setRooms(d.rooms()); // Rooms: UUID, non remappes
|
||||
sceneMap.put(d.id(), sceneRepo.save(e).getId());
|
||||
}
|
||||
result.count("scenes", sceneMap.size());
|
||||
|
||||
// 4. 2e PASSE de remapping.
|
||||
|
||||
// LoreNode.parentId
|
||||
for (LoreNodeJpaEntity e : loreNodesToFix) {
|
||||
Long newParent = loreNodeMap.get(e.getParentId());
|
||||
if (newParent != null) {
|
||||
e.setParentId(newParent);
|
||||
loreNodeRepo.save(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Template.defaultNodeId
|
||||
for (ContentExport.TemplateDto d : templatesWithDefaultNode) {
|
||||
Long newTemplateId = templateMap.get(d.id());
|
||||
Long newNode = loreNodeMap.get(d.defaultNodeId());
|
||||
if (newTemplateId != null && newNode != null) {
|
||||
templateRepo.findById(newTemplateId).ifPresent(t -> {
|
||||
t.setDefaultNodeId(newNode);
|
||||
templateRepo.save(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign.loreId & gameSystemId (refs faibles String -> remap via maps Long).
|
||||
for (Long newCampaignId : campaignMap.values()) {
|
||||
campaignRepo.findById(newCampaignId).ifPresent(c -> {
|
||||
String newLore = remapStringId(loreMap, c.getLoreId());
|
||||
String newGs = remapStringId(gameSystemMap, c.getGameSystemId());
|
||||
c.setLoreId(newLore);
|
||||
c.setGameSystemId(newGs);
|
||||
campaignRepo.save(c);
|
||||
});
|
||||
}
|
||||
|
||||
// Page.relatedPageIds
|
||||
for (Long newPageId : pageMap.values()) {
|
||||
pageRepo.findById(newPageId).ifPresent(p -> {
|
||||
p.setRelatedPageIds(remapStringList(pageMap, p.getRelatedPageIds()));
|
||||
pageRepo.save(p);
|
||||
});
|
||||
}
|
||||
|
||||
// Arc.relatedPageIds
|
||||
for (Long newArcId : arcMap.values()) {
|
||||
arcRepo.findById(newArcId).ifPresent(a -> {
|
||||
a.setRelatedPageIds(remapStringList(pageMap, a.getRelatedPageIds()));
|
||||
arcRepo.save(a);
|
||||
});
|
||||
}
|
||||
|
||||
// Chapter.relatedPageIds + prerequisites(QuestCompleted -> map Chapter)
|
||||
for (Long newChapterId : chapterMap.values()) {
|
||||
chapterRepo.findById(newChapterId).ifPresent(c -> {
|
||||
c.setRelatedPageIds(remapStringList(pageMap, c.getRelatedPageIds()));
|
||||
c.setPrerequisites(remapPrerequisites(chapterMap, c.getPrerequisites()));
|
||||
chapterRepo.save(c);
|
||||
});
|
||||
}
|
||||
|
||||
// Npc.relatedPageIds
|
||||
for (Long newNpcId : npcMap.values()) {
|
||||
npcRepo.findById(newNpcId).ifPresent(n -> {
|
||||
n.setRelatedPageIds(remapStringList(pageMap, n.getRelatedPageIds()));
|
||||
npcRepo.save(n);
|
||||
});
|
||||
}
|
||||
|
||||
// Scene.relatedPageIds + enemyIds(map Enemy) + branches.targetSceneId(map Scene)
|
||||
for (Long newSceneId : sceneMap.values()) {
|
||||
sceneRepo.findById(newSceneId).ifPresent(s -> {
|
||||
s.setRelatedPageIds(remapStringList(pageMap, s.getRelatedPageIds()));
|
||||
s.setEnemyIds(remapStringList(enemyMap, s.getEnemyIds()));
|
||||
s.setBranches(remapBranches(sceneMap, s.getBranches()));
|
||||
sceneRepo.save(s);
|
||||
});
|
||||
}
|
||||
|
||||
return result.build();
|
||||
}
|
||||
|
||||
// ----- Images -----
|
||||
|
||||
private void importImages(ContentExport export,
|
||||
Map<String, byte[]> imageBinaries,
|
||||
ImportResult.Builder result) {
|
||||
// Index des metadonnees d'image par cle (depuis le data.json).
|
||||
Map<String, ContentExport.ImageDto> metaByKey = new HashMap<>();
|
||||
for (ContentExport.ImageDto img : nullSafe(export.images())) {
|
||||
if (img.storageKey() != null) metaByKey.put(img.storageKey(), img);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, byte[]> bin : imageBinaries.entrySet()) {
|
||||
String storageKey = bin.getKey();
|
||||
byte[] data = bin.getValue();
|
||||
if (imageRepo.findByStorageKey(storageKey).isPresent()) {
|
||||
// Image deja presente : on reutilise, pas de reupload (eviter doublon).
|
||||
result.imageReused();
|
||||
continue;
|
||||
}
|
||||
ContentExport.ImageDto meta = metaByKey.get(storageKey);
|
||||
String contentType = meta != null && meta.contentType() != null
|
||||
? meta.contentType() : guessContentType(storageKey);
|
||||
long size = meta != null ? meta.sizeBytes() : data.length;
|
||||
|
||||
imageStorage.store(storageKey, contentType, new ByteArrayInputStream(data), data.length);
|
||||
|
||||
ImageJpaEntity e = new ImageJpaEntity();
|
||||
e.setFilename(meta != null && meta.filename() != null
|
||||
? meta.filename() : fileNameOf(storageKey));
|
||||
e.setContentType(contentType);
|
||||
e.setSizeBytes(size);
|
||||
e.setStorageKey(storageKey);
|
||||
imageRepo.save(e);
|
||||
result.imageUploaded();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Helpers de remapping -----
|
||||
|
||||
/** Remap obligatoire d'une FK Long : si absente de la map, on garde l'ancienne valeur. */
|
||||
private Long remapRequired(Map<Long, Long> map, Long oldId) {
|
||||
if (oldId == null) return null;
|
||||
return map.getOrDefault(oldId, oldId);
|
||||
}
|
||||
|
||||
/** Remap d'une FK Long nullable : null reste null. */
|
||||
private Long remapNullable(Map<Long, Long> map, Long oldId) {
|
||||
if (oldId == null) return null;
|
||||
return map.getOrDefault(oldId, oldId);
|
||||
}
|
||||
|
||||
/** Remap d'un id stocke en String ("oldLong" -> "newLong") via une map Long. */
|
||||
private String remapStringId(Map<Long, Long> map, String oldId) {
|
||||
if (oldId == null || oldId.isBlank()) return oldId;
|
||||
try {
|
||||
Long newId = map.get(Long.parseLong(oldId.trim()));
|
||||
return newId != null ? String.valueOf(newId) : oldId;
|
||||
} catch (NumberFormatException ex) {
|
||||
return oldId; // pas un Long : on laisse tel quel
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> remapStringList(Map<Long, Long> map, List<String> ids) {
|
||||
if (ids == null) return null;
|
||||
List<String> out = new ArrayList<>(ids.size());
|
||||
for (String id : ids) out.add(remapStringId(map, id));
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Prerequisite> remapPrerequisites(Map<Long, Long> chapterMap, List<Prerequisite> prereqs) {
|
||||
if (prereqs == null) return null;
|
||||
List<Prerequisite> out = new ArrayList<>(prereqs.size());
|
||||
for (Prerequisite p : prereqs) {
|
||||
if (p instanceof Prerequisite.QuestCompleted qc) {
|
||||
out.add(new Prerequisite.QuestCompleted(remapStringId(chapterMap, qc.questId())));
|
||||
} else {
|
||||
out.add(p); // FlagSet / SessionReached : inchanges
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<SceneBranch> remapBranches(Map<Long, Long> sceneMap, List<SceneBranch> branches) {
|
||||
if (branches == null) return null;
|
||||
List<SceneBranch> out = new ArrayList<>(branches.size());
|
||||
for (SceneBranch b : branches) {
|
||||
out.add(new SceneBranch(b.label(), remapStringId(sceneMap, b.targetSceneId()), b.condition()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private com.loremind.domain.campaigncontext.ArcType parseArcType(String type) {
|
||||
if (type == null) return com.loremind.domain.campaigncontext.ArcType.LINEAR;
|
||||
try {
|
||||
return com.loremind.domain.campaigncontext.ArcType.valueOf(type);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return com.loremind.domain.campaigncontext.ArcType.LINEAR;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Helpers divers -----
|
||||
|
||||
private static <T> List<T> nullSafe(List<T> list) {
|
||||
return list != null ? list : List.of();
|
||||
}
|
||||
|
||||
private static byte[] readAll(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
in.transferTo(buffer);
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
private static String fileNameOf(String storageKey) {
|
||||
int slash = storageKey.lastIndexOf('/');
|
||||
return slash >= 0 ? storageKey.substring(slash + 1) : storageKey;
|
||||
}
|
||||
|
||||
private static String guessContentType(String storageKey) {
|
||||
String lower = storageKey.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.loremind.infrastructure.transfer.dto;
|
||||
|
||||
import com.loremind.domain.campaigncontext.Room;
|
||||
import com.loremind.domain.campaigncontext.SceneBranch;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Format d'export LOGIQUE (JSON) du "contenu" de LoreMind.
|
||||
* <p>
|
||||
* Records plats, miroirs des champs des entites JPA — volontairement DECOUPLES
|
||||
* des entites pour la stabilite inter-versions (le schema JPA peut bouger sans
|
||||
* casser un .zip exporte). Chaque record porte son {@code id} d'origine (Long)
|
||||
* afin que l'import puisse construire les maps de remapping oldId -> newId.
|
||||
* <p>
|
||||
* Les sous-objets riches (TemplateField, Prerequisite, SceneBranch, Room)
|
||||
* reutilisent les types domaine existants : Jackson sait les (de)serialiser
|
||||
* (records ou Lombok), et on evite de dupliquer la structure.
|
||||
*/
|
||||
public record ContentExport(
|
||||
Manifest manifest,
|
||||
List<GameSystemDto> gameSystems,
|
||||
List<LoreDto> lores,
|
||||
List<LoreNodeDto> loreNodes,
|
||||
List<TemplateDto> templates,
|
||||
List<PageDto> pages,
|
||||
List<CampaignDto> campaigns,
|
||||
List<ArcDto> arcs,
|
||||
List<ChapterDto> chapters,
|
||||
List<SceneDto> scenes,
|
||||
List<CharacterDto> characters,
|
||||
List<NpcDto> npcs,
|
||||
List<EnemyDto> enemies,
|
||||
List<ItemCatalogDto> itemCatalogs,
|
||||
List<RandomTableDto> randomTables,
|
||||
List<ImageDto> images
|
||||
) {
|
||||
|
||||
/**
|
||||
* Metadonnees de l'export. {@code exportedAt} est passe en parametre depuis
|
||||
* la couche requete (PAS Instant.now() ici, pour rester deterministe et
|
||||
* testable).
|
||||
*/
|
||||
public record Manifest(
|
||||
int formatVersion,
|
||||
String appVersion,
|
||||
String exportedAt
|
||||
) {}
|
||||
|
||||
public record GameSystemDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
String rulesMarkdown,
|
||||
List<TemplateField> characterTemplate,
|
||||
List<TemplateField> npcTemplate,
|
||||
List<TemplateField> enemyTemplate,
|
||||
String author,
|
||||
boolean isPublic
|
||||
) {}
|
||||
|
||||
public record LoreDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
int nodeCount,
|
||||
int pageCount
|
||||
) {}
|
||||
|
||||
public record LoreNodeDto(
|
||||
Long id,
|
||||
String name,
|
||||
String icon,
|
||||
Long parentId,
|
||||
Long loreId
|
||||
) {}
|
||||
|
||||
public record TemplateDto(
|
||||
Long id,
|
||||
Long loreId,
|
||||
String name,
|
||||
String description,
|
||||
Long defaultNodeId,
|
||||
List<TemplateField> fields
|
||||
) {}
|
||||
|
||||
public record PageDto(
|
||||
Long id,
|
||||
Long loreId,
|
||||
Long nodeId,
|
||||
Long templateId,
|
||||
String title,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Map<String, List<Map<String, String>>> tableValues,
|
||||
String notes,
|
||||
List<String> tags,
|
||||
List<String> relatedPageIds
|
||||
) {}
|
||||
|
||||
public record CampaignDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
int arcsCount,
|
||||
String loreId,
|
||||
String gameSystemId
|
||||
) {}
|
||||
|
||||
public record ArcDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
Long campaignId,
|
||||
int order,
|
||||
String type,
|
||||
String icon,
|
||||
String themes,
|
||||
String stakes,
|
||||
String gmNotes,
|
||||
String rewards,
|
||||
String resolution,
|
||||
List<String> relatedPageIds,
|
||||
List<String> illustrationImageIds,
|
||||
List<String> mapImageIds
|
||||
) {}
|
||||
|
||||
public record ChapterDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
Long arcId,
|
||||
int order,
|
||||
// Prerequisites en JSON brut (format "kind" du converter JPA) : Prerequisite
|
||||
// est un type scellé SANS annotations Jackson, donc impossible à (dé)sérialiser
|
||||
// en polymorphe via l'ObjectMapper standard. On réutilise
|
||||
// PrerequisiteListJsonConverter (format on-disk stable, identique à la base).
|
||||
String prerequisitesJson,
|
||||
String icon,
|
||||
String gmNotes,
|
||||
String playerObjectives,
|
||||
String narrativeStakes,
|
||||
List<String> relatedPageIds,
|
||||
List<String> illustrationImageIds,
|
||||
List<String> mapImageIds
|
||||
) {}
|
||||
|
||||
public record SceneDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
Long chapterId,
|
||||
int order,
|
||||
String icon,
|
||||
String location,
|
||||
String timing,
|
||||
String atmosphere,
|
||||
String playerNarration,
|
||||
String gmSecretNotes,
|
||||
String choicesConsequences,
|
||||
String combatDifficulty,
|
||||
String enemies,
|
||||
List<String> enemyIds,
|
||||
List<String> relatedPageIds,
|
||||
List<String> illustrationImageIds,
|
||||
List<String> mapImageIds,
|
||||
List<SceneBranch> branches,
|
||||
List<Room> rooms
|
||||
) {}
|
||||
|
||||
public record CharacterDto(
|
||||
Long id,
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Long campaignId,
|
||||
Long playthroughId,
|
||||
int order
|
||||
) {}
|
||||
|
||||
public record NpcDto(
|
||||
Long id,
|
||||
String name,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Long campaignId,
|
||||
List<String> relatedPageIds,
|
||||
String folder,
|
||||
int order
|
||||
) {}
|
||||
|
||||
public record EnemyDto(
|
||||
Long id,
|
||||
String name,
|
||||
String level,
|
||||
String folder,
|
||||
String portraitImageId,
|
||||
String headerImageId,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Long campaignId,
|
||||
int order
|
||||
) {}
|
||||
|
||||
public record ItemCatalogDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
String icon,
|
||||
Long campaignId,
|
||||
int order,
|
||||
List<CatalogItemDto> items
|
||||
) {}
|
||||
|
||||
public record CatalogItemDto(
|
||||
Long id,
|
||||
String name,
|
||||
String price,
|
||||
String category,
|
||||
String description,
|
||||
int position
|
||||
) {}
|
||||
|
||||
public record RandomTableDto(
|
||||
Long id,
|
||||
String name,
|
||||
String description,
|
||||
String diceFormula,
|
||||
String icon,
|
||||
Long campaignId,
|
||||
int order,
|
||||
List<RandomTableEntryDto> entries
|
||||
) {}
|
||||
|
||||
public record RandomTableEntryDto(
|
||||
Long id,
|
||||
int minRoll,
|
||||
int maxRoll,
|
||||
String label,
|
||||
String detail,
|
||||
int position
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Metadonnees d'une image. Le binaire voyage a part dans le zip sous
|
||||
* {@code images/<storageKey>}. La cle est PRESERVEE telle quelle a l'import.
|
||||
*/
|
||||
public record ImageDto(
|
||||
Long id,
|
||||
String filename,
|
||||
String contentType,
|
||||
long sizeBytes,
|
||||
String storageKey
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.loremind.infrastructure.web.controller;
|
||||
|
||||
import com.loremind.infrastructure.transfer.ExportService;
|
||||
import com.loremind.infrastructure.transfer.ImportResult;
|
||||
import com.loremind.infrastructure.transfer.ImportService;
|
||||
import com.loremind.infrastructure.transfer.dto.ContentExport;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Endpoints d'EXPORT / IMPORT du "contenu" (admin).
|
||||
* <p>
|
||||
* - {@code GET /api/admin/data/export} : telecharge un .zip portable.<br>
|
||||
* - {@code POST /api/admin/data/import} : importe un .zip en mode FUSION.
|
||||
* <p>
|
||||
* Garde le mode demo coherent avec {@code SettingsController} : ces operations
|
||||
* sont desactivees en demo (donnees partagees, pas d'ecriture massive).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/data")
|
||||
public class DataTransferController {
|
||||
|
||||
private final ExportService exportService;
|
||||
private final ImportService importService;
|
||||
private final boolean demoMode;
|
||||
|
||||
public DataTransferController(ExportService exportService,
|
||||
ImportService importService,
|
||||
@Value("${app.demo-mode:false}") boolean demoMode) {
|
||||
this.exportService = exportService;
|
||||
this.importService = importService;
|
||||
this.demoMode = demoMode;
|
||||
}
|
||||
|
||||
@GetMapping(value = "/export", produces = "application/zip")
|
||||
public ResponseEntity<StreamingResponseBody> export() {
|
||||
guardDemoMode();
|
||||
// Stamp de l'horodatage cote requete (PAS dans un init statique).
|
||||
String exportedAt = Instant.now().toString();
|
||||
ContentExport content = exportService.buildExport(exportedAt);
|
||||
|
||||
StreamingResponseBody body = out -> exportService.writeZip(content, out);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"loremind-export.zip\"")
|
||||
.contentType(MediaType.parseMediaType("application/zip"))
|
||||
.body(body);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ImportResult> importData(@RequestParam("file") MultipartFile file) {
|
||||
guardDemoMode();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Fichier d'import vide");
|
||||
}
|
||||
try {
|
||||
ImportResult result = importService.importZip(file.getInputStream());
|
||||
return ResponseEntity.ok(result);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Echec de lecture du fichier d'import", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void guardDemoMode() {
|
||||
if (demoMode) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Data transfer disabled in demo mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/common": "^21.2.16",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -66,6 +66,13 @@ export interface OllamaModelInfo {
|
||||
context_length: number;
|
||||
}
|
||||
|
||||
/** Resultat d'un import de donnees (compteurs par type + images). */
|
||||
export interface ImportResult {
|
||||
created: Record<string, number>;
|
||||
imagesUploaded: number;
|
||||
imagesReused: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SettingsService {
|
||||
private readonly apiUrl = '/api/settings';
|
||||
@@ -85,6 +92,21 @@ export class SettingsService {
|
||||
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
|
||||
}
|
||||
|
||||
// --- Export / Import des donnees (sauvegarde & transfert d'instance) -------
|
||||
|
||||
/** Telecharge l'export complet du contenu (zip : data.json + images). */
|
||||
exportData(): Observable<Blob> {
|
||||
return this.http.get('/api/admin/data/export',
|
||||
{ withCredentials: true, responseType: 'blob' });
|
||||
}
|
||||
|
||||
/** Importe un zip d'export en mode FUSION (ajoute, ne remplace pas). */
|
||||
importData(file: File): Observable<ImportResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return this.http.post<ImportResult>('/api/admin/data/import', form, this.authOptions);
|
||||
}
|
||||
|
||||
listOllamaModels(): Observable<{ models: string[] }> {
|
||||
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||
<input
|
||||
id="onemin-key"
|
||||
type="password"
|
||||
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||
[(ngModel)]="oneminApiKeyInput"
|
||||
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
|
||||
@if (settings.onemin_api_key_set) {
|
||||
@@ -149,7 +149,7 @@
|
||||
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||
<input
|
||||
id="openrouter-key"
|
||||
type="password"
|
||||
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||
[(ngModel)]="openrouterApiKeyInput"
|
||||
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
|
||||
@if (settings.openrouter_api_key_set) {
|
||||
@@ -183,7 +183,7 @@
|
||||
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||
<input
|
||||
id="mistral-key"
|
||||
type="password"
|
||||
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||
[(ngModel)]="mistralApiKeyInput"
|
||||
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||
@if (settings.mistral_api_key_set) {
|
||||
@@ -213,7 +213,7 @@
|
||||
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||
<input
|
||||
id="gemini-key"
|
||||
type="password"
|
||||
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||
[(ngModel)]="geminiApiKeyInput"
|
||||
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
|
||||
@if (settings.gemini_api_key_set) {
|
||||
@@ -285,7 +285,7 @@
|
||||
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</span>
|
||||
}
|
||||
</label>
|
||||
<input id="emb-mistral-key" type="password" [(ngModel)]="mistralApiKeyInput"
|
||||
<input id="emb-mistral-key" type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore [(ngModel)]="mistralApiKeyInput"
|
||||
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||
@if (settings.mistral_api_key_set) {
|
||||
<label class="checkbox">
|
||||
@@ -372,6 +372,33 @@
|
||||
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
|
||||
<app-settings-updates-section></app-settings-updates-section>
|
||||
|
||||
<!-- Sauvegarde / Restauration des donnees (export & import) -->
|
||||
<section class="card">
|
||||
<h2>{{ 'settings.data.title' | translate }}</h2>
|
||||
<p class="hint" [innerHTML]="'settings.data.hint' | translate"></p>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" (click)="exportData()" [disabled]="exporting || importing">
|
||||
<span>{{ (exporting ? 'settings.data.exporting' : 'settings.data.exportBtn') | translate }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" (click)="importInput.click()" [disabled]="exporting || importing">
|
||||
<span>{{ (importing ? 'settings.data.importing' : 'settings.data.importBtn') | translate }}</span>
|
||||
</button>
|
||||
<input #importInput type="file" accept=".zip,application/zip" hidden (change)="onImportFileSelected($event)">
|
||||
</div>
|
||||
@if (dataError) {
|
||||
<div class="alert alert-error">
|
||||
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
|
||||
<span>{{ dataError }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (dataMessage) {
|
||||
<div class="alert alert-success">
|
||||
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
|
||||
<span>{{ dataMessage }}</span>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (settings) {
|
||||
<div class="actions">
|
||||
<button class="btn-primary" (click)="save()" [disabled]="saving">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel } from '../services/settings.service';
|
||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, MistralModel, GeminiModel, ImportResult } from '../services/settings.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||
@@ -332,6 +332,60 @@ export class SettingsComponent implements OnInit {
|
||||
this.router.navigate(['/lore']);
|
||||
}
|
||||
|
||||
// --- Sauvegarde / Restauration des donnees --------------------------------
|
||||
|
||||
exporting = false;
|
||||
importing = false;
|
||||
dataMessage = '';
|
||||
dataError = '';
|
||||
|
||||
/** Telecharge l'export complet (zip) via un lien temporaire. */
|
||||
exportData(): void {
|
||||
this.exporting = true;
|
||||
this.dataMessage = '';
|
||||
this.dataError = '';
|
||||
this.settingsService.exportData().subscribe({
|
||||
next: (blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'loremind-export.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
this.exporting = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.dataError = this.extractError(err, this.translate.instant('settings.data.exportError'));
|
||||
this.exporting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Déclenché par le sélecteur de fichier : importe le zip choisi (fusion). */
|
||||
onImportFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = ''; // permet de re-sélectionner le même fichier
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm(this.translate.instant('settings.data.importConfirm'))) return;
|
||||
|
||||
this.importing = true;
|
||||
this.dataMessage = '';
|
||||
this.dataError = '';
|
||||
this.settingsService.importData(file).subscribe({
|
||||
next: (result: ImportResult) => {
|
||||
const total = Object.values(result.created).reduce((a, b) => a + b, 0);
|
||||
this.dataMessage = this.translate.instant('settings.data.importSuccess', { count: total });
|
||||
this.importing = false;
|
||||
},
|
||||
error: (err) => {
|
||||
this.dataError = this.extractError(err, this.translate.instant('settings.data.importError'));
|
||||
this.importing = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(err: any, fallback: string): string {
|
||||
if (err?.error?.detail) return String(err.error.detail);
|
||||
if (err?.message) return err.message;
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"data": {
|
||||
"title": "Data backup",
|
||||
"hint": "Export all your content (game systems, lores, campaigns + images) to a file, to back it up or move it to another instance. Importing <strong>adds</strong> the content without deleting what's already there.",
|
||||
"exportBtn": "Export data",
|
||||
"exporting": "Exporting…",
|
||||
"importBtn": "Import a file",
|
||||
"importing": "Importing…",
|
||||
"importConfirm": "Import this file? Its content will be added to your instance (nothing is deleted).",
|
||||
"importSuccess": "Import successful: {{count}} item(s) added.",
|
||||
"exportError": "Export failed.",
|
||||
"importError": "Import failed (invalid file?)."
|
||||
},
|
||||
"language": {
|
||||
"title": "Interface language",
|
||||
"hint": "Choose the application display language. The change is applied instantly and remembered on this device."
|
||||
|
||||
@@ -50,6 +50,18 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"data": {
|
||||
"title": "Sauvegarde des données",
|
||||
"hint": "Exportez tout votre contenu (systèmes de jeu, lores, campagnes + images) dans un fichier, pour le sauvegarder ou le transférer vers une autre instance. L'import <strong>ajoute</strong> le contenu sans supprimer l'existant.",
|
||||
"exportBtn": "Exporter les données",
|
||||
"exporting": "Export en cours…",
|
||||
"importBtn": "Importer un fichier",
|
||||
"importing": "Import en cours…",
|
||||
"importConfirm": "Importer ce fichier ? Le contenu sera ajouté à votre instance (rien n'est supprimé).",
|
||||
"importSuccess": "Import réussi : {{count}} élément(s) ajouté(s).",
|
||||
"exportError": "Échec de l'export.",
|
||||
"importError": "Échec de l'import (fichier invalide ?)."
|
||||
},
|
||||
"language": {
|
||||
"title": "Langue de l'interface",
|
||||
"hint": "Choix de la langue d'affichage de l'application. Le changement est immédiat et mémorisé sur cet appareil."
|
||||
|
||||
Reference in New Issue
Block a user