Compare commits
4 Commits
| 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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v*']
|
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:
|
permissions:
|
||||||
contents: write # requis pour creer la Release et y attacher le .msi
|
contents: write # requis pour creer la Release et y attacher le .msi
|
||||||
@@ -30,7 +38,12 @@ jobs:
|
|||||||
windows:
|
windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
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
|
- 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.
|
# Apporte jpackage (lanceur d'empaquetage natif) dans le PATH.
|
||||||
- name: Set up JDK 21
|
- name: Set up JDK 21
|
||||||
@@ -57,24 +70,37 @@ jobs:
|
|||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: choco install wixtoolset -y --no-progress
|
run: choco install wixtoolset -y --no-progress
|
||||||
|
|
||||||
# Version de l'installeur = version du tag (numerique, sans suffixe -beta).
|
# Version de l'installeur = version du tag (push) OU de l'input (manuel).
|
||||||
- name: Derive version from tag
|
# 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
|
id: ver
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
$full = "${{ github.ref_name }}" -replace '^v','' # ex: 0.15.0-beta.1
|
if ('${{ github.event_name }}' -eq 'workflow_dispatch') {
|
||||||
$num = ($full -split '-')[0] # ex: 0.15.0
|
$raw = '${{ inputs.version }}'
|
||||||
"version=$num" >> $env:GITHUB_OUTPUT
|
} 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
|
- name: Build Windows installer
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
|
run: .\installers\desktop\build-windows.ps1 -Version ${{ steps.ver.outputs.version }}
|
||||||
|
|
||||||
# STABLE uniquement : Release GitHub publique avec le .msi.
|
# 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)
|
- 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
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
|
tag_name: ${{ steps.ver.outputs.tag }}
|
||||||
files: core/target/dist-out/*.msi
|
files: core/target/dist-out/*.msi
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
@@ -82,7 +108,7 @@ jobs:
|
|||||||
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
|
# BETA uniquement : artefact PRIVE (pas de release publique). A recuperer
|
||||||
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
|
# via l'onglet Actions puis a joindre a un post Patreon gate par palier.
|
||||||
- name: Upload installer as private artifact (beta)
|
- name: Upload installer as private artifact (beta)
|
||||||
if: ${{ contains(github.ref_name, '-beta') }}
|
if: ${{ steps.ver.outputs.isbeta == 'true' }}
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: loremind-beta-${{ steps.ver.outputs.version }}-msi
|
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(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.15.0",
|
version="0.16.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.15.0</version>
|
<version>0.16.0</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind;
|
package com.loremind;
|
||||||
|
|
||||||
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
|
import com.loremind.infrastructure.desktop.DesktopSingleInstance;
|
||||||
|
import com.loremind.infrastructure.desktop.DesktopUserConfig;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
@@ -30,6 +31,12 @@ public class LoreMindApplication {
|
|||||||
// ce qui leverait HeadlessException — on le desactive ici. En mode
|
// ce qui leverait HeadlessException — on le desactive ici. En mode
|
||||||
// serveur/Docker, on reste en headless (defaut), aucun impact.
|
// serveur/Docker, on reste en headless (defaut), aucun impact.
|
||||||
app.setHeadless(false);
|
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);
|
app.run(args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ public interface ImageStorage {
|
|||||||
*/
|
*/
|
||||||
String upload(String filename, String contentType, InputStream data, long sizeBytes);
|
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. */
|
/** Recupere le flux binaire associe a une cle, ou null si inexistante. */
|
||||||
InputStream download(String storageKey);
|
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() {
|
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). */
|
/** 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() {
|
private static Path loremindHome() {
|
||||||
String home = System.getProperty("loremind.home");
|
String home = System.getProperty("loremind.home");
|
||||||
if (home != null && !home.isBlank()) return Path.of(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();
|
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");
|
MenuItem quit = new MenuItem("Quitter LoreMind");
|
||||||
quit.addActionListener(e -> quit());
|
quit.addActionListener(e -> quit());
|
||||||
popup.add(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.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repository Spring Data JPA pour ImageJpaEntity.
|
* Repository Spring Data JPA pour ImageJpaEntity.
|
||||||
* Ne contient aucune requete custom pour l'instant : CRUD standard suffit.
|
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface ImageJpaRepository extends JpaRepository<ImageJpaEntity, Long> {
|
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.io.UncheckedIOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
import java.util.UUID;
|
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
|
@Override
|
||||||
public InputStream download(String storageKey) {
|
public InputStream download(String storageKey) {
|
||||||
Path source = resolveKey(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
|
@Override
|
||||||
public InputStream download(String storageKey) {
|
public InputStream download(String storageKey) {
|
||||||
try {
|
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) :
|
# Surcharge UNIQUEMENT ce qui differe du profil par defaut (application.properties) :
|
||||||
# le reste (timeouts, multipart, licensing...) est herite tel quel.
|
# 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 ---------
|
# --- Base de donnees : H2 en mode fichier, compatibilite PostgreSQL ---------
|
||||||
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
|
# MODE=PostgreSQL : H2 interprete le SQL PostgreSQL -> les MEMES migrations
|
||||||
# Flyway (ecrites en SQL Postgres) tournent ici comme sur la prod Postgres.
|
# 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) ------------------------------------------
|
# --- Admin (localhost uniquement) ------------------------------------------
|
||||||
# Application mono-utilisateur sur le poste : l'utilisateur EST l'admin.
|
# 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.username=${ADMIN_USERNAME:admin}
|
||||||
admin.password=${ADMIN_PASSWORD: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",
|
"name": "loremind-web",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.16",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.16",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -66,6 +66,13 @@ export interface OllamaModelInfo {
|
|||||||
context_length: number;
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
private readonly apiUrl = '/api/settings';
|
private readonly apiUrl = '/api/settings';
|
||||||
@@ -85,6 +92,21 @@ export class SettingsService {
|
|||||||
return this.http.put<AppSettings>(this.apiUrl, patch, this.authOptions);
|
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[] }> {
|
listOllamaModels(): Observable<{ models: string[] }> {
|
||||||
return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions);
|
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>
|
<label for="onemin-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||||
<input
|
<input
|
||||||
id="onemin-key"
|
id="onemin-key"
|
||||||
type="password"
|
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||||
[(ngModel)]="oneminApiKeyInput"
|
[(ngModel)]="oneminApiKeyInput"
|
||||||
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
|
[placeholder]="(settings.onemin_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOnemin') | translate">
|
||||||
@if (settings.onemin_api_key_set) {
|
@if (settings.onemin_api_key_set) {
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
|
<label for="openrouter-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||||
<input
|
<input
|
||||||
id="openrouter-key"
|
id="openrouter-key"
|
||||||
type="password"
|
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||||
[(ngModel)]="openrouterApiKeyInput"
|
[(ngModel)]="openrouterApiKeyInput"
|
||||||
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
|
[placeholder]="(settings.openrouter_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderOpenrouter') | translate">
|
||||||
@if (settings.openrouter_api_key_set) {
|
@if (settings.openrouter_api_key_set) {
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
|
<label for="mistral-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||||
<input
|
<input
|
||||||
id="mistral-key"
|
id="mistral-key"
|
||||||
type="password"
|
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||||
[(ngModel)]="mistralApiKeyInput"
|
[(ngModel)]="mistralApiKeyInput"
|
||||||
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||||
@if (settings.mistral_api_key_set) {
|
@if (settings.mistral_api_key_set) {
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
|
<label for="gemini-key">{{ 'settings.apiKey.label' | translate }}</label>
|
||||||
<input
|
<input
|
||||||
id="gemini-key"
|
id="gemini-key"
|
||||||
type="password"
|
type="password" autocomplete="new-password" data-1p-ignore data-lpignore="true" data-bwignore
|
||||||
[(ngModel)]="geminiApiKeyInput"
|
[(ngModel)]="geminiApiKeyInput"
|
||||||
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
|
[placeholder]="(settings.gemini_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderGemini') | translate">
|
||||||
@if (settings.gemini_api_key_set) {
|
@if (settings.gemini_api_key_set) {
|
||||||
@@ -285,7 +285,7 @@
|
|||||||
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</span>
|
<span class="key-state">{{ 'settings.apiKey.configured' | translate }}</span>
|
||||||
}
|
}
|
||||||
</label>
|
</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">
|
[placeholder]="(settings.mistral_api_key_set ? 'settings.apiKey.placeholderSet' : 'settings.apiKey.placeholderMistral') | translate">
|
||||||
@if (settings.mistral_api_key_set) {
|
@if (settings.mistral_api_key_set) {
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
@@ -372,6 +372,33 @@
|
|||||||
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
|
<!-- Mises a jour conteneurs + licence Patreon + canal beta (sous-composant) -->
|
||||||
<app-settings-updates-section></app-settings-updates-section>
|
<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) {
|
@if (settings) {
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-primary" (click)="save()" [disabled]="saving">
|
<button class="btn-primary" (click)="save()" [disabled]="saving">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Plus } from 'lucide-angular';
|
||||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
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 { LayoutService } from '../services/layout.service';
|
||||||
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
import { UpdatesSectionComponent } from './updates-section/updates-section.component';
|
||||||
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
import { OllamaModelManagerComponent } from './ollama-model-manager/ollama-model-manager.component';
|
||||||
@@ -332,6 +332,60 @@ export class SettingsComponent implements OnInit {
|
|||||||
this.router.navigate(['/lore']);
|
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 {
|
private extractError(err: any, fallback: string): string {
|
||||||
if (err?.error?.detail) return String(err.error.detail);
|
if (err?.error?.detail) return String(err.error.detail);
|
||||||
if (err?.message) return err.message;
|
if (err?.message) return err.message;
|
||||||
|
|||||||
@@ -50,6 +50,18 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "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": {
|
"language": {
|
||||||
"title": "Interface language",
|
"title": "Interface language",
|
||||||
"hint": "Choose the application display language. The change is applied instantly and remembered on this device."
|
"hint": "Choose the application display language. The change is applied instantly and remembered on this device."
|
||||||
|
|||||||
@@ -50,6 +50,18 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Paramètres",
|
"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": {
|
"language": {
|
||||||
"title": "Langue de l'interface",
|
"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."
|
"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