Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
All checks were successful
Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.
Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
passage de la non-relecture de `foundryPath` dans le converter.
Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
`image_framing` (migration V8), DTO/mapper + passthrough export/import.
Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
(.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
(→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
fonctionnent sans changement. Mode déduit à la relecture de l'extension.
Front-only, sans migration.
i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
This commit is contained in:
@@ -86,6 +86,7 @@ public class PageService {
|
||||
existing.setNodeId(changes.getNodeId());
|
||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
||||
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
|
||||
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
|
||||
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
|
||||
existing.setNotes(changes.getNotes());
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
/**
|
||||
* Cadrage d'une image dans son bloc IMAGE d'une page de lore.
|
||||
* <p>
|
||||
* Donnée purement présentationnelle, définie PAR IMAGE et PAR PAGE :
|
||||
* <ul>
|
||||
* <li>{@link #x}, {@link #y} : object-position en pourcentage (0..100).
|
||||
* 50/50 = centré (défaut).</li>
|
||||
* <li>{@link #scale} : facteur de zoom (>= 1). 1 = ajusté plein cadre (cover).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Stockée sur la Page dans {@code imageFraming} (fieldKey → imageId → cadrage),
|
||||
* sérialisée en JSON. Entité pure du domaine : aucune dépendance technique.
|
||||
*/
|
||||
public record ImageFraming(double x, double y, double scale) {
|
||||
}
|
||||
@@ -42,6 +42,13 @@ public class Page {
|
||||
*/
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/**
|
||||
* Cadrage (pan/zoom) des images dans leur bloc IMAGE : fieldKey → imageId →
|
||||
* {@link ImageFraming}. Purement presentationnel ; l'absence d'entree = cadrage
|
||||
* par defaut (centre, plein cadre). Voir {@link ImageFraming}.
|
||||
*/
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
|
||||
/**
|
||||
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
|
||||
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Value Object decrivant le placement d'un bloc dans la grille du template
|
||||
* (kernel partage).
|
||||
* <p>
|
||||
* Modele : grille responsive a 12 colonnes (facon page-builder), inspire de
|
||||
* l'app "Lore" d'Amsel. Chaque bloc occupe un rectangle de la grille :
|
||||
* <ul>
|
||||
* <li>{@link #x} : colonne de depart, 0..11</li>
|
||||
* <li>{@link #y} : ligne de depart, 0..n</li>
|
||||
* <li>{@link #w} : largeur en colonnes, 1..12</li>
|
||||
* <li>{@link #h} : hauteur en lignes, 1..n</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Tous les champs sont nullables : un {@code pos} absent (null) ou des
|
||||
* coordonnees nulles signifient "auto-flow" — le bloc est empile a la suite
|
||||
* des precedents, exactement comme le rendu historique en une seule colonne.
|
||||
* C'est ce qui garantit la retrocompatibilite des templates existants (qui
|
||||
* n'ont aucune donnee de placement) : ils continuent de s'afficher empiles.
|
||||
* <p>
|
||||
* Donnee purement presentationnelle : ignoree par l'IA, l'export campagne et
|
||||
* l'export Foundry, qui ne lisent que l'ordre et les valeurs par nom de champ.
|
||||
* <p>
|
||||
* Entite pure du domaine : aucune dependance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BlockPosition {
|
||||
/** Colonne de depart dans la grille 12 colonnes (0..11). Null = auto-flow. */
|
||||
private Integer x;
|
||||
/** Ligne de depart (0..n). Null = auto-flow. */
|
||||
private Integer y;
|
||||
/** Largeur en colonnes (1..12). Null = pleine largeur par defaut. */
|
||||
private Integer w;
|
||||
/** Hauteur en lignes (1..n). Null = hauteur du contenu. */
|
||||
private Integer h;
|
||||
}
|
||||
@@ -17,12 +17,29 @@ import java.util.List;
|
||||
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||
* Ignore pour les autres types.
|
||||
* <p>
|
||||
* {@link #id} est la cle STABLE du bloc : les valeurs des Pages s'ancrent
|
||||
* dessus (et non sur {@link #name}), ce qui permet de renommer un bloc sans
|
||||
* orpheliner son contenu. Pour les templates anterieurs (qui n'ont pas d'id),
|
||||
* l'id est retro-rempli avec le nom courant a la lecture : comme les valeurs
|
||||
* existantes sont deja rangees par nom, {@code id == name} au depart et rien
|
||||
* n'a besoin d'etre migre.
|
||||
* <p>
|
||||
* {@link #pos} porte le placement du bloc dans la grille 12 colonnes du
|
||||
* template (voir {@link BlockPosition}). Null = auto-flow empile (rendu
|
||||
* historique).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TemplateField {
|
||||
/**
|
||||
* Identifiant STABLE du bloc, cle d'ancrage des valeurs de Page.
|
||||
* Retro-rempli avec {@link #name} pour les templates sans id. Immuable :
|
||||
* survit aux renommages du bloc.
|
||||
*/
|
||||
private String id;
|
||||
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
|
||||
private String name;
|
||||
/** Type du champ, pilote le rendu et la generation IA. */
|
||||
@@ -44,6 +61,12 @@ public class TemplateField {
|
||||
*/
|
||||
private String foundryPath;
|
||||
|
||||
/**
|
||||
* Placement du bloc dans la grille 12 colonnes du template. Null = auto-flow
|
||||
* (le bloc s'empile a la suite des precedents, comme le rendu historique).
|
||||
*/
|
||||
private BlockPosition pos;
|
||||
|
||||
/** Constructeur de retrocompat : type seul, layout/labels=null. */
|
||||
public TemplateField(String name, FieldType type) {
|
||||
this(name, type, null, null, null);
|
||||
@@ -59,6 +82,14 @@ public class TemplateField {
|
||||
this(name, type, layout, labels, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructeur de retrocompat (5 args) : sans id ni pos. Conserve la
|
||||
* signature publique historique (id retro-rempli plus tard, pos=null).
|
||||
*/
|
||||
public TemplateField(String name, FieldType type, ImageLayout layout, List<String> labels, String foundryPath) {
|
||||
this(null, name, type, layout, labels, foundryPath, null);
|
||||
}
|
||||
|
||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||
public static TemplateField text(String name) {
|
||||
return new TemplateField(name, FieldType.TEXT, null, null);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Convertit une {@code Map<String, Map<String, ImageFraming>>} en JSON et inversement.
|
||||
* <p>
|
||||
* Utilisé pour {@code Page.imageFraming} : pour chaque champ IMAGE (clé = id du bloc),
|
||||
* un cadrage (pan/zoom) par image. Exemple :
|
||||
* {"blk-illu": {"img-42": {"x":50.0,"y":30.0,"scale":1.4}}}
|
||||
* <p>
|
||||
* Tolérant aux propriétés inconnues (évolution de {@link ImageFraming}).
|
||||
* Adaptateur technique pur : le domaine ignore ce converter.
|
||||
*/
|
||||
@Converter
|
||||
public class ImageFramingMapJsonConverter
|
||||
implements AttributeConverter<Map<String, Map<String, ImageFraming>>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
private static final TypeReference<Map<String, Map<String, ImageFraming>>> TYPE_REF =
|
||||
new TypeReference<>() {};
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, Map<String, ImageFraming>> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur serialisation Map<String, Map<String,ImageFraming>> -> JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Map<String, ImageFraming>> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
|
||||
try {
|
||||
return MAPPER.readValue(dbData, TYPE_REF);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur deserialisation JSON -> Map<String, Map<String,ImageFraming>>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.loremind.infrastructure.persistence.converter;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
@@ -61,9 +62,14 @@ public class TemplateFieldListJsonConverter
|
||||
for (JsonNode item : root) {
|
||||
if (item.isTextual()) {
|
||||
// Format legacy : chaine simple, on suppose TEXT par defaut.
|
||||
result.add(TemplateField.text(item.asText()));
|
||||
// L'id stable est retro-rempli avec le nom (les valeurs de Page
|
||||
// sont deja rangees par nom -> id == name, aucune migration).
|
||||
String name = item.asText();
|
||||
TemplateField legacy = TemplateField.text(name);
|
||||
legacy.setId(name);
|
||||
result.add(legacy);
|
||||
} else if (item.isObject()) {
|
||||
// Nouveau format : {name, type}
|
||||
// Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}
|
||||
String name = item.path("name").asText(null);
|
||||
String typeStr = item.path("type").asText("TEXT");
|
||||
FieldType type;
|
||||
@@ -95,8 +101,27 @@ public class TemplateFieldListJsonConverter
|
||||
}
|
||||
}
|
||||
}
|
||||
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
|
||||
// (asText() renverrait la chaine "null"). Historiquement omis a la
|
||||
// relecture -> il etait perdu au save+reload : corrige ici.
|
||||
String foundryPath = item.hasNonNull("foundryPath")
|
||||
? item.get("foundryPath").asText() : null;
|
||||
// id : explicite si present, sinon retro-rempli avec le nom.
|
||||
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
|
||||
if (id == null || id.isBlank()) {
|
||||
id = name;
|
||||
}
|
||||
BlockPosition pos = readPos(item.path("pos"));
|
||||
if (name != null && !name.isBlank()) {
|
||||
result.add(new TemplateField(name, type, layout, labels));
|
||||
result.add(TemplateField.builder()
|
||||
.id(id)
|
||||
.name(name)
|
||||
.type(type)
|
||||
.layout(layout)
|
||||
.labels(labels)
|
||||
.foundryPath(foundryPath)
|
||||
.pos(pos)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||
@@ -108,6 +133,31 @@ public class TemplateFieldListJsonConverter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
|
||||
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
|
||||
* (-> auto-flow empile, comportement historique).
|
||||
*/
|
||||
private static BlockPosition readPos(JsonNode posNode) {
|
||||
if (posNode == null || !posNode.isObject()) {
|
||||
return null;
|
||||
}
|
||||
Integer x = intOrNull(posNode, "x");
|
||||
Integer y = intOrNull(posNode, "y");
|
||||
Integer w = intOrNull(posNode, "w");
|
||||
Integer h = intOrNull(posNode, "h");
|
||||
if (x == null && y == null && w == null && h == null) {
|
||||
return null;
|
||||
}
|
||||
return new BlockPosition(x, y, w, h);
|
||||
}
|
||||
|
||||
/** Lit un entier optionnel d'un noeud JSON ; null si absent ou non numerique. */
|
||||
private static Integer intOrNull(JsonNode node, String field) {
|
||||
JsonNode n = node.path(field);
|
||||
return n.isNumber() ? n.asInt() : null;
|
||||
}
|
||||
|
||||
// typeRef garde pour reference future si on veut deserialiser directement.
|
||||
@SuppressWarnings("unused")
|
||||
private static final TypeReference<List<TemplateField>> TYPE_REF =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import com.loremind.infrastructure.persistence.converter.ImageFramingMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
@@ -58,6 +60,11 @@ public class PageJpaEntity {
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Cadrage (pan/zoom) des images : fieldKey → imageId → ImageFraming. JSON TEXT. */
|
||||
@Column(name = "image_framing", columnDefinition = "TEXT")
|
||||
@Convert(converter = ImageFramingMapJsonConverter.class)
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
|
||||
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
|
||||
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringMapMapJsonConverter.class)
|
||||
|
||||
@@ -94,6 +94,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
.order(e.getOrder())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.imageFraming(e.getImageFraming() != null ? new HashMap<>(e.getImageFraming()) : new HashMap<>())
|
||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
|
||||
.notes(e.getNotes())
|
||||
@@ -115,6 +116,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
.order(p.getOrder())
|
||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||
.imageFraming(p.getImageFraming() != null ? new HashMap<>(p.getImageFraming()) : new HashMap<>())
|
||||
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
|
||||
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
|
||||
.notes(p.getNotes())
|
||||
|
||||
@@ -468,8 +468,8 @@ public class ExportService {
|
||||
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());
|
||||
e.getImageFraming(), e.getKeyValueValues(), e.getTableValues(), e.getNotes(),
|
||||
e.getTags(), e.getRelatedPageIds());
|
||||
}
|
||||
|
||||
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
|
||||
|
||||
@@ -210,6 +210,7 @@ public class ImportService {
|
||||
e.setTitle(d.title());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setImageFraming(d.imageFraming());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setTableValues(d.tableValues());
|
||||
e.setNotes(d.notes());
|
||||
|
||||
@@ -105,6 +105,7 @@ public record ContentExport(
|
||||
String title,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, com.loremind.domain.lorecontext.ImageFraming>> imageFraming,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Map<String, List<Map<String, String>>> tableValues,
|
||||
String notes,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
@@ -21,6 +22,8 @@ public class PageDTO {
|
||||
private Map<String, String> values;
|
||||
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
/** Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. */
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.infrastructure.web.dto.shared;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO wire-friendly du placement d'un bloc dans la grille du template.
|
||||
* <p>
|
||||
* Miroir de {@link com.loremind.domain.shared.template.BlockPosition} :
|
||||
* grille 12 colonnes, {@code {x, y, w, h}} en unites de grille. Tous les
|
||||
* champs sont nullables (Integer) — absence = auto-flow empile cote front.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BlockPositionDTO {
|
||||
/** Colonne de depart (0..11). */
|
||||
private Integer x;
|
||||
/** Ligne de depart (0..n). */
|
||||
private Integer y;
|
||||
/** Largeur en colonnes (1..12). */
|
||||
private Integer w;
|
||||
/** Hauteur en lignes (1..n). */
|
||||
private Integer h;
|
||||
}
|
||||
@@ -29,6 +29,21 @@ public class TemplateFieldDTO {
|
||||
/** Chemin Foundry du champ (mapping pour l'export d'acteur typé). Nullable. */
|
||||
private String foundryPath;
|
||||
|
||||
/**
|
||||
* Identifiant STABLE du bloc (cle d'ancrage des valeurs de Page). Retro-rempli
|
||||
* avec le nom cote backend pour les templates anterieurs. Appended en fin de
|
||||
* classe pour preserver la compat des constructeurs historiques.
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/** Placement du bloc dans la grille 12 colonnes. Null = auto-flow empile. */
|
||||
private BlockPositionDTO pos;
|
||||
|
||||
/** Retrocompat : constructeur sans id ni pos. */
|
||||
public TemplateFieldDTO(String name, String type, String layout, List<String> labels, String foundryPath) {
|
||||
this(name, type, layout, labels, foundryPath, null, null);
|
||||
}
|
||||
|
||||
/** Retrocompat : constructeur sans foundryPath. */
|
||||
public TemplateFieldDTO(String name, String type, String layout, List<String> labels) {
|
||||
this(name, type, layout, labels, null);
|
||||
|
||||
@@ -24,6 +24,7 @@ public class PageMapper {
|
||||
dto.setOrder(page.getOrder());
|
||||
dto.setValues(CollectionUtils.copyMap(page.getValues()));
|
||||
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
|
||||
dto.setImageFraming(CollectionUtils.copyMap(page.getImageFraming()));
|
||||
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
|
||||
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
|
||||
dto.setNotes(page.getNotes());
|
||||
@@ -45,6 +46,7 @@ public class PageMapper {
|
||||
.order(dto.getOrder())
|
||||
.values(CollectionUtils.copyMap(dto.getValues()))
|
||||
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
|
||||
.imageFraming(CollectionUtils.copyMap(dto.getImageFraming()))
|
||||
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
|
||||
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
|
||||
.notes(dto.getNotes())
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.shared.BlockPositionDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -33,7 +35,11 @@ public class TemplateFieldMapper {
|
||||
&& field.getLabels() != null) {
|
||||
labels = new ArrayList<>(field.getLabels());
|
||||
}
|
||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels, field.getFoundryPath());
|
||||
TemplateFieldDTO dto = new TemplateFieldDTO(
|
||||
field.getName(), typeStr, layoutStr, labels, field.getFoundryPath());
|
||||
dto.setId(resolveId(field.getId(), field.getName()));
|
||||
dto.setPos(toPosDTO(field.getPos()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||
@@ -58,7 +64,34 @@ public class TemplateFieldMapper {
|
||||
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
|
||||
labels = new ArrayList<>(dto.getLabels());
|
||||
}
|
||||
return new TemplateField(dto.getName(), type, layout, labels, dto.getFoundryPath());
|
||||
return TemplateField.builder()
|
||||
.id(resolveId(dto.getId(), dto.getName()))
|
||||
.name(dto.getName())
|
||||
.type(type)
|
||||
.layout(layout)
|
||||
.labels(labels)
|
||||
.foundryPath(dto.getFoundryPath())
|
||||
.pos(toPosDomain(dto.getPos()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie l'id du bloc, retro-rempli avec le nom quand il est absent.
|
||||
* Garantit une cle d'ancrage stable meme pour les clients qui n'envoient
|
||||
* pas encore d'id (front anterieur a la grille).
|
||||
*/
|
||||
private String resolveId(String id, String name) {
|
||||
return id != null && !id.isBlank() ? id : name;
|
||||
}
|
||||
|
||||
private BlockPositionDTO toPosDTO(BlockPosition pos) {
|
||||
if (pos == null) return null;
|
||||
return new BlockPositionDTO(pos.getX(), pos.getY(), pos.getW(), pos.getH());
|
||||
}
|
||||
|
||||
private BlockPosition toPosDomain(BlockPositionDTO pos) {
|
||||
if (pos == null) return null;
|
||||
return new BlockPosition(pos.getX(), pos.getY(), pos.getW(), pos.getH());
|
||||
}
|
||||
|
||||
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Cadrage (pan/zoom) des images dans les blocs IMAGE des pages de lore.
|
||||
-- Stocké en JSON dans une colonne TEXT (fieldKey -> imageId -> {x, y, scale}).
|
||||
-- Compatible Postgres (Docker) et H2 en MODE=PostgreSQL (desktop).
|
||||
alter table pages add column image_framing TEXT;
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests du converter de cadrage d'images (Map<String, Map<String, ImageFraming>>).
|
||||
* Vérifie notamment que le record {@link ImageFraming} fait bien l'aller-retour
|
||||
* JSON (Jackson supporte les records nativement) — sinon les valeurs seraient
|
||||
* perdues silencieusement.
|
||||
*/
|
||||
class ImageFramingMapJsonConverterTest {
|
||||
|
||||
private final ImageFramingMapJsonConverter converter = new ImageFramingMapJsonConverter();
|
||||
|
||||
@Test
|
||||
void toDb_nullOrEmpty_yieldsEmptyObject() {
|
||||
assertEquals("{}", converter.convertToDatabaseColumn(null));
|
||||
assertEquals("{}", converter.convertToDatabaseColumn(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullOrBlank_yieldsEmptyMap() {
|
||||
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
|
||||
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesFramingPerImage() {
|
||||
Map<String, Map<String, ImageFraming>> source = Map.of(
|
||||
"blk-illu", Map.of(
|
||||
"img-42", new ImageFraming(30.0, 70.0, 1.5),
|
||||
"img-7", new ImageFraming(50.0, 50.0, 1.0)));
|
||||
|
||||
Map<String, Map<String, ImageFraming>> back =
|
||||
converter.convertToEntityAttribute(converter.convertToDatabaseColumn(source));
|
||||
|
||||
ImageFraming f = back.get("blk-illu").get("img-42");
|
||||
assertEquals(30.0, f.x());
|
||||
assertEquals(70.0, f.y());
|
||||
assertEquals(1.5, f.scale());
|
||||
assertEquals(1.0, back.get("blk-illu").get("img-7").scale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_readsExplicitJson() {
|
||||
Map<String, Map<String, ImageFraming>> back = converter.convertToEntityAttribute(
|
||||
"{\"blk\":{\"img\":{\"x\":10.0,\"y\":20.0,\"scale\":2.0}}}");
|
||||
ImageFraming f = back.get("blk").get("img");
|
||||
assertEquals(10.0, f.x());
|
||||
assertEquals(20.0, f.y());
|
||||
assertEquals(2.0, f.scale());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
@@ -8,6 +9,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -208,4 +210,94 @@ class TemplateFieldListJsonConverterTest {
|
||||
assertEquals(pass1.get(i).getType(), pass2.get(i).getType());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- id stable (ancrage des valeurs de Page) --------------------
|
||||
|
||||
@Test
|
||||
void fromDb_legacyFormat_backfillsIdWithName() {
|
||||
// Format legacy (chaines) : l'id stable est retro-rempli avec le nom,
|
||||
// donc id == name -> les valeurs deja rangees par nom resolvent toujours.
|
||||
List<TemplateField> result = converter.convertToEntityAttribute("[\"Histoire\"]");
|
||||
assertEquals("Histoire", result.get(0).getName());
|
||||
assertEquals("Histoire", result.get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_newFormat_withoutId_backfillsIdWithName() {
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Ambiance\",\"type\":\"TEXT\"}]");
|
||||
assertEquals("Ambiance", result.get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_newFormat_withExplicitId_keepsItEvenWhenNameDiffers() {
|
||||
// Simule un bloc renomme : l'id stable est decorrele du nom courant.
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"id\":\"blk-1\",\"name\":\"Nouveau nom\",\"type\":\"TEXT\"}]");
|
||||
assertEquals("blk-1", result.get(0).getId());
|
||||
assertEquals("Nouveau nom", result.get(0).getName());
|
||||
}
|
||||
|
||||
// ---------- foundryPath : non-regression (perdu au save+reload avant) --
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesFoundryPath() {
|
||||
// Garde-fou : foundryPath n'etait pas relu et disparaissait au
|
||||
// save+reload. Il doit desormais survivre a l'aller-retour.
|
||||
List<TemplateField> source = List.of(
|
||||
new TemplateField("PV", FieldType.NUMBER, null, null, "attributes.hp.value"));
|
||||
|
||||
List<TemplateField> back = converter.convertToEntityAttribute(
|
||||
converter.convertToDatabaseColumn(source));
|
||||
|
||||
assertEquals("attributes.hp.value", back.get(0).getFoundryPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullFoundryPath_readsAsNull_notLiteralNullString() {
|
||||
// Piege Jackson : sur un NullNode, asText() renverrait la chaine "null".
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"foundryPath\":null}]");
|
||||
assertNull(result.get(0).getFoundryPath());
|
||||
}
|
||||
|
||||
// ---------- pos : placement dans la grille -----------------------------
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesBlockPosition() {
|
||||
TemplateField illustration = TemplateField.builder()
|
||||
.id("blk-illu").name("Illustration").type(FieldType.IMAGE)
|
||||
.layout(ImageLayout.GALLERY)
|
||||
.pos(new BlockPosition(0, 0, 6, 4))
|
||||
.build();
|
||||
TemplateField ambiance = TemplateField.builder()
|
||||
.id("blk-amb").name("Ambiance générale").type(FieldType.TEXT)
|
||||
.pos(new BlockPosition(6, 0, 6, 4))
|
||||
.build();
|
||||
|
||||
List<TemplateField> back = converter.convertToEntityAttribute(
|
||||
converter.convertToDatabaseColumn(List.of(illustration, ambiance)));
|
||||
|
||||
BlockPosition p0 = back.get(0).getPos();
|
||||
assertNotNull(p0);
|
||||
assertEquals(0, p0.getX());
|
||||
assertEquals(6, p0.getW());
|
||||
// Le second bloc est place a droite du premier (cote-a-cote).
|
||||
assertEquals(6, back.get(1).getPos().getX());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_withoutPos_yieldsNullPosition() {
|
||||
// Pas de pos -> auto-flow empile (rendu historique).
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\"}]");
|
||||
assertNull(result.get(0).getPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullPos_yieldsNullPosition() {
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"pos\":null}]");
|
||||
assertNull(result.get(0).getPos());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user