Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4a42327d | |||
| 52e389db24 | |||
| efaf5a3794 | |||
| 4fe93b5ff3 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,6 +7,11 @@
|
|||||||
brain/data/settings.json
|
brain/data/settings.json
|
||||||
*.key
|
*.key
|
||||||
*.pem
|
*.pem
|
||||||
|
# Exception : la cle PUBLIQUE JWT du relais Patreon est destinee a etre
|
||||||
|
# embarquee dans le binaire. Pas de risque a la committer (c'est une cle
|
||||||
|
# publique par construction). Sans cette exception, le module licensing
|
||||||
|
# est silencieusement desactive dans les builds CI.
|
||||||
|
!core/src/main/resources/licensing/jwt-public-key.pem
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Java / Spring Boot / Maven
|
# Java / Spring Boot / Maven
|
||||||
|
|||||||
12
core/pom.xml
12
core/pom.xml
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.8.0</version>
|
<version>0.8.1</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
@@ -111,6 +111,16 @@
|
|||||||
</exclude>
|
</exclude>
|
||||||
</excludes>
|
</excludes>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<!-- Genere META-INF/build-info.properties (project.version)
|
||||||
|
consomme par Spring BuildProperties pour exposer la
|
||||||
|
version courante a l'application (UpdateCheckService). -->
|
||||||
|
<execution>
|
||||||
|
<goals>
|
||||||
|
<goal>build-info</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
<!-- JaCoCo : rapport de couverture des tests unitaires.
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
import com.loremind.domain.campaigncontext.ports.CharacterRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +24,17 @@ public class CharacterService {
|
|||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un Character.
|
* Parameter Object pour la création / mise à jour d'un Character.
|
||||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
* `order` est fourni par le controller ; si absent, le service le calcule.
|
||||||
|
* Les maps {@code values}/{@code imageValues} peuvent etre null (interpretees vides).
|
||||||
*/
|
*/
|
||||||
public record CharacterData(String name, String markdownContent, String campaignId, Integer order) {}
|
public record CharacterData(
|
||||||
|
String name,
|
||||||
|
String portraitImageId,
|
||||||
|
String headerImageId,
|
||||||
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
public Character createCharacter(CharacterData data) {
|
public Character createCharacter(CharacterData data) {
|
||||||
int order = data.order() != null
|
int order = data.order() != null
|
||||||
@@ -31,7 +42,10 @@ public class CharacterService {
|
|||||||
: nextOrderFor(data.campaignId());
|
: nextOrderFor(data.campaignId());
|
||||||
Character character = Character.builder()
|
Character character = Character.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
.markdownContent(data.markdownContent())
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -50,7 +64,10 @@ public class CharacterService {
|
|||||||
Character existing = characterRepository.findById(id)
|
Character existing = characterRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Character non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setMarkdownContent(data.markdownContent());
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import com.loremind.domain.campaigncontext.Npc;
|
|||||||
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
import com.loremind.domain.campaigncontext.ports.NpcRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +21,15 @@ public class NpcService {
|
|||||||
this.npcRepository = npcRepository;
|
this.npcRepository = npcRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public record NpcData(
|
||||||
* Parameter Object pour la création / mise à jour d'un Npc.
|
String name,
|
||||||
* `order` est fourni par le controller ; si absent, le service le calcule.
|
String portraitImageId,
|
||||||
*/
|
String headerImageId,
|
||||||
public record NpcData(String name, String markdownContent, String campaignId, Integer order) {}
|
Map<String, String> values,
|
||||||
|
Map<String, List<String>> imageValues,
|
||||||
|
String campaignId,
|
||||||
|
Integer order
|
||||||
|
) {}
|
||||||
|
|
||||||
public Npc createNpc(NpcData data) {
|
public Npc createNpc(NpcData data) {
|
||||||
int order = data.order() != null
|
int order = data.order() != null
|
||||||
@@ -31,7 +37,10 @@ public class NpcService {
|
|||||||
: nextOrderFor(data.campaignId());
|
: nextOrderFor(data.campaignId());
|
||||||
Npc npc = Npc.builder()
|
Npc npc = Npc.builder()
|
||||||
.name(data.name())
|
.name(data.name())
|
||||||
.markdownContent(data.markdownContent())
|
.portraitImageId(data.portraitImageId())
|
||||||
|
.headerImageId(data.headerImageId())
|
||||||
|
.values(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>())
|
||||||
|
.imageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>())
|
||||||
.campaignId(data.campaignId())
|
.campaignId(data.campaignId())
|
||||||
.order(order)
|
.order(order)
|
||||||
.build();
|
.build();
|
||||||
@@ -50,7 +59,10 @@ public class NpcService {
|
|||||||
Npc existing = npcRepository.findById(id)
|
Npc existing = npcRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
.orElseThrow(() -> new IllegalArgumentException("Npc non trouvé avec l'ID: " + id));
|
||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setMarkdownContent(data.markdownContent());
|
existing.setPortraitImageId(data.portraitImageId());
|
||||||
|
existing.setHeaderImageId(data.headerImageId());
|
||||||
|
existing.setValues(data.values() != null ? new HashMap<>(data.values()) : new HashMap<>());
|
||||||
|
existing.setImageValues(data.imageValues() != null ? new HashMap<>(data.imageValues()) : new HashMap<>());
|
||||||
if (data.order() != null) {
|
if (data.order() != null) {
|
||||||
existing.setOrder(data.order());
|
existing.setOrder(data.order());
|
||||||
}
|
}
|
||||||
@@ -61,7 +73,6 @@ public class NpcService {
|
|||||||
npcRepository.deleteById(id);
|
npcRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renvoie la prochaine position libre — append en fin de liste. */
|
|
||||||
private int nextOrderFor(String campaignId) {
|
private int nextOrderFor(String campaignId) {
|
||||||
return npcRepository.findByCampaignId(campaignId).stream()
|
return npcRepository.findByCampaignId(campaignId).stream()
|
||||||
.mapToInt(Npc::getOrder)
|
.mapToInt(Npc::getOrder)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.loremind.application.gamesystemcontext;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -18,11 +19,14 @@ public class GameSystemService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
* Parameter Object pour la création / mise à jour d'un GameSystem.
|
||||||
|
* Les templates peuvent etre null (interpretes comme listes vides).
|
||||||
*/
|
*/
|
||||||
public record GameSystemData(
|
public record GameSystemData(
|
||||||
String name,
|
String name,
|
||||||
String description,
|
String description,
|
||||||
String rulesMarkdown,
|
String rulesMarkdown,
|
||||||
|
List<TemplateField> characterTemplate,
|
||||||
|
List<TemplateField> npcTemplate,
|
||||||
String author,
|
String author,
|
||||||
boolean isPublic
|
boolean isPublic
|
||||||
) {}
|
) {}
|
||||||
@@ -35,6 +39,8 @@ public class GameSystemService {
|
|||||||
.author(normalize(data.author()))
|
.author(normalize(data.author()))
|
||||||
.isPublic(data.isPublic())
|
.isPublic(data.isPublic())
|
||||||
.build();
|
.build();
|
||||||
|
gameSystem.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
gameSystem.replaceNpcTemplate(data.npcTemplate());
|
||||||
return gameSystemRepository.save(gameSystem);
|
return gameSystemRepository.save(gameSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +58,8 @@ public class GameSystemService {
|
|||||||
existing.setName(data.name());
|
existing.setName(data.name());
|
||||||
existing.setDescription(data.description());
|
existing.setDescription(data.description());
|
||||||
existing.setRulesMarkdown(data.rulesMarkdown());
|
existing.setRulesMarkdown(data.rulesMarkdown());
|
||||||
|
existing.replaceCharacterTemplate(data.characterTemplate());
|
||||||
|
existing.replaceNpcTemplate(data.npcTemplate());
|
||||||
existing.setAuthor(normalize(data.author()));
|
existing.setAuthor(normalize(data.author()));
|
||||||
existing.setPublic(data.isPublic());
|
existing.setPublic(data.isPublic());
|
||||||
return gameSystemRepository.save(existing);
|
return gameSystemRepository.save(existing);
|
||||||
|
|||||||
@@ -104,23 +104,32 @@ public class CampaignStructuralContextBuilder {
|
|||||||
* sans injecter toute sa fiche.
|
* sans injecter toute sa fiche.
|
||||||
*/
|
*/
|
||||||
private CharacterSummary toCharacterSummary(Character c) {
|
private CharacterSummary toCharacterSummary(Character c) {
|
||||||
return new CharacterSummary(c.getName(), extractSnippet(c.getMarkdownContent()));
|
return new CharacterSummary(c.getName(), extractSnippet(c.getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
/** Symétrique à {@link #toCharacterSummary} pour les PNJ. */
|
||||||
private NpcSummary toNpcSummary(Npc n) {
|
private NpcSummary toNpcSummary(Npc n) {
|
||||||
return new NpcSummary(n.getName(), extractSnippet(n.getMarkdownContent()));
|
return new NpcSummary(n.getName(), extractSnippet(n.getValues()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String extractSnippet(String markdown) {
|
/**
|
||||||
if (markdown == null || markdown.isBlank()) return "";
|
* Snippet pour le resume IA : 1re ligne signifiante de la 1re valeur non vide
|
||||||
String firstLine = markdown.lines()
|
* du template (refonte 2026-04-30 — remplace l'ancien parsing markdown).
|
||||||
.map(String::strip)
|
*/
|
||||||
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
private static String extractSnippet(java.util.Map<String, String> values) {
|
||||||
.findFirst()
|
if (values == null || values.isEmpty()) return "";
|
||||||
.orElse("");
|
for (String value : values.values()) {
|
||||||
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
if (value == null || value.isBlank()) continue;
|
||||||
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
String firstLine = value.lines()
|
||||||
|
.map(String::strip)
|
||||||
|
.filter(l -> !l.isEmpty() && !l.startsWith("#"))
|
||||||
|
.findFirst()
|
||||||
|
.orElse("");
|
||||||
|
if (firstLine.isEmpty()) continue;
|
||||||
|
if (firstLine.length() <= CHARACTER_SNIPPET_MAX_LEN) return firstLine;
|
||||||
|
return firstLine.substring(0, CHARACTER_SNIPPET_MAX_LEN - 1).stripTrailing() + "…";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArcSummary toArcSummary(Arc arc) {
|
private ArcSummary toArcSummary(Arc arc) {
|
||||||
|
|||||||
@@ -130,13 +130,19 @@ public class NarrativeEntityContextBuilder {
|
|||||||
|
|
||||||
private NarrativeEntityContext fromCharacter(Character c) {
|
private NarrativeEntityContext fromCharacter(Character c) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", c.getMarkdownContent());
|
if (c.getValues() != null) {
|
||||||
|
// Champs templates exposes individuellement — meilleur pour le LLM que
|
||||||
|
// l'ancien blob markdown monolithique.
|
||||||
|
c.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||||
|
}
|
||||||
return new NarrativeEntityContext("character", c.getName(), fields);
|
return new NarrativeEntityContext("character", c.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
private NarrativeEntityContext fromNpc(Npc n) {
|
private NarrativeEntityContext fromNpc(Npc n) {
|
||||||
Map<String, String> fields = new LinkedHashMap<>();
|
Map<String, String> fields = new LinkedHashMap<>();
|
||||||
putField(fields, "fiche complète (markdown)", n.getMarkdownContent());
|
if (n.getValues() != null) {
|
||||||
|
n.getValues().forEach((k, v) -> putField(fields, k, v));
|
||||||
|
}
|
||||||
return new NarrativeEntityContext("npc", n.getName(), fields);
|
return new NarrativeEntityContext("npc", n.getName(), fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,26 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||||
* <p>
|
* <p>
|
||||||
* MVP : contenu markdown libre, l'utilisateur met ce qu'il veut (stats,
|
* Champs universels hard-codes : {@code name}, {@code portraitImageId},
|
||||||
* backstory, équipement). Évolution prévue vers un système templaté par
|
* {@code headerImageId}. Tout le reste est piloté par le template PJ du
|
||||||
* GameSystem (la fiche Nimble n'a pas les mêmes champs qu'une fiche D&D).
|
* GameSystem associé à la campagne (cf. {@link com.loremind.domain.gamesystemcontext.GameSystem#getCharacterTemplate}).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} dédiée
|
* Les valeurs des champs templates sont stockées dans deux maps :
|
||||||
* (entité distincte plutôt qu'enum PJ/PNJ — invariants métier divergents).
|
* - {@code values} : champs TEXT et NUMBER (numérique sérialisé en string,
|
||||||
* Évolution prévue : système de templating partagé PJ/PNJ piloté par
|
* parsé à l'usage cote presentation)
|
||||||
* GameSystem pour adapter les blocs aux différents systèmes de JDR.
|
* - {@code imageValues} : champs IMAGE (liste ordonnée d'IDs d'images par champ)
|
||||||
|
* <p>
|
||||||
|
* Le champ historique {@code markdownContent} a été supprimé (refonte 2026-04-30).
|
||||||
|
* Le contenu pre-existant est migré dans {@code values["Notes"]} par défaut.
|
||||||
|
* <p>
|
||||||
|
* Scope strict PJ : les PNJ sont gérés par l'entité {@link Npc} (invariants divergents).
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -24,11 +32,24 @@ public class Character {
|
|||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
|
/** ID de l'image portrait (champ universel hard-codé). Nullable. */
|
||||||
|
private String portraitImageId;
|
||||||
|
|
||||||
|
/** ID de l'image header/banniere (champ universel hard-codé). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contenu libre en markdown — stats + backstory + notes. Nullable à la création,
|
* Valeurs des champs TEXT et NUMBER du template PJ. Cle = nom du champ
|
||||||
* renseigné progressivement par le MJ.
|
* (sensible a la casse cote stockage mais comparaison case-insensitive
|
||||||
|
* dans le domaine GameSystem). Jamais null apres construction.
|
||||||
*/
|
*/
|
||||||
private String markdownContent;
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valeurs des champs IMAGE du template PJ. Cle = nom du champ, valeur =
|
||||||
|
* liste ordonnee d'IDs d'images. Jamais null apres construction.
|
||||||
|
*/
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente. */
|
/** Référence vers la Campaign parente. */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
@@ -38,4 +59,15 @@ public class Character {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** Garantit que les maps ne sont jamais null cote consommateur. */
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,22 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
* <p>
|
* <p>
|
||||||
* MVP : entité dédiée, distincte de {@link Character} (PJ). Choix DDD assumé —
|
* Entité dédiée distincte de {@link Character} (DDD assumé : invariants divergents
|
||||||
* un PNJ a vocation à porter à terme des invariants métier propres (faction,
|
* à terme — faction, statut vivant/mort, visibilité côté joueurs, etc.).
|
||||||
* statut vivant/mort/disparu, visibilité côté joueurs, relations inter-PNJ)
|
|
||||||
* qui n'ont aucun sens sur un PJ. Mutualiser via un enum aurait pollué l'entité
|
|
||||||
* PJ avec des champs inutiles ({@code if (type == NPC)} partout = anti-pattern).
|
|
||||||
* <p>
|
* <p>
|
||||||
* Contenu markdown libre comme les PJ. Évolution prévue : templating partagé
|
* Mêmes champs universels hard-codés et meme structure de templating que Character,
|
||||||
* PJ/PNJ piloté par GameSystem.
|
* pilotée par le template PNJ du GameSystem
|
||||||
|
* ({@link com.loremind.domain.gamesystemcontext.GameSystem#getNpcTemplate}).
|
||||||
* <p>
|
* <p>
|
||||||
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent
|
* Scope campagne : les PNJ "univers" (worldboss, figures du Lore) restent gérés
|
||||||
* gérés via le système Page/Template du LoreContext.
|
* via le système Page/Template du LoreContext.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -27,10 +28,19 @@ public class Npc {
|
|||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
/** Contenu libre markdown — description, motivation, stats, notes MJ. Nullable à la création. */
|
/** ID de l'image portrait (champ universel hard-code). Nullable. */
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
|
||||||
/** Référence vers la Campaign parente (cross-aggregate via ID, jamais d'objet). */
|
/** ID de l'image header/banniere (champ universel hard-code). Nullable. */
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER du template PNJ. Jamais null apres construction. */
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE du template PNJ (listes d'IDs ordonnees par champ). Jamais null. */
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
|
/** Référence vers la Campaign parente (cross-aggregate via ID). */
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
|
|
||||||
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
/** Ordre d'affichage dans la liste des PNJ de la campagne. */
|
||||||
@@ -38,4 +48,14 @@ public class Npc {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
public Map<String, String> getValues() {
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<String>> getImageValues() {
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
|
return imageValues;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.loremind.domain.gamesystemcontext;
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité de domaine représentant un GameSystem (système de JDR).
|
* Entité de domaine représentant un GameSystem (système de JDR).
|
||||||
@@ -12,6 +16,10 @@ import java.time.LocalDateTime;
|
|||||||
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
* d'un markdown monolithique structuré par titres H2. Les sections sont extraites
|
||||||
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
* à la volée lors de l'injection dans les prompts IA (cf. GameSystemContextSelector).
|
||||||
* <p>
|
* <p>
|
||||||
|
* Porte aussi deux templates piloтant la structure des fiches PJ et PNJ d'une
|
||||||
|
* campagne adossée à ce système. Les fiches markdown libres ont laissé place à
|
||||||
|
* un système de champs typés (TEXT/IMAGE/NUMBER) défini ici.
|
||||||
|
* <p>
|
||||||
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
* {@code author} et {@code isPublic} sont des champs pensés pour un futur marketplace
|
||||||
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
* de rulesets partagés — non exploités au MVP mais persistés dès maintenant pour
|
||||||
* éviter une migration ultérieure.
|
* éviter une migration ultérieure.
|
||||||
@@ -27,6 +35,21 @@ public class GameSystem {
|
|||||||
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
/** Markdown monolithique. Sections découpées par titres H2 (## Combat, ## Classes, etc.). */
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template de fiche PJ : champs typés affichés pour chaque personnage joueur.
|
||||||
|
* Hors champs universels hard-codés (nom, portrait, header). Jamais null après
|
||||||
|
* persistance — un template vide est représenté par une liste vide.
|
||||||
|
*/
|
||||||
|
private List<TemplateField> characterTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template de fiche PNJ. Mêmes règles que {@link #characterTemplate}.
|
||||||
|
* Distinct du template PJ car les invariants métier divergent (un PNJ peut
|
||||||
|
* n'avoir qu'un nom + une motivation, un PJ porte généralement une feuille
|
||||||
|
* de stats complète).
|
||||||
|
*/
|
||||||
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
/** Auteur déclaré — futur marketplace. Nullable. */
|
/** Auteur déclaré — futur marketplace. Nullable. */
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -35,4 +58,88 @@ public class GameSystem {
|
|||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
// --- Méthodes métier : templates PJ/PNJ --------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un champ au template PJ. Refuse les doublons de nom (insensible à la casse)
|
||||||
|
* pour éviter les collisions de clés dans {@code Character.values}.
|
||||||
|
*/
|
||||||
|
public void addCharacterField(TemplateField field) {
|
||||||
|
characterTemplate = appendField(characterTemplate, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pendant PNJ de {@link #addCharacterField}. */
|
||||||
|
public void addNpcField(TemplateField field) {
|
||||||
|
npcTemplate = appendField(npcTemplate, field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire un champ du template PJ par nom (insensible à la casse).
|
||||||
|
* No-op silencieux si le champ n'existe pas — appelant n'a pas à pré-vérifier.
|
||||||
|
*/
|
||||||
|
public void removeCharacterField(String fieldName) {
|
||||||
|
characterTemplate = removeFieldByName(characterTemplate, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeNpcField(String fieldName) {
|
||||||
|
npcTemplate = removeFieldByName(npcTemplate, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remplace intégralement le template PJ. Utilisé pour le réordonnancement
|
||||||
|
* et l'édition en bloc côté UI. Valide l'unicité des noms.
|
||||||
|
*/
|
||||||
|
public void replaceCharacterTemplate(List<TemplateField> fields) {
|
||||||
|
characterTemplate = validateAndCopy(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void replaceNpcTemplate(List<TemplateField> fields) {
|
||||||
|
npcTemplate = validateAndCopy(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers privés ----------------------------------------------------
|
||||||
|
|
||||||
|
private static List<TemplateField> appendField(List<TemplateField> current, TemplateField field) {
|
||||||
|
if (field == null || field.getName() == null || field.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Field name is required");
|
||||||
|
}
|
||||||
|
List<TemplateField> next = current == null ? new ArrayList<>() : new ArrayList<>(current);
|
||||||
|
if (containsName(next, field.getName())) {
|
||||||
|
throw new IllegalArgumentException("Duplicate field name: " + field.getName());
|
||||||
|
}
|
||||||
|
next.add(field);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> removeFieldByName(List<TemplateField> current, String fieldName) {
|
||||||
|
if (current == null || fieldName == null) return current;
|
||||||
|
List<TemplateField> next = new ArrayList<>(current);
|
||||||
|
next.removeIf(f -> equalsIgnoreCase(f.getName(), fieldName));
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> validateAndCopy(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> copy = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) {
|
||||||
|
if (f == null || f.getName() == null || f.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Field name is required");
|
||||||
|
}
|
||||||
|
if (containsName(copy, f.getName())) {
|
||||||
|
throw new IllegalArgumentException("Duplicate field name: " + f.getName());
|
||||||
|
}
|
||||||
|
copy.add(f);
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsName(List<TemplateField> fields, String name) {
|
||||||
|
return fields.stream().anyMatch(f -> equalsIgnoreCase(f.getName(), name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean equalsIgnoreCase(String a, String b) {
|
||||||
|
if (a == null || b == null) return a == b;
|
||||||
|
return a.toLowerCase(Locale.ROOT).equals(b.toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type d'un champ dynamique d'un Template.
|
|
||||||
* <p>
|
|
||||||
* - TEXT : valeur textuelle libre (stockee dans Page.values : Map<String, String>)
|
|
||||||
* - IMAGE : galerie d'images, represente comme une liste d'IDs d'images
|
|
||||||
* (stockee dans Page.imageValues : Map<String, List<String>>)
|
|
||||||
* <p>
|
|
||||||
* Extension future possible : RICH_TEXT, NUMBER, DATE, BOOLEAN, LORE_LINK...
|
|
||||||
*/
|
|
||||||
public enum FieldType {
|
|
||||||
TEXT,
|
|
||||||
IMAGE
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type d'un champ dynamique de template (kernel partage).
|
||||||
|
* <p>
|
||||||
|
* - TEXT : valeur textuelle libre (Map<String, String>)
|
||||||
|
* - IMAGE : galerie d'images, liste d'IDs (Map<String, List<String>>)
|
||||||
|
* - NUMBER : valeur numerique stockee en texte (parsee a l'usage)
|
||||||
|
* <p>
|
||||||
|
* Extension future possible : RICH_TEXT, DATE, BOOLEAN, KEY_VALUE_LIST, REFERENCE...
|
||||||
|
*/
|
||||||
|
public enum FieldType {
|
||||||
|
TEXT,
|
||||||
|
IMAGE,
|
||||||
|
NUMBER
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ de type IMAGE.
|
* Variante de rendu pour un champ de type IMAGE.
|
||||||
@@ -8,7 +8,7 @@ package com.loremind.domain.lorecontext;
|
|||||||
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
||||||
* - CAROUSEL : defilement horizontal
|
* - CAROUSEL : defilement horizontal
|
||||||
* <p>
|
* <p>
|
||||||
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
|
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore sinon.
|
||||||
*/
|
*/
|
||||||
public enum ImageLayout {
|
public enum ImageLayout {
|
||||||
GALLERY,
|
GALLERY,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -6,15 +6,15 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value Object d'un champ de Template.
|
* Value Object d'un champ de Template (kernel partage).
|
||||||
* <p>
|
* <p>
|
||||||
* Un champ a un nom (affiche dans l'UI) et un type (TEXT ou IMAGE, extensible).
|
* Un champ a un nom (affiche dans l'UI) et un type. Le type pilote
|
||||||
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
* le rendu cote front et la logique metier (seuls les champs TEXT sont
|
||||||
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
* envoyes a l'IA pour generation).
|
||||||
* <p>
|
* <p>
|
||||||
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||||
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||||
* Ignore pour les champs TEXT.
|
* Ignore pour les autres types.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -47,4 +47,9 @@ public class TemplateField {
|
|||||||
public static TemplateField image(String name, ImageLayout layout) {
|
public static TemplateField image(String name, ImageLayout layout) {
|
||||||
return new TemplateField(name, FieldType.IMAGE, layout);
|
return new TemplateField(name, FieldType.IMAGE, layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ de type NUMBER. */
|
||||||
|
public static TemplateField number(String name) {
|
||||||
|
return new TemplateField(name, FieldType.NUMBER, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.loremind.infrastructure.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill one-shot des fiches Character / Npc post-refonte 2026-04-30.
|
||||||
|
* <p>
|
||||||
|
* Avant la refonte, les fiches stockaient leur contenu dans la colonne
|
||||||
|
* {@code markdown_content}. Apres la refonte, le contenu est dans
|
||||||
|
* {@code field_values} (JSON Map<String,String>). La colonne
|
||||||
|
* {@code markdown_content} subsiste car Hibernate ddl-auto=update ne drop pas.
|
||||||
|
* <p>
|
||||||
|
* Ce backfill copie {@code markdown_content} dans {@code field_values["Notes"]}
|
||||||
|
* pour toutes les fiches qui ont un markdown non vide ET un field_values vide.
|
||||||
|
* Idempotent : si field_values contient deja des donnees, on ne touche pas.
|
||||||
|
* <p>
|
||||||
|
* La colonne {@code markdown_content} n'est PAS supprimee apres backfill —
|
||||||
|
* permet un rollback applicatif au cas ou. Suppression definitive a faire dans
|
||||||
|
* une release ulterieure quand la confiance est etablie.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CharacterNpcMarkdownBackfill {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CharacterNpcMarkdownBackfill.class);
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbc;
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
public CharacterNpcMarkdownBackfill(JdbcTemplate jdbc) {
|
||||||
|
this.jdbc = jdbc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void backfillIfNeeded() {
|
||||||
|
if (!hasMarkdownContentColumn("characters")) {
|
||||||
|
log.debug("Backfill skip : colonne markdown_content absente (deja migre ou install propre).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int chars = backfillTable("characters");
|
||||||
|
int npcs = backfillTable("npcs");
|
||||||
|
if (chars + npcs > 0) {
|
||||||
|
log.info("Backfill markdown -> field_values : {} character(s), {} npc(s) migre(s).", chars, npcs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasMarkdownContentColumn(String table) {
|
||||||
|
try {
|
||||||
|
Integer count = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
+ "WHERE table_name = ? AND column_name = 'markdown_content'",
|
||||||
|
Integer.class, table);
|
||||||
|
return count != null && count > 0;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Backfill : impossible de verifier la colonne markdown_content sur {}: {}",
|
||||||
|
table, e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int backfillTable(String table) {
|
||||||
|
// Selection : fiches avec markdown non vide ET field_values vide ou absent.
|
||||||
|
// field_values peut etre NULL (legacy avant refonte) ou "{}" (refonte appliquee mais sans data).
|
||||||
|
String selectSql = "SELECT id, markdown_content FROM " + table
|
||||||
|
+ " WHERE markdown_content IS NOT NULL "
|
||||||
|
+ " AND markdown_content <> '' "
|
||||||
|
+ " AND (field_values IS NULL OR field_values = '' OR field_values = '{}')";
|
||||||
|
|
||||||
|
var rows = jdbc.queryForList(selectSql);
|
||||||
|
int migrated = 0;
|
||||||
|
for (var row : rows) {
|
||||||
|
Long id = ((Number) row.get("id")).longValue();
|
||||||
|
String markdown = (String) row.get("markdown_content");
|
||||||
|
String json;
|
||||||
|
try {
|
||||||
|
json = mapper.writeValueAsString(Map.of("Notes", markdown));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Backfill {} id={} : echec serialisation JSON, ignore. {}", table, id, e.getMessage());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
jdbc.update("UPDATE " + table + " SET field_values = ? WHERE id = ?", json, id);
|
||||||
|
migrated++;
|
||||||
|
}
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.loremind.infrastructure.persistence;
|
|||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
import com.loremind.domain.gamesystemcontext.ports.GameSystemRepository;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
@@ -23,6 +25,10 @@ import java.util.List;
|
|||||||
* <p>
|
* <p>
|
||||||
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
* Idempotence : ne seed qu'une fois. Si l'utilisateur supprime un ruleset seedé,
|
||||||
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
* il ne revient pas au redémarrage — c'est voulu (respect du choix utilisateur).
|
||||||
|
* <p>
|
||||||
|
* Backfill 2026-04-30 : pour les GameSystems existants (avant la refonte
|
||||||
|
* template-based), on remplit aussi les templates PJ/PNJ par defaut s'ils
|
||||||
|
* sont vides — sinon les fiches restent inutilisables.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemSeeder {
|
public class GameSystemSeeder {
|
||||||
@@ -37,15 +43,37 @@ public class GameSystemSeeder {
|
|||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void seedIfEmpty() {
|
public void seedIfEmpty() {
|
||||||
if (!gameSystemRepository.findAll().isEmpty()) {
|
List<GameSystem> existing = gameSystemRepository.findAll();
|
||||||
log.debug("GameSystem seed skipped — table non vide.");
|
if (existing.isEmpty()) {
|
||||||
|
log.info("Seed initial des GameSystems (table vide)...");
|
||||||
|
for (GameSystem gs : defaultSystems()) {
|
||||||
|
gameSystemRepository.save(gs);
|
||||||
|
}
|
||||||
|
log.info("GameSystems seedés : {}", defaultSystems().size());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info("Seed initial des GameSystems (table vide)...");
|
log.debug("GameSystem seed skipped — table non vide. Backfill templates si necessaire...");
|
||||||
for (GameSystem gs : defaultSystems()) {
|
backfillEmptyTemplates(existing);
|
||||||
gameSystemRepository.save(gs);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill idempotent : pour chaque GameSystem existant ou les deux templates
|
||||||
|
* sont vides (PJ ET PNJ), injecte le template generique. Si l'utilisateur a
|
||||||
|
* deja personnalise au moins un des deux, on ne touche a rien.
|
||||||
|
*/
|
||||||
|
private void backfillEmptyTemplates(List<GameSystem> systems) {
|
||||||
|
int patched = 0;
|
||||||
|
for (GameSystem gs : systems) {
|
||||||
|
boolean charEmpty = gs.getCharacterTemplate() == null || gs.getCharacterTemplate().isEmpty();
|
||||||
|
boolean npcEmpty = gs.getNpcTemplate() == null || gs.getNpcTemplate().isEmpty();
|
||||||
|
if (charEmpty && npcEmpty) {
|
||||||
|
gs.replaceCharacterTemplate(genericCharacterTemplate());
|
||||||
|
gs.replaceNpcTemplate(genericNpcTemplate());
|
||||||
|
gameSystemRepository.save(gs);
|
||||||
|
patched++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
log.info("GameSystems seedés : {}", defaultSystems().size());
|
if (patched > 0) log.info("Backfill templates GameSystem : {} systeme(s) patche(s).", patched);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<GameSystem> defaultSystems() {
|
private List<GameSystem> defaultSystems() {
|
||||||
@@ -56,6 +84,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(NIMBLE_RULES)
|
.rulesMarkdown(NIMBLE_RULES)
|
||||||
|
.characterTemplate(nimbleCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("D&D 5e SRD (extrait)")
|
.name("D&D 5e SRD (extrait)")
|
||||||
@@ -63,6 +93,8 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(DND_SRD_RULES)
|
.rulesMarkdown(DND_SRD_RULES)
|
||||||
|
.characterTemplate(dndCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build(),
|
.build(),
|
||||||
GameSystem.builder()
|
GameSystem.builder()
|
||||||
.name("Homebrew Exemple")
|
.name("Homebrew Exemple")
|
||||||
@@ -70,10 +102,70 @@ public class GameSystemSeeder {
|
|||||||
.author("LoreMind seed")
|
.author("LoreMind seed")
|
||||||
.isPublic(false)
|
.isPublic(false)
|
||||||
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
.rulesMarkdown(HOMEBREW_EXAMPLE)
|
||||||
|
.characterTemplate(genericCharacterTemplate())
|
||||||
|
.npcTemplate(genericNpcTemplate())
|
||||||
.build()
|
.build()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Templates par defaut ---------------------------------------------
|
||||||
|
|
||||||
|
/** Template generique PJ — utilise pour Homebrew, backfill, et fallback. */
|
||||||
|
private static List<TemplateField> genericCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Personnalite"),
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Template generique PNJ — focus besoins MJ. */
|
||||||
|
private static List<TemplateField> genericNpcTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Apparence"),
|
||||||
|
TemplateField.text("Motivation"),
|
||||||
|
TemplateField.text("Faction"),
|
||||||
|
TemplateField.text("Notes MJ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> nimbleCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.number("Blessures graves max"),
|
||||||
|
TemplateField.text("Capacites de classe"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Objectifs personnels"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<TemplateField> dndCharacterTemplate() {
|
||||||
|
return List.of(
|
||||||
|
TemplateField.text("Classe"),
|
||||||
|
TemplateField.text("Race"),
|
||||||
|
TemplateField.text("Historique"),
|
||||||
|
TemplateField.text("Alignement"),
|
||||||
|
TemplateField.number("Niveau"),
|
||||||
|
TemplateField.number("PV max"),
|
||||||
|
TemplateField.number("CA"),
|
||||||
|
TemplateField.number("FOR"),
|
||||||
|
TemplateField.number("DEX"),
|
||||||
|
TemplateField.number("CON"),
|
||||||
|
TemplateField.number("INT"),
|
||||||
|
TemplateField.number("SAG"),
|
||||||
|
TemplateField.number("CHA"),
|
||||||
|
TemplateField.text("Competences"),
|
||||||
|
TemplateField.text("Equipement"),
|
||||||
|
TemplateField.text("Sorts"),
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.image("Galerie", ImageLayout.GALLERY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static final String NIMBLE_RULES = """
|
private static final String NIMBLE_RULES = """
|
||||||
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
Système Nimble — résolution rapide, narration fluide, peu de tableaux. Agnostique (aucun univers imposé).
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package com.loremind.infrastructure.persistence.converter;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,11 +9,18 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour les fiches de personnages (PJ) d'une campagne.
|
* Entité JPA pour les fiches de personnages (PJ).
|
||||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte :
|
* <p>
|
||||||
* on reste dans le Campaign Context, mais l'agrégat Character est autonome).
|
* Refonte 2026-04-30 : ancien champ markdownContent migré vers {@code values["Notes"]}
|
||||||
|
* via Hibernate au demarrage. Hibernate ddl-auto=update ajoute les nouvelles colonnes
|
||||||
|
* sans dropper {@code markdown_content} — les donnees existantes sont conservees mais
|
||||||
|
* plus mappees au domaine. Migration manuelle prevue (script SQL one-shot) si le
|
||||||
|
* deploiement passe en bluegreen.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "characters")
|
@Table(name = "characters")
|
||||||
@@ -28,8 +37,21 @@ public class CharacterJpaEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
@Column(name = "portrait_image_id")
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
/** Valeurs TEXT/NUMBER serialisees JSON. */
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
/** Valeurs IMAGE serialisees JSON. */
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
@@ -47,6 +69,8 @@ public class CharacterJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,6 +9,8 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
* Entité JPA pour la persistance des GameSystems (systèmes de JDR).
|
||||||
@@ -32,6 +36,16 @@ public class GameSystemJpaEntity {
|
|||||||
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
@Column(name = "rules_markdown", columnDefinition = "TEXT")
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
|
||||||
|
/** Template PJ serialise en JSON via {@link TemplateFieldListJsonConverter}. */
|
||||||
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
|
@Column(name = "character_template", columnDefinition = "TEXT")
|
||||||
|
private List<TemplateField> characterTemplate;
|
||||||
|
|
||||||
|
/** Template PNJ serialise en JSON. */
|
||||||
|
@Convert(converter = TemplateFieldListJsonConverter.class)
|
||||||
|
@Column(name = "npc_template", columnDefinition = "TEXT")
|
||||||
|
private List<TemplateField> npcTemplate;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private String author;
|
private String author;
|
||||||
|
|
||||||
@@ -48,6 +62,8 @@ public class GameSystemJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (characterTemplate == null) characterTemplate = new ArrayList<>();
|
||||||
|
if (npcTemplate == null) npcTemplate = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||||
|
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -7,10 +9,13 @@ import lombok.Data;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entité JPA pour les fiches de PNJ d'une campagne.
|
* Entité JPA pour les fiches de PNJ. Memes regles que CharacterJpaEntity
|
||||||
* Pas de FK physique vers campaigns (weak reference cross-agrégat intra-contexte).
|
* (cf. note de refonte 2026-04-30 sur la migration markdownContent).
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "npcs")
|
@Table(name = "npcs")
|
||||||
@@ -27,8 +32,19 @@ public class NpcJpaEntity {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Column(name = "markdown_content", columnDefinition = "TEXT")
|
@Column(name = "portrait_image_id")
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
|
||||||
|
@Column(name = "header_image_id")
|
||||||
|
private String headerImageId;
|
||||||
|
|
||||||
|
@Convert(converter = StringMapJsonConverter.class)
|
||||||
|
@Column(name = "field_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, String> values;
|
||||||
|
|
||||||
|
@Convert(converter = StringListMapJsonConverter.class)
|
||||||
|
@Column(name = "image_values", columnDefinition = "TEXT")
|
||||||
|
private Map<String, List<String>> imageValues;
|
||||||
|
|
||||||
@Column(name = "campaign_id", nullable = false)
|
@Column(name = "campaign_id", nullable = false)
|
||||||
private Long campaignId;
|
private Long campaignId;
|
||||||
@@ -46,6 +62,8 @@ public class NpcJpaEntity {
|
|||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = LocalDateTime.now();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = LocalDateTime.now();
|
||||||
|
if (values == null) values = new HashMap<>();
|
||||||
|
if (imageValues == null) imageValues = new HashMap<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.loremind.infrastructure.persistence.entity;
|
package com.loremind.infrastructure.persistence.entity;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
import com.loremind.infrastructure.persistence.converter.TemplateFieldListJsonConverter;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.CharacterJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.CharacterJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -52,7 +53,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.markdownContent(e.getMarkdownContent())
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -65,7 +69,10 @@ public class PostgresCharacterRepository implements CharacterRepository {
|
|||||||
return CharacterJpaEntity.builder()
|
return CharacterJpaEntity.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.name(c.getName())
|
.name(c.getName())
|
||||||
.markdownContent(c.getMarkdownContent())
|
.portraitImageId(c.getPortraitImageId())
|
||||||
|
.headerImageId(c.getHeaderImageId())
|
||||||
|
.values(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(c.getCampaignId()))
|
.campaignId(Long.parseLong(c.getCampaignId()))
|
||||||
.order(c.getOrder())
|
.order(c.getOrder())
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.description(e.getDescription())
|
.description(e.getDescription())
|
||||||
.rulesMarkdown(e.getRulesMarkdown())
|
.rulesMarkdown(e.getRulesMarkdown())
|
||||||
|
.characterTemplate(e.getCharacterTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(e.getCharacterTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
|
.npcTemplate(e.getNpcTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(e.getNpcTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(e.getAuthor())
|
.author(e.getAuthor())
|
||||||
.isPublic(e.isPublic())
|
.isPublic(e.isPublic())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -75,6 +81,12 @@ public class PostgresGameSystemRepository implements GameSystemRepository {
|
|||||||
.name(g.getName())
|
.name(g.getName())
|
||||||
.description(g.getDescription())
|
.description(g.getDescription())
|
||||||
.rulesMarkdown(g.getRulesMarkdown())
|
.rulesMarkdown(g.getRulesMarkdown())
|
||||||
|
.characterTemplate(g.getCharacterTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(g.getCharacterTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
|
.npcTemplate(g.getNpcTemplate() != null
|
||||||
|
? new java.util.ArrayList<>(g.getNpcTemplate())
|
||||||
|
: new java.util.ArrayList<>())
|
||||||
.author(g.getAuthor())
|
.author(g.getAuthor())
|
||||||
.isPublic(g.isPublic())
|
.isPublic(g.isPublic())
|
||||||
.createdAt(g.getCreatedAt())
|
.createdAt(g.getCreatedAt())
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.loremind.infrastructure.persistence.entity.NpcJpaEntity;
|
|||||||
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
import com.loremind.infrastructure.persistence.jpa.NpcJpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@@ -52,7 +53,10 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
return Npc.builder()
|
return Npc.builder()
|
||||||
.id(e.getId().toString())
|
.id(e.getId().toString())
|
||||||
.name(e.getName())
|
.name(e.getName())
|
||||||
.markdownContent(e.getMarkdownContent())
|
.portraitImageId(e.getPortraitImageId())
|
||||||
|
.headerImageId(e.getHeaderImageId())
|
||||||
|
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(e.getCampaignId().toString())
|
.campaignId(e.getCampaignId().toString())
|
||||||
.order(e.getOrder())
|
.order(e.getOrder())
|
||||||
.createdAt(e.getCreatedAt())
|
.createdAt(e.getCreatedAt())
|
||||||
@@ -65,7 +69,10 @@ public class PostgresNpcRepository implements NpcRepository {
|
|||||||
return NpcJpaEntity.builder()
|
return NpcJpaEntity.builder()
|
||||||
.id(id)
|
.id(id)
|
||||||
.name(n.getName())
|
.name(n.getName())
|
||||||
.markdownContent(n.getMarkdownContent())
|
.portraitImageId(n.getPortraitImageId())
|
||||||
|
.headerImageId(n.getHeaderImageId())
|
||||||
|
.values(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(Long.parseLong(n.getCampaignId()))
|
.campaignId(Long.parseLong(n.getCampaignId()))
|
||||||
.order(n.getOrder())
|
.order(n.getOrder())
|
||||||
.createdAt(n.getCreatedAt())
|
.createdAt(n.getCreatedAt())
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import com.loremind.application.licensing.LicenseService;
|
|||||||
import com.loremind.domain.licensing.LicenseSnapshot;
|
import com.loremind.domain.licensing.LicenseSnapshot;
|
||||||
import com.loremind.domain.licensing.LicenseStatus;
|
import com.loremind.domain.licensing.LicenseStatus;
|
||||||
import com.loremind.domain.licensing.RegistryCredentials;
|
import com.loremind.domain.licensing.RegistryCredentials;
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpEntity;
|
import org.springframework.http.HttpEntity;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -29,187 +29,121 @@ import java.util.HashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
* Detection des mises a jour disponibles + declenchement via Watchtower.
|
||||||
|
* <p>
|
||||||
|
* <b>Strategie</b> : comparaison de versions semver, pas de digests.
|
||||||
|
* <ul>
|
||||||
|
* <li>La version courante de l'app est lue depuis {@link BuildProperties}
|
||||||
|
* (genere par spring-boot-maven-plugin dans META-INF/build-info.properties).</li>
|
||||||
|
* <li>Pour chaque image suivie, on interroge le registry sur
|
||||||
|
* {@code /v2/<image>/tags/list}, on extrait les tags semver, on prend le max.</li>
|
||||||
|
* <li>Si max > version courante => UPDATE_AVAILABLE.</li>
|
||||||
|
* <li>Si max == version courante => UP_TO_DATE.</li>
|
||||||
|
* <li>Si registry injoignable ou aucun tag valide => UNKNOWN.</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* Strategie :
|
* <b>Pourquoi pas les digests ?</b> Le bug historique etait : le baseline-digest
|
||||||
* - Au demarrage, on interroge le registry pour le digest courant de chaque
|
* pose au @PostConstruct supposait que le pull venait d'avoir lieu (vrai apres
|
||||||
* image suivie ({@code update-check.images}). On stocke ces digests comme
|
* `docker compose pull && up -d`, faux apres un simple restart de daemon ou un
|
||||||
* "baseline" (= ce que le conteneur en cours d'execution est cense faire
|
* OOM). La version semver lue depuis le binaire est <b>fiable par construction</b> :
|
||||||
* tourner, puisque le `docker compose pull` precede toujours `up -d`).
|
* c'est ce que le code source declare faire tourner.
|
||||||
* - Si l'init echoue (reseau Docker pas encore pret, registry transitoirement
|
|
||||||
* indisponible), un thread daemon de retry avec backoff complete les
|
|
||||||
* baselines manquantes en arriere-plan.
|
|
||||||
* - {@link #check()} re-interroge le registry et compare. Si un digest a
|
|
||||||
* change, une mise a jour est disponible. Si la baseline manque (echec
|
|
||||||
* de tous les retries), retourne {@link ImageStatusKind#UNKNOWN} pour
|
|
||||||
* cette image — JAMAIS d'alignement silencieux (eviterait des MAJ ratees).
|
|
||||||
* - {@link #apply()} POST sur /v1/update de Watchtower (qui doit etre lance
|
|
||||||
* avec WATCHTOWER_HTTP_API_UPDATE=true et le meme token).
|
|
||||||
*
|
|
||||||
* Apres un apply reussi, Watchtower redemarre core => ce service est
|
|
||||||
* re-instancie => baseline re-aligne sur le registry => check renvoie
|
|
||||||
* "pas de MAJ" (etat coherent).
|
|
||||||
*
|
|
||||||
* La feature est <b>desactivee silencieusement</b> si {@code WATCHTOWER_TOKEN}
|
|
||||||
* n'est pas defini : check/apply renvoient des reponses neutres et l'UI
|
|
||||||
* masque le badge / bouton.
|
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class UpdateCheckService {
|
public class UpdateCheckService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
private static final Logger log = LoggerFactory.getLogger(UpdateCheckService.class);
|
||||||
|
|
||||||
private static final List<MediaType> MANIFEST_ACCEPT = List.of(
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.docker.distribution.manifest.list.v2+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.manifest.v1+json"),
|
|
||||||
MediaType.parseMediaType("application/vnd.oci.image.index.v1+json")
|
|
||||||
);
|
|
||||||
|
|
||||||
private final RestTemplate http;
|
private final RestTemplate http;
|
||||||
private final String registry;
|
private final String registry;
|
||||||
private final List<String> images;
|
private final List<String> images;
|
||||||
private final String tag;
|
|
||||||
private final String watchtowerUrl;
|
private final String watchtowerUrl;
|
||||||
private final String watchtowerToken;
|
private final String watchtowerToken;
|
||||||
private final List<String> betaImages;
|
private final List<String> betaImages;
|
||||||
private final String betaTag;
|
|
||||||
private final LicenseService licenseService;
|
private final LicenseService licenseService;
|
||||||
|
/** Version semver courante du binaire (ex: "0.8.0"). Source de verite. */
|
||||||
private final Map<String, String> baselineDigests = new ConcurrentHashMap<>();
|
private final String currentVersion;
|
||||||
|
|
||||||
public UpdateCheckService(
|
public UpdateCheckService(
|
||||||
RestTemplateBuilder builder,
|
RestTemplateBuilder builder,
|
||||||
@Value("${update-check.registry:}") String registry,
|
@Value("${update-check.registry:}") String registry,
|
||||||
@Value("${update-check.images:}") String imagesCsv,
|
@Value("${update-check.images:}") String imagesCsv,
|
||||||
@Value("${update-check.tag:latest}") String tag,
|
|
||||||
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
@Value("${update-check.watchtower-url:http://watchtower:8080}") String watchtowerUrl,
|
||||||
@Value("${update-check.watchtower-token:}") String watchtowerToken,
|
@Value("${update-check.watchtower-token:}") String watchtowerToken,
|
||||||
@Value("${licensing.beta.images:}") String betaImagesCsv,
|
@Value("${licensing.beta.images:}") String betaImagesCsv,
|
||||||
@Value("${licensing.beta.tag:latest}") String betaTag,
|
LicenseService licenseService,
|
||||||
LicenseService licenseService) {
|
@Nullable BuildProperties buildProperties) {
|
||||||
this.http = builder
|
this.http = builder
|
||||||
.setConnectTimeout(Duration.ofSeconds(5))
|
.setConnectTimeout(Duration.ofSeconds(5))
|
||||||
.setReadTimeout(Duration.ofSeconds(15))
|
.setReadTimeout(Duration.ofSeconds(15))
|
||||||
.build();
|
.build();
|
||||||
this.registry = normalizeRegistry(registry);
|
this.registry = normalizeRegistry(registry);
|
||||||
this.images = parseImages(imagesCsv);
|
this.images = parseImages(imagesCsv);
|
||||||
this.tag = tag;
|
|
||||||
this.watchtowerUrl = watchtowerUrl;
|
this.watchtowerUrl = watchtowerUrl;
|
||||||
this.watchtowerToken = watchtowerToken;
|
this.watchtowerToken = watchtowerToken;
|
||||||
this.betaImages = parseImages(betaImagesCsv);
|
this.betaImages = parseImages(betaImagesCsv);
|
||||||
this.betaTag = betaTag;
|
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
}
|
this.currentVersion = buildProperties != null ? buildProperties.getVersion() : null;
|
||||||
|
log.info("Update check init - registry={} images={} currentVersion={}",
|
||||||
/** Backoff progressif (ms) pour retry de baseline en cas d'echec initial. */
|
this.registry, this.images, this.currentVersion);
|
||||||
private static final long[] BASELINE_RETRY_BACKOFFS_MS = {2_000, 5_000, 15_000, 30_000, 60_000};
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void initBaseline() {
|
|
||||||
if (!isEnabled()) {
|
|
||||||
log.info("Update check disabled (WATCHTOWER_TOKEN not set)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
log.info("Update check enabled - registry={} images={} tag={}", registry, images, tag);
|
|
||||||
boolean complete = tryBaselineMissing();
|
|
||||||
if (!complete) {
|
|
||||||
startBaselineRetryThread();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tente de poser la baseline pour les images qui ne l'ont pas encore.
|
|
||||||
* @return true si TOUTES les images ont leur baseline apres cet essai.
|
|
||||||
*/
|
|
||||||
private boolean tryBaselineMissing() {
|
|
||||||
for (String image : images) {
|
|
||||||
if (baselineDigests.containsKey(image)) continue;
|
|
||||||
try {
|
|
||||||
String digest = fetchRemoteDigest(image);
|
|
||||||
if (digest != null) {
|
|
||||||
baselineDigests.put(image, digest);
|
|
||||||
log.debug("Baseline digest for {} = {}", image, digest);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Cannot baseline digest for {}: {}", image, e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return baselineDigests.size() == images.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lance un thread daemon qui retente de poser les baselines manquantes
|
|
||||||
* avec backoff. Le thread s'arrete des que toutes les baselines sont
|
|
||||||
* posees, ou apres epuisement des backoffs (et alors {@link #check()}
|
|
||||||
* retournera UNKNOWN pour ces images jusqu'au prochain redemarrage).
|
|
||||||
*/
|
|
||||||
private void startBaselineRetryThread() {
|
|
||||||
Thread t = new Thread(() -> {
|
|
||||||
for (long backoff : BASELINE_RETRY_BACKOFFS_MS) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(backoff);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (tryBaselineMissing()) {
|
|
||||||
log.info("Baseline complete after retry");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.warn("Baseline incomplete after all retries; check() will return UNKNOWN for missing images");
|
|
||||||
}, "update-baseline-retry");
|
|
||||||
t.setDaemon(true);
|
|
||||||
t.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
return watchtowerToken != null && !watchtowerToken.isBlank() && !images.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return version courante exposee aux endpoints (ex: pour affichage UI).
|
||||||
|
* {@code null} si build-info.properties absent (dev en IDE sans build Maven).
|
||||||
|
*/
|
||||||
|
public String getCurrentVersion() {
|
||||||
|
return currentVersion;
|
||||||
|
}
|
||||||
|
|
||||||
public UpdateStatus check() {
|
public UpdateStatus check() {
|
||||||
if (!isEnabled()) {
|
if (!isEnabled()) {
|
||||||
return new UpdateStatus(false, false, false, List.of(), Instant.now());
|
return new UpdateStatus(false, false, false, null, List.of(), Instant.now());
|
||||||
}
|
}
|
||||||
|
if (currentVersion == null) {
|
||||||
|
log.warn("Update check : currentVersion absente (build-info manquant). Tous UNKNOWN.");
|
||||||
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
|
for (String image : images) {
|
||||||
|
statuses.add(new ImageStatus(image, null, null, ImageStatusKind.UNKNOWN));
|
||||||
|
}
|
||||||
|
return new UpdateStatus(true, false, true, null, statuses, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
List<ImageStatus> statuses = new ArrayList<>();
|
List<ImageStatus> statuses = new ArrayList<>();
|
||||||
boolean anyUpdate = false;
|
boolean anyUpdate = false;
|
||||||
boolean anyUnknown = false;
|
boolean anyUnknown = false;
|
||||||
for (String image : images) {
|
for (String image : images) {
|
||||||
String baseline = baselineDigests.get(image);
|
String latest = null;
|
||||||
String remote = null;
|
|
||||||
try {
|
try {
|
||||||
remote = fetchRemoteDigest(image);
|
latest = fetchLatestSemverTag(registry, image, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Check failed for {}: {}", image, e.getMessage());
|
log.warn("Tags fetch failed for {}: {}", image, e.getMessage());
|
||||||
}
|
}
|
||||||
// PAS d'alignement lazy si baseline absente : ce serait un faux negatif
|
|
||||||
// silencieux. On reporte UNKNOWN pour que l'UI le signale.
|
|
||||||
ImageStatusKind kind;
|
ImageStatusKind kind;
|
||||||
if (baseline == null || remote == null) {
|
if (latest == null) {
|
||||||
kind = ImageStatusKind.UNKNOWN;
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
anyUnknown = true;
|
anyUnknown = true;
|
||||||
} else if (baseline.equals(remote)) {
|
|
||||||
kind = ImageStatusKind.UP_TO_DATE;
|
|
||||||
} else {
|
} else {
|
||||||
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
int cmp = compareSemver(currentVersion, latest);
|
||||||
anyUpdate = true;
|
if (cmp >= 0) {
|
||||||
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
|
} else {
|
||||||
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
statuses.add(new ImageStatus(image, baseline, remote, kind));
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
}
|
}
|
||||||
return new UpdateStatus(true, anyUpdate, anyUnknown, statuses, Instant.now());
|
return new UpdateStatus(true, anyUpdate, anyUnknown, currentVersion, statuses, Instant.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifie l'etat du canal beta (images privees GHCR).
|
* Verifie l'etat du canal beta (images privees GHCR) avec auth basique.
|
||||||
* Necessite licence valide/grace + toggle beta ON.
|
|
||||||
* Authentification basic auth via le PAT distribue par le relais.
|
|
||||||
*
|
|
||||||
* @return statut beta (peut etre {@link BetaStatus#disabled()} si licence absente,
|
|
||||||
* beta off ou licence expiree)
|
|
||||||
*/
|
*/
|
||||||
public BetaStatus checkBeta() {
|
public BetaStatus checkBeta() {
|
||||||
if (!licenseService.isLicensingEnabled()) {
|
if (!licenseService.isLicensingEnabled()) {
|
||||||
@@ -239,48 +173,156 @@ public class UpdateCheckService {
|
|||||||
boolean anyUpdate = false;
|
boolean anyUpdate = false;
|
||||||
boolean anyUnknown = false;
|
boolean anyUnknown = false;
|
||||||
for (String image : betaImages) {
|
for (String image : betaImages) {
|
||||||
String remote = null;
|
String latest = null;
|
||||||
try {
|
try {
|
||||||
remote = fetchRemoteDigestAuth(betaRegistry, image, betaTag, basicAuth);
|
latest = fetchLatestSemverTag(betaRegistry, image, basicAuth);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Beta check failed for {}: {}", image, e.getMessage());
|
log.warn("Beta tags fetch failed for {}: {}", image, e.getMessage());
|
||||||
}
|
}
|
||||||
// Pas de baseline pour la beta : on ne peut pas dire "a jour" car on
|
ImageStatusKind kind;
|
||||||
// ne sait pas quelle version le user fait tourner. On expose juste le
|
if (latest == null) {
|
||||||
// digest remote ; l'UI affichera "version disponible : <tag>" sans
|
kind = ImageStatusKind.UNKNOWN;
|
||||||
// comparaison locale tant qu'il n'y a pas un mecanisme de baseline.
|
anyUnknown = true;
|
||||||
ImageStatusKind kind = (remote == null) ? ImageStatusKind.UNKNOWN : ImageStatusKind.UPDATE_AVAILABLE;
|
} else if (currentVersion != null && compareSemver(currentVersion, latest) >= 0) {
|
||||||
if (kind == ImageStatusKind.UNKNOWN) anyUnknown = true;
|
kind = ImageStatusKind.UP_TO_DATE;
|
||||||
else anyUpdate = true;
|
} else {
|
||||||
statuses.add(new ImageStatus(image, null, remote, kind));
|
kind = ImageStatusKind.UPDATE_AVAILABLE;
|
||||||
|
anyUpdate = true;
|
||||||
|
}
|
||||||
|
statuses.add(new ImageStatus(image, currentVersion, latest, kind));
|
||||||
}
|
}
|
||||||
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
return new BetaStatus(true, anyUpdate, anyUnknown, statuses, Instant.now(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String fetchRemoteDigestAuth(String registryUrl, String image, String tagName, String authHeader) {
|
public void apply() {
|
||||||
String url = registryUrl + "/v2/" + image + "/manifests/" + tagName;
|
if (!isEnabled()) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
||||||
headers.setAccept(MANIFEST_ACCEPT);
|
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
|
||||||
try {
|
|
||||||
return digestCall(url, headers);
|
|
||||||
} catch (HttpClientErrorException.Unauthorized e) {
|
|
||||||
// GHCR peut exiger d'echanger basic auth contre un bearer token via
|
|
||||||
// le challenge WWW-Authenticate. On reuse la logique existante en
|
|
||||||
// ajoutant l'auth header a la requete /token.
|
|
||||||
String www = e.getResponseHeaders() == null ? null
|
|
||||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
|
||||||
String token = obtainBearerTokenWithAuth(www, authHeader);
|
|
||||||
if (token == null) return null;
|
|
||||||
HttpHeaders bearerHeaders = new HttpHeaders();
|
|
||||||
bearerHeaders.setAccept(MANIFEST_ACCEPT);
|
|
||||||
bearerHeaders.setBearerAuth(token);
|
|
||||||
return digestCall(url, bearerHeaders);
|
|
||||||
}
|
}
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setBearerAuth(watchtowerToken);
|
||||||
|
http.exchange(
|
||||||
|
watchtowerUrl + "/v1/update",
|
||||||
|
HttpMethod.POST,
|
||||||
|
new HttpEntity<>(headers),
|
||||||
|
Void.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Registry HTTP API v2 - tags listing + auth bearer
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interroge le registry pour la liste des tags d'une image, parse les
|
||||||
|
* versions semver et retourne la plus elevee. {@code null} si echec
|
||||||
|
* ou aucun tag valide.
|
||||||
|
*
|
||||||
|
* @param registryUrl URL normalisee (ex: "https://ghcr.io")
|
||||||
|
* @param image nom de l'image (ex: "igmlcreation/loremind-core")
|
||||||
|
* @param authHeader optionnel - "Basic ..." pour les registries prives
|
||||||
|
*/
|
||||||
|
private String fetchLatestSemverTag(String registryUrl, String image, @Nullable String authHeader) {
|
||||||
|
String url = registryUrl + "/v2/" + image + "/tags/list";
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||||
|
if (authHeader != null) {
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
||||||
|
}
|
||||||
|
TagsListResponse body;
|
||||||
|
try {
|
||||||
|
body = tagsCall(url, headers);
|
||||||
|
} catch (HttpClientErrorException.Unauthorized e) {
|
||||||
|
String www = e.getResponseHeaders() == null ? null
|
||||||
|
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
||||||
|
String token = obtainBearerToken(www, authHeader);
|
||||||
|
if (token == null) {
|
||||||
|
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||||
|
bearerHeaders.setAccept(List.of(MediaType.APPLICATION_JSON));
|
||||||
|
bearerHeaders.setBearerAuth(token);
|
||||||
|
body = tagsCall(url, bearerHeaders);
|
||||||
|
}
|
||||||
|
if (body == null || body.tags == null || body.tags.isEmpty()) return null;
|
||||||
|
return findMaxSemver(body.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TagsListResponse tagsCall(String url, HttpHeaders headers) {
|
||||||
|
ResponseEntity<TagsListResponse> resp = http.exchange(
|
||||||
|
url, HttpMethod.GET, new HttpEntity<>(headers), TagsListResponse.class);
|
||||||
|
return resp.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parcourt la liste des tags, garde uniquement ceux qui parsent en semver
|
||||||
|
* (1 a 3 chiffres separes par des points, optionnel prefix "v"), retourne le max.
|
||||||
|
* Pre-release / build metadata sont strippes pour la comparaison.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
static String findMaxSemver(List<String> tags) {
|
||||||
|
String maxTag = null;
|
||||||
|
int[] maxParts = null;
|
||||||
|
for (String t : tags) {
|
||||||
|
if (t == null || t.isBlank()) continue;
|
||||||
|
int[] parts = parseSemver(t);
|
||||||
|
if (parts == null) continue;
|
||||||
|
if (maxParts == null || compareParts(parts, maxParts) > 0) {
|
||||||
|
maxParts = parts;
|
||||||
|
maxTag = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return [major, minor, patch] ou null si non parsable. */
|
||||||
|
@Nullable
|
||||||
|
static int[] parseSemver(String tag) {
|
||||||
|
if (tag == null) return null;
|
||||||
|
String s = tag.trim();
|
||||||
|
if (s.isEmpty()) return null;
|
||||||
|
if (s.startsWith("v") || s.startsWith("V")) s = s.substring(1);
|
||||||
|
int dashIdx = s.indexOf('-');
|
||||||
|
if (dashIdx > 0) s = s.substring(0, dashIdx);
|
||||||
|
int plusIdx = s.indexOf('+');
|
||||||
|
if (plusIdx > 0) s = s.substring(0, plusIdx);
|
||||||
|
String[] parts = s.split("\\.");
|
||||||
|
if (parts.length < 1 || parts.length > 3) return null;
|
||||||
|
int[] result = new int[]{0, 0, 0};
|
||||||
|
for (int i = 0; i < parts.length; i++) {
|
||||||
|
try {
|
||||||
|
int v = Integer.parseInt(parts[i]);
|
||||||
|
if (v < 0) return null;
|
||||||
|
result[i] = v;
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare deux versions semver brutes (sans prefix). Negatif si a < b. */
|
||||||
|
static int compareSemver(String a, String b) {
|
||||||
|
int[] aParts = parseSemver(a);
|
||||||
|
int[] bParts = parseSemver(b);
|
||||||
|
if (aParts == null || bParts == null) return 0;
|
||||||
|
return compareParts(aParts, bParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int compareParts(int[] a, int[] b) {
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
int diff = Integer.compare(a[i], b[i]);
|
||||||
|
if (diff != 0) return diff;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suit le challenge {@code WWW-Authenticate: Bearer realm="..."} pour obtenir
|
||||||
|
* un token. Si {@code basicAuth} est fourni, l'utilise pour l'echange (cas
|
||||||
|
* registry prive). Sinon anonyme (cas registry public).
|
||||||
|
*/
|
||||||
@SuppressWarnings("rawtypes")
|
@SuppressWarnings("rawtypes")
|
||||||
private String obtainBearerTokenWithAuth(@Nullable String wwwAuth, String authHeader) {
|
private String obtainBearerToken(@Nullable String wwwAuth, @Nullable String basicAuth) {
|
||||||
if (wwwAuth == null) return null;
|
if (wwwAuth == null) return null;
|
||||||
String prefix = "Bearer ";
|
String prefix = "Bearer ";
|
||||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
||||||
@@ -301,7 +343,9 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
|
if (basicAuth != null) {
|
||||||
|
headers.set(HttpHeaders.AUTHORIZATION, basicAuth);
|
||||||
|
}
|
||||||
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
|
ResponseEntity<Map> resp = http.exchange(url.toString(), HttpMethod.GET,
|
||||||
new HttpEntity<>(headers), Map.class);
|
new HttpEntity<>(headers), Map.class);
|
||||||
Map<?, ?> body = resp.getBody();
|
Map<?, ?> body = resp.getBody();
|
||||||
@@ -309,97 +353,6 @@ public class UpdateCheckService {
|
|||||||
Object t = body.get("token");
|
Object t = body.get("token");
|
||||||
if (t == null) t = body.get("access_token");
|
if (t == null) t = body.get("access_token");
|
||||||
return t == null ? null : t.toString();
|
return t == null ? null : t.toString();
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Beta bearer token request failed: {}", e.getMessage());
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void apply() {
|
|
||||||
if (!isEnabled()) {
|
|
||||||
throw new IllegalStateException("Update apply not configured (WATCHTOWER_TOKEN missing)");
|
|
||||||
}
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setBearerAuth(watchtowerToken);
|
|
||||||
// Watchtower /v1/update declenche un scan+update immediat de tous les
|
|
||||||
// conteneurs labellises. La reponse est synchrone et peut prendre
|
|
||||||
// plusieurs secondes; en cas de redemarrage de core, le client
|
|
||||||
// recevra une connexion coupee — c'est attendu, l'UI le gere.
|
|
||||||
http.exchange(
|
|
||||||
watchtowerUrl + "/v1/update",
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(headers),
|
|
||||||
Void.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
// Registry HTTP API v2
|
|
||||||
// -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
private String fetchRemoteDigest(String image) {
|
|
||||||
String url = registry + "/v2/" + image + "/manifests/" + tag;
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setAccept(MANIFEST_ACCEPT);
|
|
||||||
try {
|
|
||||||
return digestCall(url, headers);
|
|
||||||
} catch (HttpClientErrorException.Unauthorized e) {
|
|
||||||
String www = e.getResponseHeaders() == null ? null
|
|
||||||
: e.getResponseHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE);
|
|
||||||
String token = obtainBearerToken(www);
|
|
||||||
if (token == null) {
|
|
||||||
log.warn("Cannot obtain bearer token for {} (registry response: {})", image, www);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
headers.setBearerAuth(token);
|
|
||||||
return digestCall(url, headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String digestCall(String url, HttpHeaders headers) {
|
|
||||||
ResponseEntity<Void> resp = http.exchange(
|
|
||||||
url, HttpMethod.HEAD, new HttpEntity<>(headers), Void.class);
|
|
||||||
return resp.getHeaders().getFirst("Docker-Content-Digest");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suit le challenge {@code WWW-Authenticate: Bearer realm="...",service="...",scope="..."}
|
|
||||||
* pour obtenir un jeton (anonyme — suffisant pour les images publiques).
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("rawtypes")
|
|
||||||
private String obtainBearerToken(String wwwAuth) {
|
|
||||||
if (wwwAuth == null) return null;
|
|
||||||
String prefix = "Bearer ";
|
|
||||||
if (!wwwAuth.regionMatches(true, 0, prefix, 0, prefix.length())) return null;
|
|
||||||
Map<String, String> params = parseAuthParams(wwwAuth.substring(prefix.length()));
|
|
||||||
String realm = params.get("realm");
|
|
||||||
if (realm == null) return null;
|
|
||||||
StringBuilder url = new StringBuilder(realm);
|
|
||||||
boolean hasQuery = realm.contains("?");
|
|
||||||
for (String key : new String[]{"service", "scope"}) {
|
|
||||||
String v = params.get(key);
|
|
||||||
if (v != null) {
|
|
||||||
// URLEncoder fait du "form encoding" qui transforme `:` et `/`
|
|
||||||
// en %3A et %2F. La plupart des registries (Docker Hub, Gitea)
|
|
||||||
// acceptent les deux, mais GHCR est strict et rejette le scope
|
|
||||||
// encode (403 DENIED). On preserve donc `:` et `/` dans la
|
|
||||||
// valeur, conformement a ce que GHCR attend
|
|
||||||
// (et que docker pull lui-meme envoie).
|
|
||||||
String encoded = URLEncoder.encode(v, StandardCharsets.UTF_8)
|
|
||||||
.replace("%3A", ":")
|
|
||||||
.replace("%2F", "/");
|
|
||||||
url.append(hasQuery ? '&' : '?')
|
|
||||||
.append(key).append('=')
|
|
||||||
.append(encoded);
|
|
||||||
hasQuery = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ResponseEntity<Map> resp = http.getForEntity(url.toString(), Map.class);
|
|
||||||
Map<?, ?> body = resp.getBody();
|
|
||||||
if (body == null) return null;
|
|
||||||
Object t = body.get("token");
|
|
||||||
if (t == null) t = body.get("access_token");
|
|
||||||
return t == null ? null : t.toString();
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Bearer token request failed: {}", e.getMessage());
|
log.warn("Bearer token request failed: {}", e.getMessage());
|
||||||
return null;
|
return null;
|
||||||
@@ -455,37 +408,36 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Records de retour (sortis sous forme JSON par Jackson)
|
// Records / DTO
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
|
||||||
* Etat tri-state d'une image vis-a-vis du registry.
|
|
||||||
* <ul>
|
|
||||||
* <li>{@link #UP_TO_DATE} : digest local == digest remote.</li>
|
|
||||||
* <li>{@link #UPDATE_AVAILABLE} : digests differents, MAJ disponible.</li>
|
|
||||||
* <li>{@link #UNKNOWN} : impossible de comparer (baseline ou remote manquant).
|
|
||||||
* L'UI doit afficher un avertissement plutot que de declarer "a jour".</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
public enum ImageStatusKind { UP_TO_DATE, UPDATE_AVAILABLE, UNKNOWN }
|
||||||
|
|
||||||
public record UpdateStatus(
|
public record UpdateStatus(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
boolean updateAvailable,
|
boolean updateAvailable,
|
||||||
boolean anyUnknown,
|
boolean anyUnknown,
|
||||||
|
String currentVersion,
|
||||||
List<ImageStatus> images,
|
List<ImageStatus> images,
|
||||||
Instant checkedAt) {}
|
Instant checkedAt) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Etat du canal beta.
|
* Statut par image. {@code localVersion} = version embarquee dans le binaire ;
|
||||||
* <ul>
|
* {@code remoteVersion} = plus haute version semver trouvee dans le registry.
|
||||||
* <li>{@code enabled} : true si le canal beta est actif et la licence valide.</li>
|
* {@code updateAvailable} est derive de {@code status} (back-compat front).
|
||||||
* <li>{@code disabledReason} : si {@code enabled=false}, raison technique
|
|
||||||
* (licensing-not-configured, license-none, license-expired, beta-toggle-off,
|
|
||||||
* no-beta-images-configured, relay-unavailable). Permet a l'UI d'afficher
|
|
||||||
* un message contextuel.</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
*/
|
||||||
|
public record ImageStatus(
|
||||||
|
String image,
|
||||||
|
String localVersion,
|
||||||
|
String remoteVersion,
|
||||||
|
ImageStatusKind status,
|
||||||
|
boolean updateAvailable) {
|
||||||
|
|
||||||
|
public ImageStatus(String image, String localVersion, String remoteVersion, ImageStatusKind status) {
|
||||||
|
this(image, localVersion, remoteVersion, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public record BetaStatus(
|
public record BetaStatus(
|
||||||
boolean enabled,
|
boolean enabled,
|
||||||
boolean updateAvailable,
|
boolean updateAvailable,
|
||||||
@@ -499,20 +451,9 @@ public class UpdateCheckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** DTO pour deserialisation Jackson de /v2/.../tags/list. */
|
||||||
* Le champ {@code updateAvailable} est conserve pour la compatibilite
|
static class TagsListResponse {
|
||||||
* avec les anciens clients ; il est strictement derive de {@code status}
|
public String name;
|
||||||
* dans le constructeur compact.
|
public List<String> tags;
|
||||||
*/
|
|
||||||
public record ImageStatus(
|
|
||||||
String image,
|
|
||||||
String localDigest,
|
|
||||||
String remoteDigest,
|
|
||||||
ImageStatusKind status,
|
|
||||||
boolean updateAvailable) {
|
|
||||||
|
|
||||||
public ImageStatus(String image, String localDigest, String remoteDigest, ImageStatusKind status) {
|
|
||||||
this(image, localDigest, remoteDigest, status, status == ImageStatusKind.UPDATE_AVAILABLE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> createCharacter(@RequestBody CharacterDTO dto) {
|
||||||
Character created = characterService.createCharacter(
|
Character created = characterService.createCharacter(toData(dto, null));
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(created));
|
return ResponseEntity.ok(characterMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +45,7 @@ public class CharacterController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
public ResponseEntity<CharacterDTO> updateCharacter(@PathVariable String id, @RequestBody CharacterDTO dto) {
|
||||||
Character updated = characterService.updateCharacter(
|
Character updated = characterService.updateCharacter(id, toData(dto, dto.getOrder()));
|
||||||
id,
|
|
||||||
new CharacterService.CharacterData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
return ResponseEntity.ok(characterMapper.toDTO(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +54,16 @@ public class CharacterController {
|
|||||||
characterService.deleteCharacter(id);
|
characterService.deleteCharacter(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private CharacterService.CharacterData toData(CharacterDTO dto, Integer order) {
|
||||||
|
return new CharacterService.CharacterData(
|
||||||
|
dto.getName(),
|
||||||
|
dto.getPortraitImageId(),
|
||||||
|
dto.getHeaderImageId(),
|
||||||
|
dto.getValues(),
|
||||||
|
dto.getImageValues(),
|
||||||
|
dto.getCampaignId(),
|
||||||
|
order
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.gamesystemcontext.GameSystemService;
|
import com.loremind.application.gamesystemcontext.GameSystemService;
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
import com.loremind.infrastructure.web.mapper.GameSystemMapper;
|
||||||
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@@ -16,10 +21,14 @@ public class GameSystemController {
|
|||||||
|
|
||||||
private final GameSystemService gameSystemService;
|
private final GameSystemService gameSystemService;
|
||||||
private final GameSystemMapper gameSystemMapper;
|
private final GameSystemMapper gameSystemMapper;
|
||||||
|
private final TemplateFieldMapper templateFieldMapper;
|
||||||
|
|
||||||
public GameSystemController(GameSystemService gameSystemService, GameSystemMapper gameSystemMapper) {
|
public GameSystemController(GameSystemService gameSystemService,
|
||||||
|
GameSystemMapper gameSystemMapper,
|
||||||
|
TemplateFieldMapper templateFieldMapper) {
|
||||||
this.gameSystemService = gameSystemService;
|
this.gameSystemService = gameSystemService;
|
||||||
this.gameSystemMapper = gameSystemMapper;
|
this.gameSystemMapper = gameSystemMapper;
|
||||||
|
this.templateFieldMapper = templateFieldMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@@ -63,13 +72,28 @@ public class GameSystemController {
|
|||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mappe les violations d'invariants domaine (doublons de champs, etc.) en 400. */
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ResponseEntity<String> onIllegalArgument(IllegalArgumentException ex) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
private GameSystemService.GameSystemData toData(GameSystemDTO dto) {
|
||||||
return new GameSystemService.GameSystemData(
|
return new GameSystemService.GameSystemData(
|
||||||
dto.getName(),
|
dto.getName(),
|
||||||
dto.getDescription(),
|
dto.getDescription(),
|
||||||
dto.getRulesMarkdown(),
|
dto.getRulesMarkdown(),
|
||||||
|
toDomainFields(dto.getCharacterTemplate()),
|
||||||
|
toDomainFields(dto.getNpcTemplate()),
|
||||||
dto.getAuthor(),
|
dto.getAuthor(),
|
||||||
dto.isPublic()
|
dto.isPublic()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> toDomainFields(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) out.add(templateFieldMapper.toDomain(d));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,7 @@ public class NpcController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
public ResponseEntity<NpcDTO> createNpc(@RequestBody NpcDTO dto) {
|
||||||
Npc created = npcService.createNpc(
|
Npc created = npcService.createNpc(toData(dto, null));
|
||||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), null)
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(npcMapper.toDTO(created));
|
return ResponseEntity.ok(npcMapper.toDTO(created));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +45,7 @@ public class NpcController {
|
|||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
public ResponseEntity<NpcDTO> updateNpc(@PathVariable String id, @RequestBody NpcDTO dto) {
|
||||||
Npc updated = npcService.updateNpc(
|
Npc updated = npcService.updateNpc(id, toData(dto, dto.getOrder()));
|
||||||
id,
|
|
||||||
new NpcService.NpcData(dto.getName(), dto.getMarkdownContent(), dto.getCampaignId(), dto.getOrder())
|
|
||||||
);
|
|
||||||
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
return ResponseEntity.ok(npcMapper.toDTO(updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,4 +54,16 @@ public class NpcController {
|
|||||||
npcService.deleteNpc(id);
|
npcService.deleteNpc(id);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private NpcService.NpcData toData(NpcDTO dto, Integer order) {
|
||||||
|
return new NpcService.NpcData(
|
||||||
|
dto.getName(),
|
||||||
|
dto.getPortraitImageId(),
|
||||||
|
dto.getHeaderImageId(),
|
||||||
|
dto.getValues(),
|
||||||
|
dto.getImageValues(),
|
||||||
|
dto.getCampaignId(),
|
||||||
|
order
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.loremind.infrastructure.web.controller;
|
|||||||
|
|
||||||
import com.loremind.application.lorecontext.TemplateService;
|
import com.loremind.application.lorecontext.TemplateService;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateFieldMapper;
|
||||||
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
import com.loremind.infrastructure.web.mapper.TemplateMapper;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint public exposant la version courante du binaire.
|
||||||
|
* <p>
|
||||||
|
* Consomme par le frontend pour detecter qu'une mise a jour a ete deployee
|
||||||
|
* pendant qu'un onglet utilisateur etait deja ouvert : si la version polled
|
||||||
|
* differe de celle observee au boot, l'UI affiche un bandeau "rechargez".
|
||||||
|
* <p>
|
||||||
|
* Volontairement public (pas d'auth) : la version est deja exposee dans le
|
||||||
|
* JAR / l'image Docker, aucun risque de leak.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/version")
|
||||||
|
public class VersionController {
|
||||||
|
|
||||||
|
private final String version;
|
||||||
|
|
||||||
|
public VersionController(@Nullable BuildProperties buildProperties) {
|
||||||
|
this.version = buildProperties != null ? buildProperties.getVersion() : "dev";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Map<String, String> getVersion() {
|
||||||
|
return Map.of("version", version);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,25 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
* DTO pour les fiches de personnages (PJ) d'une campagne.
|
||||||
|
* Reflete la refonte template-based : champs universels hard-codes (name,
|
||||||
|
* portrait, header) + maps {@code values}/{@code imageValues} pour les
|
||||||
|
* champs templates pilotes par le GameSystem.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class CharacterDTO {
|
public class CharacterDTO {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ package com.loremind.infrastructure.web.dto.campaigncontext;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour les fiches de PNJ d'une campagne.
|
* DTO pour les fiches de PNJ d'une campagne. Meme structure que CharacterDTO.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class NpcDTO {
|
public class NpcDTO {
|
||||||
|
|
||||||
private String id;
|
private String id;
|
||||||
private String name;
|
private String name;
|
||||||
private String markdownContent;
|
private String portraitImageId;
|
||||||
|
private String headerImageId;
|
||||||
|
private Map<String, String> values = new HashMap<>();
|
||||||
|
private Map<String, List<String>> imageValues = new HashMap<>();
|
||||||
private String campaignId;
|
private String campaignId;
|
||||||
private int order;
|
private int order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
package com.loremind.infrastructure.web.dto.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO pour l'entité GameSystem (système de JDR).
|
* DTO pour l'entité GameSystem (système de JDR).
|
||||||
|
* Expose les templates PJ/PNJ comme listes de TemplateFieldDTO pour le wire.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class GameSystemDTO {
|
public class GameSystemDTO {
|
||||||
@@ -12,6 +17,8 @@ public class GameSystemDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private String rulesMarkdown;
|
private String rulesMarkdown;
|
||||||
|
private List<TemplateFieldDTO> characterTemplate = new ArrayList<>();
|
||||||
|
private List<TemplateFieldDTO> npcTemplate = new ArrayList<>();
|
||||||
private String author;
|
private String author;
|
||||||
private boolean isPublic;
|
private boolean isPublic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
package com.loremind.infrastructure.web.dto.shared;
|
||||||
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
|
|||||||
/**
|
/**
|
||||||
* DTO pour un champ de Template.
|
* DTO pour un champ de Template.
|
||||||
* <p>
|
* <p>
|
||||||
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
* Miroir wire-friendly de {@link com.loremind.domain.shared.template.TemplateField}.
|
||||||
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||||
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
||||||
* le rendu visuel des champs image cote front.
|
* le rendu visuel des champs image cote front.
|
||||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Character;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.CharacterDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class CharacterMapper {
|
public class CharacterMapper {
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ public class CharacterMapper {
|
|||||||
CharacterDTO dto = new CharacterDTO();
|
CharacterDTO dto = new CharacterDTO();
|
||||||
dto.setId(c.getId());
|
dto.setId(c.getId());
|
||||||
dto.setName(c.getName());
|
dto.setName(c.getName());
|
||||||
dto.setMarkdownContent(c.getMarkdownContent());
|
dto.setPortraitImageId(c.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(c.getHeaderImageId());
|
||||||
|
dto.setValues(c.getValues() != null ? new HashMap<>(c.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(c.getImageValues() != null ? new HashMap<>(c.getImageValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(c.getCampaignId());
|
dto.setCampaignId(c.getCampaignId());
|
||||||
dto.setOrder(c.getOrder());
|
dto.setOrder(c.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -23,7 +28,10 @@ public class CharacterMapper {
|
|||||||
return Character.builder()
|
return Character.builder()
|
||||||
.id(dto.getId())
|
.id(dto.getId())
|
||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.markdownContent(dto.getMarkdownContent())
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.gamesystemcontext.GameSystem;
|
import com.loremind.domain.gamesystemcontext.GameSystem;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class GameSystemMapper {
|
public class GameSystemMapper {
|
||||||
|
|
||||||
|
private final TemplateFieldMapper fieldMapper;
|
||||||
|
|
||||||
|
public GameSystemMapper(TemplateFieldMapper fieldMapper) {
|
||||||
|
this.fieldMapper = fieldMapper;
|
||||||
|
}
|
||||||
|
|
||||||
public GameSystemDTO toDTO(GameSystem g) {
|
public GameSystemDTO toDTO(GameSystem g) {
|
||||||
if (g == null) return null;
|
if (g == null) return null;
|
||||||
GameSystemDTO dto = new GameSystemDTO();
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
@@ -14,6 +25,8 @@ public class GameSystemMapper {
|
|||||||
dto.setName(g.getName());
|
dto.setName(g.getName());
|
||||||
dto.setDescription(g.getDescription());
|
dto.setDescription(g.getDescription());
|
||||||
dto.setRulesMarkdown(g.getRulesMarkdown());
|
dto.setRulesMarkdown(g.getRulesMarkdown());
|
||||||
|
dto.setCharacterTemplate(toDTOList(g.getCharacterTemplate()));
|
||||||
|
dto.setNpcTemplate(toDTOList(g.getNpcTemplate()));
|
||||||
dto.setAuthor(g.getAuthor());
|
dto.setAuthor(g.getAuthor());
|
||||||
dto.setPublic(g.isPublic());
|
dto.setPublic(g.isPublic());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -26,8 +39,24 @@ public class GameSystemMapper {
|
|||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.description(dto.getDescription())
|
.description(dto.getDescription())
|
||||||
.rulesMarkdown(dto.getRulesMarkdown())
|
.rulesMarkdown(dto.getRulesMarkdown())
|
||||||
|
.characterTemplate(toDomainList(dto.getCharacterTemplate()))
|
||||||
|
.npcTemplate(toDomainList(dto.getNpcTemplate()))
|
||||||
.author(dto.getAuthor())
|
.author(dto.getAuthor())
|
||||||
.isPublic(dto.isPublic())
|
.isPublic(dto.isPublic())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<TemplateFieldDTO> toDTOList(List<TemplateField> fields) {
|
||||||
|
if (fields == null) return new ArrayList<>();
|
||||||
|
List<TemplateFieldDTO> out = new ArrayList<>(fields.size());
|
||||||
|
for (TemplateField f : fields) out.add(fieldMapper.toDTO(f));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TemplateField> toDomainList(List<TemplateFieldDTO> dtos) {
|
||||||
|
if (dtos == null) return new ArrayList<>();
|
||||||
|
List<TemplateField> out = new ArrayList<>(dtos.size());
|
||||||
|
for (TemplateFieldDTO d : dtos) out.add(fieldMapper.toDomain(d));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.loremind.domain.campaigncontext.Npc;
|
|||||||
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
import com.loremind.infrastructure.web.dto.campaigncontext.NpcDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class NpcMapper {
|
public class NpcMapper {
|
||||||
|
|
||||||
@@ -12,7 +14,10 @@ public class NpcMapper {
|
|||||||
NpcDTO dto = new NpcDTO();
|
NpcDTO dto = new NpcDTO();
|
||||||
dto.setId(n.getId());
|
dto.setId(n.getId());
|
||||||
dto.setName(n.getName());
|
dto.setName(n.getName());
|
||||||
dto.setMarkdownContent(n.getMarkdownContent());
|
dto.setPortraitImageId(n.getPortraitImageId());
|
||||||
|
dto.setHeaderImageId(n.getHeaderImageId());
|
||||||
|
dto.setValues(n.getValues() != null ? new HashMap<>(n.getValues()) : new HashMap<>());
|
||||||
|
dto.setImageValues(n.getImageValues() != null ? new HashMap<>(n.getImageValues()) : new HashMap<>());
|
||||||
dto.setCampaignId(n.getCampaignId());
|
dto.setCampaignId(n.getCampaignId());
|
||||||
dto.setOrder(n.getOrder());
|
dto.setOrder(n.getOrder());
|
||||||
return dto;
|
return dto;
|
||||||
@@ -23,7 +28,10 @@ public class NpcMapper {
|
|||||||
return Npc.builder()
|
return Npc.builder()
|
||||||
.id(dto.getId())
|
.id(dto.getId())
|
||||||
.name(dto.getName())
|
.name(dto.getName())
|
||||||
.markdownContent(dto.getMarkdownContent())
|
.portraitImageId(dto.getPortraitImageId())
|
||||||
|
.headerImageId(dto.getHeaderImageId())
|
||||||
|
.values(dto.getValues() != null ? new HashMap<>(dto.getValues()) : new HashMap<>())
|
||||||
|
.imageValues(dto.getImageValues() != null ? new HashMap<>(dto.getImageValues()) : new HashMap<>())
|
||||||
.campaignId(dto.getCampaignId())
|
.campaignId(dto.getCampaignId())
|
||||||
.order(dto.getOrder())
|
.order(dto.getOrder())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ app.demo-mode=${DEMO_MODE:false}
|
|||||||
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
# Feature desactivee si UPDATE_CHECK_WATCHTOWER_TOKEN est vide.
|
||||||
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
update-check.registry=${UPDATE_CHECK_REGISTRY:}
|
||||||
update-check.images=${UPDATE_CHECK_IMAGES:}
|
update-check.images=${UPDATE_CHECK_IMAGES:}
|
||||||
update-check.tag=${UPDATE_CHECK_TAG:latest}
|
|
||||||
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
update-check.watchtower-url=${WATCHTOWER_URL:http://watchtower:8080}
|
||||||
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
update-check.watchtower-token=${WATCHTOWER_TOKEN:}
|
||||||
|
|
||||||
@@ -97,7 +96,6 @@ licensing.instance-id-file=${LICENSING_INSTANCE_ID_FILE:}
|
|||||||
# Image beta : si la licence est valide ET le toggle canal beta active,
|
# Image beta : si la licence est valide ET le toggle canal beta active,
|
||||||
# UpdateCheckService check ces images en plus du canal stable.
|
# UpdateCheckService check ces images en plus du canal stable.
|
||||||
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
|
licensing.beta.images=${LICENSING_BETA_IMAGES:igmlcreation/loremind-beta-core,igmlcreation/loremind-beta-brain,igmlcreation/loremind-beta-web}
|
||||||
licensing.beta.tag=${LICENSING_BETA_TAG:latest}
|
|
||||||
|
|
||||||
# Chemin de sortie pour le docker config.json partage avec Watchtower.
|
# Chemin de sortie pour le docker config.json partage avec Watchtower.
|
||||||
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
|
# Volume Docker `docker-config` monte sur ce chemin dans Core, et sur
|
||||||
|
|||||||
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
3
core/src/main/resources/licensing/jwt-public-key.pem
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEArbfaqBq54HJR1pKqliTShKrNIab32gpBwSTDw90I4wg=
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -11,6 +11,7 @@ import org.mockito.Mock;
|
|||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
@@ -38,7 +39,7 @@ public class NpcServiceTest {
|
|||||||
testNpc = Npc.builder()
|
testNpc = Npc.builder()
|
||||||
.id("npc-1")
|
.id("npc-1")
|
||||||
.name("Borin le forgeron")
|
.name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\nForgeron nain")
|
.values(new java.util.HashMap<>(Map.of("Notes", "Forgeron nain")))
|
||||||
.campaignId("camp-1")
|
.campaignId("camp-1")
|
||||||
.order(1)
|
.order(1)
|
||||||
.build();
|
.build();
|
||||||
@@ -49,7 +50,8 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
Npc result = npcService.createNpc(
|
Npc result = npcService.createNpc(
|
||||||
new NpcService.NpcData("Borin le forgeron", "# Borin", "camp-1", 5));
|
new NpcService.NpcData("Borin le forgeron", null, null,
|
||||||
|
Map.of("Notes", "Borin"), null, "camp-1", 5));
|
||||||
|
|
||||||
assertNotNull(result);
|
assertNotNull(result);
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
@@ -65,7 +67,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of(a, b));
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Nouveau", null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Nouveau", null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -77,7 +79,7 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(npcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
when(npcRepository.save(any(Npc.class))).thenReturn(testNpc);
|
||||||
|
|
||||||
npcService.createNpc(new NpcService.NpcData("Premier", null, "camp-1", null));
|
npcService.createNpc(new NpcService.NpcData("Premier", null, null, null, null, "camp-1", null));
|
||||||
|
|
||||||
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
ArgumentCaptor<Npc> captor = ArgumentCaptor.forClass(Npc.class);
|
||||||
verify(npcRepository).save(captor.capture());
|
verify(npcRepository).save(captor.capture());
|
||||||
@@ -121,10 +123,11 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin renommé", "# v2", "camp-1", 7));
|
new NpcService.NpcData("Borin renommé", null, null,
|
||||||
|
Map.of("Notes", "v2"), null, "camp-1", 7));
|
||||||
|
|
||||||
assertEquals("Borin renommé", result.getName());
|
assertEquals("Borin renommé", result.getName());
|
||||||
assertEquals("# v2", result.getMarkdownContent());
|
assertEquals("v2", result.getValues().get("Notes"));
|
||||||
assertEquals(7, result.getOrder());
|
assertEquals(7, result.getOrder());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +137,8 @@ public class NpcServiceTest {
|
|||||||
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
when(npcRepository.save(any(Npc.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Npc result = npcService.updateNpc("npc-1",
|
Npc result = npcService.updateNpc("npc-1",
|
||||||
new NpcService.NpcData("Borin", "# txt", "camp-1", null));
|
new NpcService.NpcData("Borin", null, null,
|
||||||
|
Map.of("Notes", "txt"), null, "camp-1", null));
|
||||||
|
|
||||||
// testNpc avait order=1 → préservé
|
// testNpc avait order=1 → préservé
|
||||||
assertEquals(1, result.getOrder());
|
assertEquals(1, result.getOrder());
|
||||||
@@ -146,7 +150,7 @@ public class NpcServiceTest {
|
|||||||
|
|
||||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||||
() -> npcService.updateNpc("missing",
|
() -> npcService.updateNpc("missing",
|
||||||
new NpcService.NpcData("x", null, "camp-1", null)));
|
new NpcService.NpcData("x", null, null, null, null, "camp-1", null)));
|
||||||
assertTrue(ex.getMessage().contains("missing"));
|
assertTrue(ex.getMessage().contains("missing"));
|
||||||
verify(npcRepository, never()).save(any());
|
verify(npcRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,19 +153,19 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
void testBuild_ProjectsCharactersAndNpcsWithSnippets() {
|
||||||
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
Character pj1 = Character.builder().id("c-1").campaignId("camp-1").order(1)
|
||||||
.name("Aragorn")
|
.name("Aragorn")
|
||||||
.markdownContent("# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Aragorn\n\nRôdeur du Nord, héritier d'Isildur.")))
|
||||||
.build();
|
.build();
|
||||||
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
Character pj2 = Character.builder().id("c-2").campaignId("camp-1").order(2)
|
||||||
.name("Legolas")
|
.name("Legolas")
|
||||||
.markdownContent(null) // pas de snippet → string vide
|
.values(null) // pas de snippet → string vide
|
||||||
.build();
|
.build();
|
||||||
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
Npc npc1 = Npc.builder().id("n-1").campaignId("camp-1").order(2)
|
||||||
.name("Borin le forgeron")
|
.name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "# Borin\n\nNain barbu au regard perçant, ancien clan Feuillefer.")))
|
||||||
.build();
|
.build();
|
||||||
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
Npc npc2 = Npc.builder().id("n-2").campaignId("camp-1").order(1)
|
||||||
.name("Dame Elara")
|
.name("Dame Elara")
|
||||||
.markdownContent("")
|
.values(new java.util.HashMap<>(java.util.Map.of("Histoire", "")))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
@@ -196,7 +196,7 @@ public class CampaignStructuralContextBuilderTest {
|
|||||||
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
// Snippet > 160 chars : doit être tronqué à 159 + "…"
|
||||||
String longLine = "x".repeat(200);
|
String longLine = "x".repeat(200);
|
||||||
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
Npc longNpc = Npc.builder().id("n-1").campaignId("camp-1").order(1)
|
||||||
.name("Verbeux").markdownContent(longLine).build();
|
.name("Verbeux").values(new java.util.HashMap<>(java.util.Map.of("Histoire", longLine))).build();
|
||||||
|
|
||||||
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
when(campaignRepository.findById("camp-1")).thenReturn(Optional.of(campaign));
|
||||||
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
when(arcRepository.findByCampaignId("camp-1")).thenReturn(List.of());
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package com.loremind.application.generationcontext;
|
|||||||
import com.loremind.domain.generationcontext.GenerationContext;
|
import com.loremind.domain.generationcontext.GenerationContext;
|
||||||
import com.loremind.domain.generationcontext.GenerationResult;
|
import com.loremind.domain.generationcontext.GenerationResult;
|
||||||
import com.loremind.domain.generationcontext.ports.AiProvider;
|
import com.loremind.domain.generationcontext.ports.AiProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.LoreNode;
|
import com.loremind.domain.lorecontext.LoreNode;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
import com.loremind.domain.lorecontext.ports.LoreNodeRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
|
|||||||
@@ -115,8 +115,13 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Character_MarkdownProjected() {
|
void testBuild_Character_MarkdownProjected() {
|
||||||
|
// Refonte 2026-04-30 : les valeurs templates sont projetees individuellement
|
||||||
|
// dans la map fields (cle = nom du champ template).
|
||||||
Character c = Character.builder()
|
Character c = Character.builder()
|
||||||
.id("c-1").name("Aragorn").markdownContent("# Aragorn\nRôdeur")
|
.id("c-1").name("Aragorn")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Histoire", "# Aragorn\nRôdeur",
|
||||||
|
"Race", "Humain")))
|
||||||
.build();
|
.build();
|
||||||
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
when(characterRepository.findById("c-1")).thenReturn(Optional.of(c));
|
||||||
|
|
||||||
@@ -124,14 +129,17 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
assertEquals("character", ctx.entityType());
|
assertEquals("character", ctx.entityType());
|
||||||
assertEquals("Aragorn", ctx.title());
|
assertEquals("Aragorn", ctx.title());
|
||||||
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("fiche complète (markdown)"));
|
assertEquals("# Aragorn\nRôdeur", ctx.fields().get("Histoire"));
|
||||||
|
assertEquals("Humain", ctx.fields().get("Race"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Npc_MarkdownProjected() {
|
void testBuild_Npc_MarkdownProjected() {
|
||||||
Npc n = Npc.builder()
|
Npc n = Npc.builder()
|
||||||
.id("n-1").name("Borin le forgeron")
|
.id("n-1").name("Borin le forgeron")
|
||||||
.markdownContent("# Borin\n**Faction :** Clan Feuillefer")
|
.values(new java.util.HashMap<>(java.util.Map.of(
|
||||||
|
"Faction", "Clan Feuillefer",
|
||||||
|
"Histoire", "# Borin")))
|
||||||
.build();
|
.build();
|
||||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
@@ -139,13 +147,14 @@ public class NarrativeEntityContextBuilderTest {
|
|||||||
|
|
||||||
assertEquals("npc", ctx.entityType());
|
assertEquals("npc", ctx.entityType());
|
||||||
assertEquals("Borin le forgeron", ctx.title());
|
assertEquals("Borin le forgeron", ctx.title());
|
||||||
assertEquals("# Borin\n**Faction :** Clan Feuillefer",
|
assertEquals("Clan Feuillefer", ctx.fields().get("Faction"));
|
||||||
ctx.fields().get("fiche complète (markdown)"));
|
assertEquals("# Borin", ctx.fields().get("Histoire"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testBuild_Npc_NormalizesCase() {
|
void testBuild_Npc_NormalizesCase() {
|
||||||
Npc n = Npc.builder().id("n-1").name("Elara").markdownContent("desc").build();
|
Npc n = Npc.builder().id("n-1").name("Elara")
|
||||||
|
.values(new java.util.HashMap<>(java.util.Map.of("Notes", "desc"))).build();
|
||||||
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
when(npcRepository.findById("n-1")).thenReturn(Optional.of(n));
|
||||||
|
|
||||||
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
NarrativeEntityContext ctx = builder.build(" NPC ", "n-1");
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import com.loremind.domain.generationcontext.ChatRequest;
|
|||||||
import com.loremind.domain.generationcontext.ChatUsage;
|
import com.loremind.domain.generationcontext.ChatUsage;
|
||||||
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
import com.loremind.domain.generationcontext.LoreStructuralContext;
|
||||||
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
import com.loremind.domain.generationcontext.ports.AiChatProvider;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.Page;
|
import com.loremind.domain.lorecontext.Page;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.PageRepository;
|
import com.loremind.domain.lorecontext.ports.PageRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.loremind.application.lorecontext;
|
package com.loremind.application.lorecontext;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package com.loremind.domain.gamesystemcontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests unitaires du domaine GameSystem ciblant la gestion des templates PJ/PNJ.
|
||||||
|
* Le ruleset markdown est testé ailleurs via GameSystemContextSelector.
|
||||||
|
*/
|
||||||
|
class GameSystemTest {
|
||||||
|
|
||||||
|
// --- addCharacterField --------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_appendsField() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
gs.addCharacterField(TemplateField.image("Portrait", ImageLayout.HERO));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Histoire", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals(FieldType.IMAGE, gs.getCharacterTemplate().get(1).getType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsDuplicateNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.addCharacterField(TemplateField.text("Histoire"));
|
||||||
|
|
||||||
|
// Doublon de cle dans Character.values garanti casse-insensible :
|
||||||
|
// "Histoire" et "histoire" produiraient la meme cle JSON.
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(TemplateField.number("HISTOIRE")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addCharacterField_rejectsBlankName() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addCharacterField(new TemplateField(" ", FieldType.TEXT)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- removeCharacterField ----------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_removesByNameCaseInsensitive() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(
|
||||||
|
TemplateField.text("Histoire"),
|
||||||
|
TemplateField.text("Notes")
|
||||||
|
)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("HISTOIRE");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("Notes", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removeCharacterField_silentNoOpWhenMissing() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Histoire"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.removeCharacterField("absent");
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- replaceCharacterTemplate ------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_overwritesEntireList() {
|
||||||
|
GameSystem gs = GameSystem.builder()
|
||||||
|
.characterTemplate(new ArrayList<>(List.of(TemplateField.text("Old"))))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("A"),
|
||||||
|
TemplateField.number("B")));
|
||||||
|
|
||||||
|
assertEquals(2, gs.getCharacterTemplate().size());
|
||||||
|
assertEquals("A", gs.getCharacterTemplate().get(0).getName());
|
||||||
|
assertEquals("B", gs.getCharacterTemplate().get(1).getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_rejectsDuplicates() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.replaceCharacterTemplate(List.of(
|
||||||
|
TemplateField.text("a"),
|
||||||
|
TemplateField.text("A"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_nullBecomesEmptyList() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
gs.replaceCharacterTemplate(null);
|
||||||
|
assertTrue(gs.getCharacterTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceCharacterTemplate_isolatesInternalListFromCallerMutations() {
|
||||||
|
// Garantie d'encapsulation : muter la liste passee ne doit pas affecter le GameSystem.
|
||||||
|
List<TemplateField> external = new ArrayList<>(List.of(TemplateField.text("A")));
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.replaceCharacterTemplate(external);
|
||||||
|
external.add(TemplateField.text("B"));
|
||||||
|
|
||||||
|
assertEquals(1, gs.getCharacterTemplate().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates NPC : meme logique, sanity check minimal ----------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void npcTemplate_followsSameRulesAsCharacterTemplate() {
|
||||||
|
GameSystem gs = GameSystem.builder().build();
|
||||||
|
|
||||||
|
gs.addNpcField(TemplateField.text("Motivation"));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> gs.addNpcField(TemplateField.text("motivation")));
|
||||||
|
|
||||||
|
gs.removeNpcField("Motivation");
|
||||||
|
assertTrue(gs.getNpcTemplate().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.loremind.domain.lorecontext;
|
package com.loremind.domain.shared.template;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
package com.loremind.infrastructure.persistence.converter;
|
package com.loremind.infrastructure.persistence.converter;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package com.loremind.infrastructure.persistence.postgres;
|
package com.loremind.infrastructure.persistence.postgres;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.shared.template.FieldType;
|
||||||
import com.loremind.domain.lorecontext.ImageLayout;
|
import com.loremind.domain.shared.template.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.Lore;
|
import com.loremind.domain.lorecontext.Lore;
|
||||||
import com.loremind.domain.lorecontext.Template;
|
import com.loremind.domain.lorecontext.Template;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.shared.template.TemplateField;
|
||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
package com.loremind.infrastructure.updates;
|
package com.loremind.infrastructure.updates;
|
||||||
|
|
||||||
|
import com.loremind.application.licensing.LicenseService;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatus;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
import com.loremind.infrastructure.updates.UpdateCheckService.ImageStatusKind;
|
||||||
|
import com.loremind.infrastructure.updates.UpdateCheckService.TagsListResponse;
|
||||||
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
import com.loremind.infrastructure.updates.UpdateCheckService.UpdateStatus;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.List;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.Properties;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -19,69 +23,68 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test unitaire pour UpdateCheckService.
|
* Tests UpdateCheckService - approche semver (post-refactor v0.8.x).
|
||||||
*
|
*
|
||||||
* Couvre les invariants critiques de la detection de MAJ :
|
* Couvre :
|
||||||
* - feature desactivee si token absent
|
* - feature desactivee si WATCHTOWER_TOKEN absent
|
||||||
* - status UP_TO_DATE quand baseline == remote
|
* - UP_TO_DATE quand version locale == max(tags remote)
|
||||||
* - status UPDATE_AVAILABLE quand baseline != remote
|
* - UPDATE_AVAILABLE quand un tag plus eleve existe
|
||||||
* - status UNKNOWN quand baseline manque (PAS d'alignement lazy — invariant
|
* - UNKNOWN quand le registry echoue
|
||||||
* central, regression historique)
|
* - UNKNOWN quand BuildProperties est absent (currentVersion = null)
|
||||||
* - status UNKNOWN quand remote impossible a fetcher
|
* - parseSemver / findMaxSemver / compareSemver utilitaires
|
||||||
* - drapeaux top-level updateAvailable / anyUnknown coherents
|
|
||||||
* - back-compat : champ updateAvailable sur ImageStatus = (status == UPDATE_AVAILABLE)
|
|
||||||
*/
|
*/
|
||||||
public class UpdateCheckServiceTest {
|
public class UpdateCheckServiceTest {
|
||||||
|
|
||||||
private static UpdateCheckService newService(String token) {
|
private static UpdateCheckService newService(String token, String currentVersion) {
|
||||||
// licensing.* params left empty + LicenseService null : la feature beta est
|
BuildProperties bp = null;
|
||||||
// desactivee dans ces tests, qui couvrent uniquement le canal stable.
|
if (currentVersion != null) {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.setProperty("version", currentVersion);
|
||||||
|
bp = new BuildProperties(p);
|
||||||
|
}
|
||||||
|
// licenseService null : la beta est testee separement, ces tests
|
||||||
|
// couvrent uniquement le canal stable.
|
||||||
return new UpdateCheckService(
|
return new UpdateCheckService(
|
||||||
new RestTemplateBuilder(),
|
new RestTemplateBuilder(),
|
||||||
"ghcr.io",
|
"ghcr.io",
|
||||||
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
"igmlcreation/loremind-core,igmlcreation/loremind-brain",
|
||||||
"latest",
|
|
||||||
"http://watchtower:8080",
|
"http://watchtower:8080",
|
||||||
token,
|
token,
|
||||||
"",
|
"",
|
||||||
"latest",
|
null,
|
||||||
null
|
bp
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Injecte un RestTemplate moque dans le service deja construit, et pose
|
|
||||||
* directement les baselines pour eviter les vrais appels HTTP.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private static void setBaselines(UpdateCheckService svc, Map<String, String> baselines) {
|
|
||||||
((Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests")).putAll(baselines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
private static RestTemplate stubHttp(UpdateCheckService svc) {
|
||||||
RestTemplate http = mock(RestTemplate.class);
|
RestTemplate http = mock(RestTemplate.class);
|
||||||
ReflectionTestUtils.setField(svc, "http", http);
|
ReflectionTestUtils.setField(svc, "http", http);
|
||||||
return http;
|
return http;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void stubRemoteDigest(RestTemplate http, String image, String digest) {
|
private static void stubTags(RestTemplate http, String image, List<String> tags) {
|
||||||
HttpHeaders headers = new HttpHeaders();
|
TagsListResponse body = new TagsListResponse();
|
||||||
if (digest != null) headers.add("Docker-Content-Digest", digest);
|
body.name = image;
|
||||||
ResponseEntity<Void> resp = new ResponseEntity<>(headers, org.springframework.http.HttpStatus.OK);
|
body.tags = tags;
|
||||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
ResponseEntity<TagsListResponse> resp = new ResponseEntity<>(body, HttpStatus.OK);
|
||||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
.thenReturn(resp);
|
.thenReturn(resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void stubRemoteFailure(RestTemplate http, String image) {
|
private static void stubTagsFailure(RestTemplate http, String image) {
|
||||||
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/manifests/latest"),
|
when(http.exchange(eq("https://ghcr.io/v2/" + image + "/tags/list"),
|
||||||
eq(org.springframework.http.HttpMethod.HEAD), any(), eq(Void.class)))
|
eq(HttpMethod.GET), any(), eq(TagsListResponse.class)))
|
||||||
.thenThrow(new RuntimeException("network down"));
|
.thenThrow(new RuntimeException("network down"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
// Comportement du service
|
||||||
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void disabledWhenTokenMissing() {
|
void disabledWhenTokenMissing() {
|
||||||
UpdateCheckService svc = newService("");
|
UpdateCheckService svc = newService("", "0.8.0");
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
assertFalse(status.enabled());
|
assertFalse(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
@@ -90,118 +93,153 @@ public class UpdateCheckServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void upToDate_whenBaselineEqualsRemote() {
|
void upToDate_whenCurrentEqualsMaxRemote() {
|
||||||
UpdateCheckService svc = newService("token");
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
setBaselines(svc, Map.of(
|
|
||||||
"igmlcreation/loremind-core", "sha256:aaa",
|
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
|
||||||
));
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:aaa");
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.0", "0.8.0", "latest"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.enabled());
|
assertTrue(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
assertFalse(status.anyUnknown());
|
assertFalse(status.anyUnknown());
|
||||||
|
assertEquals("0.8.0", status.currentVersion());
|
||||||
for (ImageStatus img : status.images()) {
|
for (ImageStatus img : status.images()) {
|
||||||
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
assertEquals(ImageStatusKind.UP_TO_DATE, img.status());
|
||||||
|
assertEquals("0.8.0", img.localVersion());
|
||||||
|
assertEquals("0.8.0", img.remoteVersion());
|
||||||
assertFalse(img.updateAvailable(), "back-compat bool");
|
assertFalse(img.updateAvailable(), "back-compat bool");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updateAvailable_whenRemoteDiffers() {
|
void updateAvailable_whenRemoteHigher() {
|
||||||
UpdateCheckService svc = newService("token");
|
UpdateCheckService svc = newService("token", "0.7.2");
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
setBaselines(svc, Map.of(
|
|
||||||
"igmlcreation/loremind-core", "sha256:OLD",
|
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"
|
|
||||||
));
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
stubTags(http, "igmlcreation/loremind-core",
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain",
|
||||||
|
List.of("0.7.2", "latest"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.updateAvailable());
|
assertTrue(status.updateAvailable());
|
||||||
assertFalse(status.anyUnknown());
|
assertFalse(status.anyUnknown());
|
||||||
|
|
||||||
ImageStatus core = status.images().stream()
|
ImageStatus core = status.images().stream()
|
||||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
assertEquals(ImageStatusKind.UPDATE_AVAILABLE, core.status());
|
||||||
|
assertEquals("0.7.2", core.localVersion());
|
||||||
|
assertEquals("0.8.0", core.remoteVersion());
|
||||||
assertTrue(core.updateAvailable(), "back-compat bool");
|
assertTrue(core.updateAvailable(), "back-compat bool");
|
||||||
|
|
||||||
ImageStatus brain = status.images().stream()
|
ImageStatus brain = status.images().stream()
|
||||||
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
.filter(i -> i.image().endsWith("brain")).findFirst().orElseThrow();
|
||||||
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
assertEquals(ImageStatusKind.UP_TO_DATE, brain.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknown_whenBaselineMissing_DOES_NOT_lazyAlign() {
|
void unknown_whenRegistryFails() {
|
||||||
// INVARIANT CENTRAL : si la baseline est absente (echec init au boot),
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
// on NE DOIT PAS aligner lazy sur le remote courant — sinon une MAJ
|
|
||||||
// pousse APRES le boot serait declaree "a jour" silencieusement.
|
|
||||||
UpdateCheckService svc = newService("token");
|
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
|
||||||
// baseline DELIBEREMENT vide
|
|
||||||
RestTemplate http = stubHttp(svc);
|
RestTemplate http = stubHttp(svc);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:remote-now");
|
stubTagsFailure(http, "igmlcreation/loremind-core");
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:remote-now-2");
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
assertEquals("0.8.0", core.localVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenNoValidSemverTags() {
|
||||||
|
UpdateCheckService svc = newService("token", "0.8.0");
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("latest", "stable", "main"));
|
||||||
|
stubTags(http, "igmlcreation/loremind-brain", List.of("0.8.0"));
|
||||||
|
|
||||||
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
|
assertTrue(status.anyUnknown());
|
||||||
|
ImageStatus core = status.images().stream()
|
||||||
|
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
||||||
|
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
||||||
|
assertNull(core.remoteVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknown_whenBuildPropertiesAbsent() {
|
||||||
|
// INVARIANT : pas de version courante => tout UNKNOWN, jamais "a jour"
|
||||||
|
// par defaut. Evite de declarer "a jour" un build dev sans build-info.
|
||||||
|
UpdateCheckService svc = newService("token", null);
|
||||||
|
RestTemplate http = stubHttp(svc);
|
||||||
|
// Meme si on stub des tags, le service doit bypass et renvoyer UNKNOWN
|
||||||
|
stubTags(http, "igmlcreation/loremind-core", List.of("0.8.0"));
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
UpdateStatus status = svc.check();
|
||||||
|
|
||||||
assertTrue(status.enabled());
|
assertTrue(status.enabled());
|
||||||
assertFalse(status.updateAvailable());
|
assertFalse(status.updateAvailable());
|
||||||
assertTrue(status.anyUnknown());
|
assertTrue(status.anyUnknown());
|
||||||
|
assertNull(status.currentVersion());
|
||||||
for (ImageStatus img : status.images()) {
|
for (ImageStatus img : status.images()) {
|
||||||
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
assertEquals(ImageStatusKind.UNKNOWN, img.status());
|
||||||
assertNull(img.localDigest());
|
|
||||||
assertNotNull(img.remoteDigest()); // remote OK, baseline manquante
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VERIFICATION CRITIQUE : la baseline ne doit PAS avoir ete posee.
|
// -----------------------------------------------------------------
|
||||||
@SuppressWarnings("unchecked")
|
// Utilitaires semver
|
||||||
Map<String, String> baselines = (Map<String, String>) ReflectionTestUtils.getField(svc, "baselineDigests");
|
// -----------------------------------------------------------------
|
||||||
assertTrue(baselines.isEmpty(),
|
|
||||||
"check() ne doit JAMAIS aligner lazy la baseline sur le remote — "
|
@Test
|
||||||
+ "regression de bug historique (faux negatif silencieux).");
|
void parseSemver_acceptsCommonFormats() {
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("v0.8.0"));
|
||||||
|
assertArrayEquals(new int[]{1, 0, 0}, UpdateCheckService.parseSemver("1.0.0"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0-beta.1"));
|
||||||
|
assertArrayEquals(new int[]{0, 8, 0}, UpdateCheckService.parseSemver("0.8.0+build.42"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void unknown_whenRemoteFetchFails() {
|
void parseSemver_rejectsInvalid() {
|
||||||
UpdateCheckService svc = newService("token");
|
assertNull(UpdateCheckService.parseSemver(null));
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
assertNull(UpdateCheckService.parseSemver(""));
|
||||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:aaa",
|
assertNull(UpdateCheckService.parseSemver("latest"));
|
||||||
"igmlcreation/loremind-brain", "sha256:bbb"));
|
assertNull(UpdateCheckService.parseSemver("stable"));
|
||||||
RestTemplate http = stubHttp(svc);
|
assertNull(UpdateCheckService.parseSemver("0.8.0.1.2"));
|
||||||
stubRemoteFailure(http, "igmlcreation/loremind-core");
|
assertNull(UpdateCheckService.parseSemver("0.x.0"));
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-brain", "sha256:bbb");
|
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
|
||||||
|
|
||||||
assertFalse(status.updateAvailable());
|
|
||||||
assertTrue(status.anyUnknown());
|
|
||||||
ImageStatus core = status.images().stream()
|
|
||||||
.filter(i -> i.image().endsWith("core")).findFirst().orElseThrow();
|
|
||||||
assertEquals(ImageStatusKind.UNKNOWN, core.status());
|
|
||||||
assertNull(core.remoteDigest());
|
|
||||||
assertEquals("sha256:aaa", core.localDigest()); // baseline preservee
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mixedStatuses_anyUnknownAndAnyUpdateBothTrue() {
|
void compareSemver_basic() {
|
||||||
UpdateCheckService svc = newService("token");
|
assertTrue(UpdateCheckService.compareSemver("0.7.2", "0.8.0") < 0);
|
||||||
ReflectionTestUtils.setField(svc, "baselineDigests", new ConcurrentHashMap<>());
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.7.2") > 0);
|
||||||
setBaselines(svc, Map.of("igmlcreation/loremind-core", "sha256:OLD"));
|
assertEquals(0, UpdateCheckService.compareSemver("0.8.0", "0.8.0"));
|
||||||
// brain n'a pas de baseline -> UNKNOWN
|
assertEquals(0, UpdateCheckService.compareSemver("v0.8.0", "0.8.0"));
|
||||||
RestTemplate http = stubHttp(svc);
|
assertTrue(UpdateCheckService.compareSemver("0.8.0", "0.10.0") < 0);
|
||||||
stubRemoteDigest(http, "igmlcreation/loremind-core", "sha256:NEW");
|
assertTrue(UpdateCheckService.compareSemver("1.0.0", "0.99.99") > 0);
|
||||||
stubRemoteFailure(http, "igmlcreation/loremind-brain");
|
}
|
||||||
|
|
||||||
UpdateStatus status = svc.check();
|
@Test
|
||||||
|
void findMaxSemver_picksHighest() {
|
||||||
|
assertEquals("0.8.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.7.0", "0.7.1", "0.7.2", "0.8.0", "latest")));
|
||||||
|
assertEquals("0.10.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("0.8.0", "0.10.0", "0.9.5")));
|
||||||
|
assertEquals("v1.0.0", UpdateCheckService.findMaxSemver(
|
||||||
|
List.of("v0.8.0", "v1.0.0", "latest")));
|
||||||
|
}
|
||||||
|
|
||||||
assertTrue(status.updateAvailable(), "core a une MAJ disponible");
|
@Test
|
||||||
assertTrue(status.anyUnknown(), "brain est UNKNOWN");
|
void findMaxSemver_returnsNullWhenNoValidTag() {
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of("latest", "stable", "main")));
|
||||||
|
assertNull(UpdateCheckService.findMaxSemver(List.of()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.loremind.infrastructure.web.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.loremind.infrastructure.web.dto.gamesystemcontext.GameSystemDTO;
|
||||||
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests d'integration du GameSystemController centres sur la persistance
|
||||||
|
* des templates PJ/PNJ via l'API REST. Le CRUD de base est suppose stable.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@Transactional
|
||||||
|
class GameSystemControllerTest {
|
||||||
|
|
||||||
|
@Autowired private MockMvc mockMvc;
|
||||||
|
@Autowired private ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_persistsCharacterAndNpcTemplates() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("Nimble Test");
|
||||||
|
dto.setRulesMarkdown("## Combat\n- d20");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("PV", "NUMBER", null),
|
||||||
|
new TemplateFieldDTO("Portrait", "IMAGE", "HERO")));
|
||||||
|
dto.setNpcTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Motivation", "TEXT", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.id").exists())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(3))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[2].layout").value("HERO"))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.npcTemplate[0].name").value("Motivation"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void update_replacesTemplates() throws Exception {
|
||||||
|
// Creation initiale avec un seul champ.
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("RuleSet");
|
||||||
|
dto.setCharacterTemplate(List.of(new TemplateFieldDTO("Old", "TEXT", null)));
|
||||||
|
|
||||||
|
MvcResult posted = mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
GameSystemDTO created = objectMapper.readValue(
|
||||||
|
posted.getResponse().getContentAsString(), GameSystemDTO.class);
|
||||||
|
|
||||||
|
// Replace template integralement.
|
||||||
|
created.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Histoire", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("Niveau", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/game-systems/{id}", created.getId())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(created)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[0].name").value("Histoire"))
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[1].type").value("NUMBER"));
|
||||||
|
|
||||||
|
// Verification que le GET independant retourne bien les nouveaux champs (pas de cache stale).
|
||||||
|
mockMvc.perform(get("/api/game-systems/{id}", created.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Old')]").doesNotExist())
|
||||||
|
.andExpect(jsonPath("$.characterTemplate[?(@.name == 'Histoire')]").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void create_rejectsDuplicateFieldNames() throws Exception {
|
||||||
|
GameSystemDTO dto = new GameSystemDTO();
|
||||||
|
dto.setName("BadRules");
|
||||||
|
dto.setCharacterTemplate(List.of(
|
||||||
|
new TemplateFieldDTO("Nom", "TEXT", null),
|
||||||
|
new TemplateFieldDTO("nom", "NUMBER", null)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/game-systems")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(dto)))
|
||||||
|
.andExpect(status().is4xxClientError());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import com.loremind.domain.lorecontext.Template;
|
|||||||
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
import com.loremind.domain.lorecontext.ports.LoreRepository;
|
||||||
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
import com.loremind.domain.lorecontext.ports.TemplateRepository;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateDTO;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
Auteur : ietm64
|
Auteur : ietm64
|
||||||
Licence : AGPL-3.0
|
Licence : AGPL-3.0
|
||||||
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
Projet : LoreMindMJ - assistant pour Maitres de Jeu de JDR
|
||||||
Version : 0.8.0
|
Version : 0.8.1
|
||||||
|
|
||||||
.LINK
|
.LINK
|
||||||
https://github.com/IGMLcreation/LoreMind
|
https://github.com/IGMLcreation/LoreMind
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.8.0",
|
"version": "0.8.1",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-update-banner></app-update-banner>
|
||||||
|
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<app-sidebar></app-sidebar>
|
<app-sidebar></app-sidebar>
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
import { SidebarComponent } from './sidebar/sidebar.component';
|
import { SidebarComponent } from './sidebar/sidebar.component';
|
||||||
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
import { SecondarySidebarComponent } from './shared/secondary-sidebar/secondary-sidebar.component';
|
||||||
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
import { GlobalSearchComponent } from './shared/global-search/global-search.component';
|
||||||
|
import { UpdateBannerComponent } from './shared/update-banner/update-banner.component';
|
||||||
import { LayoutService } from './services/layout.service';
|
import { LayoutService } from './services/layout.service';
|
||||||
import { GlobalSearchService } from './services/global-search.service';
|
import { GlobalSearchService } from './services/global-search.service';
|
||||||
|
import { VersionCheckerService } from './services/version-checker.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet, SidebarComponent, SecondarySidebarComponent, GlobalSearchComponent, AsyncPipe, NgIf],
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
SidebarComponent,
|
||||||
|
SecondarySidebarComponent,
|
||||||
|
GlobalSearchComponent,
|
||||||
|
UpdateBannerComponent,
|
||||||
|
AsyncPipe,
|
||||||
|
NgIf,
|
||||||
|
],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss']
|
styleUrls: ['./app.component.scss']
|
||||||
})
|
})
|
||||||
@@ -19,8 +29,14 @@ export class AppComponent {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private layoutService: LayoutService,
|
private layoutService: LayoutService,
|
||||||
private globalSearch: GlobalSearchService
|
private globalSearch: GlobalSearchService,
|
||||||
) {}
|
versionChecker: VersionCheckerService,
|
||||||
|
) {
|
||||||
|
// Demarre la detection de mise a jour en arriere-plan.
|
||||||
|
// Si une nouvelle version est deployee pendant la session, l'UpdateBanner
|
||||||
|
// s'affichera automatiquement.
|
||||||
|
versionChecker.start();
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
@HostListener('document:keydown', ['$event'])
|
||||||
onKeydown(event: KeyboardEvent): void {
|
onKeydown(event: KeyboardEvent): void {
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ export const routes: Routes = [
|
|||||||
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
{ path: 'campaigns/:id', loadComponent: () => import('./campaigns/campaign/campaign-detail/campaign-detail.component').then(m => m.CampaignDetailComponent) },
|
||||||
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/characters/create', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
{ path: 'campaigns/:campaignId/characters/:characterId/edit', loadComponent: () => import('./campaigns/character/character-edit/character-edit.component').then(m => m.CharacterEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/characters/:characterId', loadComponent: () => import('./campaigns/character/character-view/character-view.component').then(m => m.CharacterViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/create', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
{ path: 'campaigns/:campaignId/npcs/:npcId/edit', loadComponent: () => import('./campaigns/npc/npc-edit/npc-edit.component').then(m => m.NpcEditComponent) },
|
||||||
|
{ path: 'campaigns/:campaignId/npcs/:npcId', loadComponent: () => import('./campaigns/npc/npc-view/npc-view.component').then(m => m.NpcViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
{ path: 'campaigns/:campaignId/arcs/create', loadComponent: () => import('./campaigns/arc/arc-create/arc-create.component').then(m => m.ArcCreateComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId', loadComponent: () => import('./campaigns/arc/arc-view/arc-view.component').then(m => m.ArcViewComponent) },
|
||||||
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
{ path: 'campaigns/:campaignId/arcs/:arcId/edit', loadComponent: () => import('./campaigns/arc/arc-edit/arc-edit.component').then(m => m.ArcEditComponent) },
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="characters-grid" *ngIf="characters.length > 0">
|
<div class="characters-grid" *ngIf="characters.length > 0">
|
||||||
<div class="character-card" *ngFor="let character of characters" (click)="editCharacter(character)">
|
<div class="character-card" *ngFor="let character of characters" (click)="viewCharacter(character)">
|
||||||
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
<lucide-icon [img]="User" [size]="20" class="character-icon"></lucide-icon>
|
||||||
<div class="character-info">
|
<div class="character-info">
|
||||||
<span class="character-name">{{ character.name }}</span>
|
<span class="character-name">{{ character.name }}</span>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="characters-grid" *ngIf="npcs.length > 0">
|
<div class="characters-grid" *ngIf="npcs.length > 0">
|
||||||
<div class="character-card" *ngFor="let npc of npcs" (click)="editNpc(npc)">
|
<div class="character-card" *ngFor="let npc of npcs" (click)="viewNpc(npc)">
|
||||||
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
<lucide-icon [img]="Drama" [size]="20" class="character-icon character-icon--npc"></lucide-icon>
|
||||||
<div class="character-info">
|
<div class="character-info">
|
||||||
<span class="character-name">{{ npc.name }}</span>
|
<span class="character-name">{{ npc.name }}</span>
|
||||||
|
|||||||
@@ -194,6 +194,17 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id, 'edit']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ouvre la vue lecture seule (style WorldAnvil) — clic sur la carte. */
|
||||||
|
viewCharacter(character: Character): void {
|
||||||
|
if (!this.campaign || !character.id) return;
|
||||||
|
this.router.navigate(['/campaigns', this.campaign.id, 'characters', character.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
viewNpc(npc: Npc): void {
|
||||||
|
if (!this.campaign || !npc.id) return;
|
||||||
|
this.router.navigate(['/campaigns', this.campaign.id, 'npcs', npc.id]);
|
||||||
|
}
|
||||||
|
|
||||||
createArc(): void {
|
createArc(): void {
|
||||||
if (!this.campaign) return;
|
if (!this.campaign) return;
|
||||||
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
this.router.navigate(['/campaigns', this.campaign.id, 'arcs', 'create']);
|
||||||
@@ -205,19 +216,23 @@ export class CampaignDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrait une ligne de résumé depuis le markdown (1re ligne non-vide, non-titre).
|
* Extrait une ligne de resume pour la fiche PJ/PNJ — 1re valeur de template
|
||||||
* Générique : utilisé pour les fiches PJ comme PNJ (mêmes besoins d'aperçu carte).
|
* non-vide (apres refonte 2026-04-30, remplace l'ancien parsing markdown).
|
||||||
*/
|
*/
|
||||||
personaSnippet(p: { markdownContent?: string | null }): string {
|
personaSnippet(p: { values?: Record<string, string> }): string {
|
||||||
if (!p.markdownContent) return '(Fiche vide)';
|
const values = p.values ?? {};
|
||||||
const firstMeaningful = p.markdownContent
|
for (const v of Object.values(values)) {
|
||||||
.split('\n')
|
if (!v) continue;
|
||||||
.map(l => l.trim())
|
const firstMeaningful = v
|
||||||
.find(l => l && !l.startsWith('#'));
|
.split('\n')
|
||||||
if (!firstMeaningful) return '(Fiche vide)';
|
.map(l => l.trim())
|
||||||
return firstMeaningful.length > 80
|
.find(l => l && !l.startsWith('#'));
|
||||||
? firstMeaningful.substring(0, 77) + '…'
|
if (!firstMeaningful) continue;
|
||||||
: firstMeaningful;
|
return firstMeaningful.length > 80
|
||||||
|
? firstMeaningful.substring(0, 77) + '…'
|
||||||
|
: firstMeaningful;
|
||||||
|
}
|
||||||
|
return '(Fiche vide)';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alias gardé pour compatibilité avec les anciens templates. */
|
/** Alias gardé pour compatibilité avec les anciens templates. */
|
||||||
|
|||||||
@@ -35,18 +35,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field content-field">
|
<div class="field-row image-row">
|
||||||
<label>Fiche (markdown)</label>
|
<div class="field portrait-field">
|
||||||
<p class="hint">
|
<label>Portrait</label>
|
||||||
Tout en markdown libre : stats, classe, backstory, équipement, objectifs personnels…
|
<app-single-image-picker
|
||||||
L'IA lira ces infos pour rester cohérente quand vous générez des scènes impliquant ce PJ.
|
[imageId]="portraitImageId"
|
||||||
</p>
|
aspectRatio="1 / 1"
|
||||||
<textarea
|
hint="Carre conseille (400×400)."
|
||||||
[(ngModel)]="markdownContent"
|
(imageIdChange)="portraitImageId = $event">
|
||||||
name="markdownContent"
|
</app-single-image-picker>
|
||||||
rows="22"
|
</div>
|
||||||
placeholder="# Thorin Grand-Hache **Race :** Nain **Classe :** Guerrier niveau 4 **PV :** 35 / 35 ## Stats - Force : 16 - Dextérité : 12 ... ## Backstory Originaire des montagnes du Nord, Thorin a fui son clan après..."
|
<div class="field header-field">
|
||||||
></textarea>
|
<label>Bandeau / Header</label>
|
||||||
|
<app-single-image-picker
|
||||||
|
[imageId]="headerImageId"
|
||||||
|
aspectRatio="3 / 1"
|
||||||
|
hint="Format paysage conseille (1200×400)."
|
||||||
|
(imageIdChange)="headerImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-fields">
|
||||||
|
<app-dynamic-fields-form
|
||||||
|
[fields]="templateFields"
|
||||||
|
[values]="values"
|
||||||
|
[imageValues]="imageValues"
|
||||||
|
(valuesChange)="values = $event"
|
||||||
|
(imageValuesChange)="imageValues = $event">
|
||||||
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -4,22 +4,28 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Save, ArrowLeft, User, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
import { Character } from '../../../services/character.model';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Éditeur plein écran d'une fiche de personnage (PJ).
|
* Editeur plein ecran d'une fiche de personnage (PJ).
|
||||||
* Double rôle création/édition :
|
* Refonte 2026-04-30 : remplace le markdown libre par un formulaire dynamique
|
||||||
* - `/campaigns/:campaignId/characters/create` → POST
|
* pilote par le characterTemplate du GameSystem associe a la campagne.
|
||||||
* - `/campaigns/:campaignId/characters/:characterId/edit` → PUT
|
|
||||||
*
|
*
|
||||||
* MVP : name + markdown libre. Évolution prévue vers un template dérivé
|
* Comportements :
|
||||||
* du GameSystem de la campagne (stats structurées).
|
* - Si la campagne n'a pas de GameSystem ou si son template est vide, affiche
|
||||||
|
* uniquement les champs universels (nom, portrait, header).
|
||||||
|
* - Le picker d'images dedie portrait/header est hors scope MVP — pour l'instant
|
||||||
|
* saisie manuelle d'IDs d'images.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-character-edit',
|
selector: 'app-character-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||||
templateUrl: './character-edit.component.html',
|
templateUrl: './character-edit.component.html',
|
||||||
styleUrls: ['./character-edit.component.scss']
|
styleUrls: ['./character-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -30,12 +36,11 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
/** État drawer chat IA focalisé sur ce PJ. */
|
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
readonly chatQuickSuggestions = [
|
readonly chatQuickSuggestions = [
|
||||||
'Propose une backstory cohérente avec l\'univers',
|
'Propose une backstory coherente avec l\'univers',
|
||||||
'Suggère 3 objectifs personnels pour ce personnage',
|
'Suggere 3 objectifs personnels pour ce personnage',
|
||||||
'Aide-moi à équilibrer les stats de combat'
|
'Aide-moi a equilibrer les stats de combat'
|
||||||
];
|
];
|
||||||
|
|
||||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
@@ -44,13 +49,19 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
characterId: string | null = null;
|
characterId: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
markdownContent = '';
|
portraitImageId: string | null = null;
|
||||||
|
headerImageId: string | null = null;
|
||||||
|
values: Record<string, string> = {};
|
||||||
|
imageValues: Record<string, string[]> = {};
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private service: CharacterService
|
private service: CharacterService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -58,11 +69,18 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
this.campaignId = params.get('campaignId');
|
this.campaignId = params.get('campaignId');
|
||||||
this.characterId = params.get('characterId');
|
this.characterId = params.get('characterId');
|
||||||
|
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.characterId) {
|
if (this.characterId) {
|
||||||
this.service.getById(this.characterId).subscribe({
|
this.service.getById(this.characterId).subscribe({
|
||||||
next: (c) => {
|
next: (c) => {
|
||||||
this.name = c.name;
|
this.name = c.name;
|
||||||
this.markdownContent = c.markdownContent ?? '';
|
this.portraitImageId = c.portraitImageId ?? null;
|
||||||
|
this.headerImageId = c.headerImageId ?? null;
|
||||||
|
this.values = c.values ?? {};
|
||||||
|
this.imageValues = c.imageValues ?? {};
|
||||||
this.order = c.order ?? 0;
|
this.order = c.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -70,21 +88,35 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadTemplateForCampaign(campaignId: string): void {
|
||||||
|
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||||
|
next: (campaign) => {
|
||||||
|
if (!campaign.gameSystemId) {
|
||||||
|
this.templateFields = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||||
|
next: (gs) => { this.templateFields = gs.characterTemplate ?? []; },
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
|
const payload = {
|
||||||
|
name: this.name.trim(),
|
||||||
|
portraitImageId: this.portraitImageId,
|
||||||
|
headerImageId: this.headerImageId,
|
||||||
|
values: this.values,
|
||||||
|
imageValues: this.imageValues,
|
||||||
|
campaignId: this.campaignId
|
||||||
|
};
|
||||||
const req = this.characterId
|
const req = this.characterId
|
||||||
? this.service.update(this.characterId, {
|
? this.service.update(this.characterId, { ...payload, id: this.characterId, order: this.order })
|
||||||
id: this.characterId,
|
: this.service.create(payload);
|
||||||
name: this.name.trim(),
|
|
||||||
markdownContent: this.markdownContent || null,
|
|
||||||
campaignId: this.campaignId,
|
|
||||||
order: this.order
|
|
||||||
})
|
|
||||||
: this.service.create({
|
|
||||||
name: this.name.trim(),
|
|
||||||
markdownContent: this.markdownContent || null,
|
|
||||||
campaignId: this.campaignId
|
|
||||||
});
|
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur sauvegarde Character')
|
error: () => console.error('Erreur sauvegarde Character')
|
||||||
@@ -93,7 +125,7 @@ export class CharacterEditComponent implements OnInit {
|
|||||||
|
|
||||||
deleteCharacter(): void {
|
deleteCharacter(): void {
|
||||||
if (!this.characterId) return;
|
if (!this.characterId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||||
this.service.delete(this.characterId).subscribe({
|
this.service.delete(this.characterId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Character')
|
error: () => console.error('Erreur suppression Character')
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="cv-page">
|
||||||
|
<div class="cv-toolbar">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="characterId">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
<button class="btn-edit" (click)="edit()">
|
||||||
|
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||||
|
Editer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-persona-view [persona]="character" [templateFields]="templateFields"></app-persona-view>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
*ngIf="characterId && campaignId"
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="character"
|
||||||
|
[entityId]="characterId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.cv-page {
|
||||||
|
padding: 16px 0 48px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 32px 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back,
|
||||||
|
.btn-edit,
|
||||||
|
.btn-ai {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
border-color: rgba(209, 168, 120, 0.4);
|
||||||
|
color: #d1a878;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(209, 168, 120, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai {
|
||||||
|
&.active {
|
||||||
|
background: rgba(168, 85, 247, 0.2);
|
||||||
|
border-color: rgba(168, 85, 247, 0.5);
|
||||||
|
color: #d8b4fe;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
|
||||||
|
import { CharacterService } from '../../../services/character.service';
|
||||||
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
|
import { Character } from '../../../services/character.model';
|
||||||
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue lecture seule "WorldAnvil" d'une fiche PJ.
|
||||||
|
* Route : /campaigns/:campaignId/characters/:characterId
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-character-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
|
||||||
|
templateUrl: './character-view.component.html',
|
||||||
|
styleUrls: ['./character-view.component.scss']
|
||||||
|
})
|
||||||
|
export class CharacterViewComponent implements OnInit {
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Edit3 = Edit3;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
campaignId: string | null = null;
|
||||||
|
characterId: string | null = null;
|
||||||
|
|
||||||
|
character: Character | null = null;
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
|
|
||||||
|
chatOpen = false;
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: CharacterService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const params = this.route.snapshot.paramMap;
|
||||||
|
this.campaignId = params.get('campaignId');
|
||||||
|
this.characterId = params.get('characterId');
|
||||||
|
if (this.characterId) {
|
||||||
|
this.service.getById(this.characterId).subscribe({
|
||||||
|
next: c => { this.character = c; },
|
||||||
|
error: () => this.back()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
|
if (camp.gameSystemId) {
|
||||||
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
this.templateFields = gs.characterTemplate ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(): void {
|
||||||
|
if (this.campaignId && this.characterId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'characters', this.characterId, 'edit']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/campaigns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,18 +35,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field content-field">
|
<div class="field-row image-row">
|
||||||
<label>Fiche (markdown)</label>
|
<div class="field portrait-field">
|
||||||
<p class="hint">
|
<label>Portrait</label>
|
||||||
Tout en markdown libre : apparence, motivations, faction, secrets, stats, notes MJ…
|
<app-single-image-picker
|
||||||
À terme, l'IA pourra exploiter ces infos pour incarner le PNJ avec cohérence dans les scènes.
|
[imageId]="portraitImageId"
|
||||||
</p>
|
aspectRatio="1 / 1"
|
||||||
<textarea
|
hint="Carre conseille (400×400)."
|
||||||
[(ngModel)]="markdownContent"
|
(imageIdChange)="portraitImageId = $event">
|
||||||
name="markdownContent"
|
</app-single-image-picker>
|
||||||
rows="22"
|
</div>
|
||||||
placeholder="# Borin le forgeron **Race :** Nain **Faction :** Clan Feuillefer **Statut :** Vivant ## Apparence Barbe rousse tressée, tablier de cuir brûlé... ## Motivations Venger son clan décimé par les orcs il y a 10 hivers. ## Notes MJ (secret) Connaît l'emplacement du marteau de Durin..."
|
<div class="field header-field">
|
||||||
></textarea>
|
<label>Bandeau / Header</label>
|
||||||
|
<app-single-image-picker
|
||||||
|
[imageId]="headerImageId"
|
||||||
|
aspectRatio="3 / 1"
|
||||||
|
hint="Format paysage conseille (1200×400)."
|
||||||
|
(imageIdChange)="headerImageId = $event">
|
||||||
|
</app-single-image-picker>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-fields">
|
||||||
|
<app-dynamic-fields-form
|
||||||
|
[fields]="templateFields"
|
||||||
|
[values]="values"
|
||||||
|
[imageValues]="imageValues"
|
||||||
|
(valuesChange)="values = $event"
|
||||||
|
(imageValuesChange)="imageValues = $event">
|
||||||
|
</app-dynamic-fields-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -4,21 +4,23 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
import { LucideAngularModule, Save, ArrowLeft, Drama, Trash2, Sparkles } from 'lucide-angular';
|
||||||
import { NpcService } from '../../../services/npc.service';
|
import { NpcService } from '../../../services/npc.service';
|
||||||
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
import { DynamicFieldsFormComponent } from '../../../shared/dynamic-fields-form/dynamic-fields-form.component';
|
||||||
|
import { SingleImagePickerComponent } from '../../../shared/single-image-picker/single-image-picker.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Éditeur plein écran d'une fiche de PNJ.
|
* Editeur plein ecran d'une fiche de PNJ.
|
||||||
* Double rôle création/édition :
|
* Refonte 2026-04-30 : meme principe que CharacterEditComponent — markdown
|
||||||
* - `/campaigns/:campaignId/npcs/create` → POST
|
* libre remplace par un formulaire dynamique pilote par le npcTemplate du
|
||||||
* - `/campaigns/:campaignId/npcs/:npcId/edit` → PUT
|
* GameSystem associe a la campagne.
|
||||||
*
|
|
||||||
* MVP : name + markdown libre. L'Assistant IA est branché en mode édition
|
|
||||||
* (focus entityType="npc") pour proposer apparence, motivations, secrets...
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-npc-edit',
|
selector: 'app-npc-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent],
|
imports: [CommonModule, FormsModule, LucideAngularModule, AiChatDrawerComponent, DynamicFieldsFormComponent, SingleImagePickerComponent],
|
||||||
templateUrl: './npc-edit.component.html',
|
templateUrl: './npc-edit.component.html',
|
||||||
styleUrls: ['./npc-edit.component.scss']
|
styleUrls: ['./npc-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -29,12 +31,11 @@ export class NpcEditComponent implements OnInit {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Sparkles = Sparkles;
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
/** État drawer chat IA focalisé sur ce PNJ. */
|
|
||||||
chatOpen = false;
|
chatOpen = false;
|
||||||
readonly chatQuickSuggestions = [
|
readonly chatQuickSuggestions = [
|
||||||
'Propose une apparence et une posture marquantes',
|
'Propose une apparence et une posture marquantes',
|
||||||
'Suggère 2 motivations et un secret pour ce PNJ',
|
'Suggere 2 motivations et un secret pour ce PNJ',
|
||||||
'Imagine 3 répliques signatures qui le caractérisent'
|
'Imagine 3 repliques signatures qui le caracterisent'
|
||||||
];
|
];
|
||||||
|
|
||||||
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
@@ -43,13 +44,19 @@ export class NpcEditComponent implements OnInit {
|
|||||||
npcId: string | null = null;
|
npcId: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
markdownContent = '';
|
portraitImageId: string | null = null;
|
||||||
|
headerImageId: string | null = null;
|
||||||
|
values: Record<string, string> = {};
|
||||||
|
imageValues: Record<string, string[]> = {};
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
private order = 0;
|
private order = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private service: NpcService
|
private service: NpcService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -57,11 +64,18 @@ export class NpcEditComponent implements OnInit {
|
|||||||
this.campaignId = params.get('campaignId');
|
this.campaignId = params.get('campaignId');
|
||||||
this.npcId = params.get('npcId');
|
this.npcId = params.get('npcId');
|
||||||
|
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.loadTemplateForCampaign(this.campaignId);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.npcId) {
|
if (this.npcId) {
|
||||||
this.service.getById(this.npcId).subscribe({
|
this.service.getById(this.npcId).subscribe({
|
||||||
next: (n) => {
|
next: (n) => {
|
||||||
this.name = n.name;
|
this.name = n.name;
|
||||||
this.markdownContent = n.markdownContent ?? '';
|
this.portraitImageId = n.portraitImageId ?? null;
|
||||||
|
this.headerImageId = n.headerImageId ?? null;
|
||||||
|
this.values = n.values ?? {};
|
||||||
|
this.imageValues = n.imageValues ?? {};
|
||||||
this.order = n.order ?? 0;
|
this.order = n.order ?? 0;
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
@@ -69,21 +83,35 @@ export class NpcEditComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadTemplateForCampaign(campaignId: string): void {
|
||||||
|
this.campaignService.getCampaignById(campaignId).subscribe({
|
||||||
|
next: (campaign) => {
|
||||||
|
if (!campaign.gameSystemId) {
|
||||||
|
this.templateFields = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.gameSystemService.getById(campaign.gameSystemId).subscribe({
|
||||||
|
next: (gs) => { this.templateFields = gs.npcTemplate ?? []; },
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error: () => { this.templateFields = []; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim() || !this.campaignId) return;
|
if (!this.name.trim() || !this.campaignId) return;
|
||||||
|
const payload = {
|
||||||
|
name: this.name.trim(),
|
||||||
|
portraitImageId: this.portraitImageId,
|
||||||
|
headerImageId: this.headerImageId,
|
||||||
|
values: this.values,
|
||||||
|
imageValues: this.imageValues,
|
||||||
|
campaignId: this.campaignId
|
||||||
|
};
|
||||||
const req = this.npcId
|
const req = this.npcId
|
||||||
? this.service.update(this.npcId, {
|
? this.service.update(this.npcId, { ...payload, id: this.npcId, order: this.order })
|
||||||
id: this.npcId,
|
: this.service.create(payload);
|
||||||
name: this.name.trim(),
|
|
||||||
markdownContent: this.markdownContent || null,
|
|
||||||
campaignId: this.campaignId,
|
|
||||||
order: this.order
|
|
||||||
})
|
|
||||||
: this.service.create({
|
|
||||||
name: this.name.trim(),
|
|
||||||
markdownContent: this.markdownContent || null,
|
|
||||||
campaignId: this.campaignId
|
|
||||||
});
|
|
||||||
req.subscribe({
|
req.subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur sauvegarde Npc')
|
error: () => console.error('Erreur sauvegarde Npc')
|
||||||
@@ -92,7 +120,7 @@ export class NpcEditComponent implements OnInit {
|
|||||||
|
|
||||||
deleteNpc(): void {
|
deleteNpc(): void {
|
||||||
if (!this.npcId) return;
|
if (!this.npcId) return;
|
||||||
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irréversible.`)) return;
|
if (!confirm(`Supprimer la fiche de "${this.name}" ? Cette action est irreversible.`)) return;
|
||||||
this.service.delete(this.npcId).subscribe({
|
this.service.delete(this.npcId).subscribe({
|
||||||
next: () => this.back(),
|
next: () => this.back(),
|
||||||
error: () => console.error('Erreur suppression Npc')
|
error: () => console.error('Erreur suppression Npc')
|
||||||
|
|||||||
28
web/src/app/campaigns/npc/npc-view/npc-view.component.html
Normal file
28
web/src/app/campaigns/npc/npc-view/npc-view.component.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<div class="nv-page">
|
||||||
|
<div class="nv-toolbar">
|
||||||
|
<button class="btn-back" (click)="back()">
|
||||||
|
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn-ai" (click)="toggleChat()" [class.active]="chatOpen" *ngIf="npcId">
|
||||||
|
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||||
|
Assistant IA
|
||||||
|
</button>
|
||||||
|
<button class="btn-edit" (click)="edit()">
|
||||||
|
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||||
|
Editer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-persona-view [persona]="npc" [templateFields]="templateFields"></app-persona-view>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-ai-chat-drawer
|
||||||
|
*ngIf="npcId && campaignId"
|
||||||
|
[campaignId]="campaignId"
|
||||||
|
entityType="npc"
|
||||||
|
[entityId]="npcId"
|
||||||
|
[isOpen]="chatOpen"
|
||||||
|
(close)="chatOpen = false">
|
||||||
|
</app-ai-chat-drawer>
|
||||||
51
web/src/app/campaigns/npc/npc-view/npc-view.component.scss
Normal file
51
web/src/app/campaigns/npc/npc-view/npc-view.component.scss
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
.nv-page {
|
||||||
|
padding: 16px 0 48px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 32px 16px;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back,
|
||||||
|
.btn-edit,
|
||||||
|
.btn-ai {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
border-color: rgba(209, 168, 120, 0.4);
|
||||||
|
color: #d1a878;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(209, 168, 120, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ai.active {
|
||||||
|
background: rgba(168, 85, 247, 0.2);
|
||||||
|
border-color: rgba(168, 85, 247, 0.5);
|
||||||
|
color: #d8b4fe;
|
||||||
|
}
|
||||||
80
web/src/app/campaigns/npc/npc-view/npc-view.component.ts
Normal file
80
web/src/app/campaigns/npc/npc-view/npc-view.component.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { LucideAngularModule, ArrowLeft, Edit3, Sparkles } from 'lucide-angular';
|
||||||
|
import { NpcService } from '../../../services/npc.service';
|
||||||
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
|
import { GameSystemService } from '../../../services/game-system.service';
|
||||||
|
import { TemplateField } from '../../../services/template.model';
|
||||||
|
import { Npc } from '../../../services/npc.model';
|
||||||
|
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||||
|
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
|
||||||
|
* Route : /campaigns/:campaignId/npcs/:npcId
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-npc-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule, PersonaViewComponent, AiChatDrawerComponent],
|
||||||
|
templateUrl: './npc-view.component.html',
|
||||||
|
styleUrls: ['./npc-view.component.scss']
|
||||||
|
})
|
||||||
|
export class NpcViewComponent implements OnInit {
|
||||||
|
readonly ArrowLeft = ArrowLeft;
|
||||||
|
readonly Edit3 = Edit3;
|
||||||
|
readonly Sparkles = Sparkles;
|
||||||
|
|
||||||
|
campaignId: string | null = null;
|
||||||
|
npcId: string | null = null;
|
||||||
|
|
||||||
|
npc: Npc | null = null;
|
||||||
|
templateFields: TemplateField[] = [];
|
||||||
|
|
||||||
|
chatOpen = false;
|
||||||
|
toggleChat(): void { this.chatOpen = !this.chatOpen; }
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private service: NpcService,
|
||||||
|
private campaignService: CampaignService,
|
||||||
|
private gameSystemService: GameSystemService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const params = this.route.snapshot.paramMap;
|
||||||
|
this.campaignId = params.get('campaignId');
|
||||||
|
this.npcId = params.get('npcId');
|
||||||
|
if (this.npcId) {
|
||||||
|
this.service.getById(this.npcId).subscribe({
|
||||||
|
next: n => { this.npc = n; },
|
||||||
|
error: () => this.back()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.campaignService.getCampaignById(this.campaignId).subscribe(camp => {
|
||||||
|
if (camp.gameSystemId) {
|
||||||
|
this.gameSystemService.getById(camp.gameSystemId).subscribe(gs => {
|
||||||
|
this.templateFields = gs.npcTemplate ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edit(): void {
|
||||||
|
if (this.campaignId && this.npcId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId, 'npcs', this.npcId, 'edit']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
back(): void {
|
||||||
|
if (this.campaignId) {
|
||||||
|
this.router.navigate(['/campaigns', this.campaignId]);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/campaigns']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,6 +90,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Templates de fiches PJ/PNJ -->
|
||||||
|
<div class="templates-area">
|
||||||
|
<h2 class="sections-title">Fiches de personnages</h2>
|
||||||
|
<p class="sections-hint">
|
||||||
|
Definissez la structure des fiches PJ et PNJ pour ce systeme. Les champs
|
||||||
|
universels (nom, portrait, header) sont automatiques — ne rajoutez ici
|
||||||
|
que les champs specifiques au systeme (Histoire, PV, Stats…).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<app-template-fields-editor
|
||||||
|
label="Champs de la fiche PJ"
|
||||||
|
hint="Affiches lors de la creation/edition d'un personnage joueur."
|
||||||
|
[fields]="characterTemplate"
|
||||||
|
[suggestions]="characterFieldSuggestions"
|
||||||
|
(fieldsChange)="characterTemplate = $event">
|
||||||
|
</app-template-fields-editor>
|
||||||
|
|
||||||
|
<app-template-fields-editor
|
||||||
|
label="Champs de la fiche PNJ"
|
||||||
|
hint="Affiches lors de la creation/edition d'un personnage non-joueur."
|
||||||
|
[fields]="npcTemplate"
|
||||||
|
[suggestions]="npcFieldSuggestions"
|
||||||
|
(fieldsChange)="npcTemplate = $event">
|
||||||
|
</app-template-fields-editor>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
<button type="button" class="btn-primary" [disabled]="!name.trim()" (click)="submit()">
|
||||||
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.templates-area {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.gse-header {
|
.gse-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-angular';
|
||||||
import { GameSystemService } from '../../services/game-system.service';
|
import { GameSystemService } from '../../services/game-system.service';
|
||||||
|
import { TemplateField } from '../../services/template.model';
|
||||||
|
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
|
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
|
||||||
@@ -31,10 +33,16 @@ const SUGGESTED_SECTIONS = [
|
|||||||
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
|
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
|
||||||
|
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
|
||||||
|
|
||||||
|
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
|
||||||
|
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-game-system-edit',
|
selector: 'app-game-system-edit',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
imports: [CommonModule, FormsModule, LucideAngularModule, TemplateFieldsEditorComponent],
|
||||||
templateUrl: './game-system-edit.component.html',
|
templateUrl: './game-system-edit.component.html',
|
||||||
styleUrls: ['./game-system-edit.component.scss']
|
styleUrls: ['./game-system-edit.component.scss']
|
||||||
})
|
})
|
||||||
@@ -53,8 +61,12 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
description = '';
|
description = '';
|
||||||
author = '';
|
author = '';
|
||||||
sections: RuleSection[] = [];
|
sections: RuleSection[] = [];
|
||||||
|
characterTemplate: TemplateField[] = [];
|
||||||
|
npcTemplate: TemplateField[] = [];
|
||||||
|
|
||||||
readonly suggestedSections = SUGGESTED_SECTIONS;
|
readonly suggestedSections = SUGGESTED_SECTIONS;
|
||||||
|
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
|
||||||
|
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -71,6 +83,8 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
this.description = gs.description ?? '';
|
this.description = gs.description ?? '';
|
||||||
this.author = gs.author ?? '';
|
this.author = gs.author ?? '';
|
||||||
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
|
||||||
|
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
|
||||||
|
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
});
|
});
|
||||||
@@ -104,11 +118,17 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
if (!this.name.trim()) return;
|
if (!this.name.trim()) return;
|
||||||
|
if (this.hasInvalidTemplateFields()) {
|
||||||
|
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
name: this.name.trim(),
|
name: this.name.trim(),
|
||||||
description: this.description.trim() || null,
|
description: this.description.trim() || null,
|
||||||
author: this.author.trim() || null,
|
author: this.author.trim() || null,
|
||||||
rulesMarkdown: this.serializeMarkdown(),
|
rulesMarkdown: this.serializeMarkdown(),
|
||||||
|
characterTemplate: this.characterTemplate,
|
||||||
|
npcTemplate: this.npcTemplate,
|
||||||
isPublic: false
|
isPublic: false
|
||||||
};
|
};
|
||||||
const req = this.id
|
const req = this.id
|
||||||
@@ -124,6 +144,22 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
this.router.navigate(['/game-systems']);
|
this.router.navigate(['/game-systems']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validation cote front : nom vide ou doublons (case-insensitive). */
|
||||||
|
private hasInvalidTemplateFields(): boolean {
|
||||||
|
return this.hasInvalidList(this.characterTemplate) || this.hasInvalidList(this.npcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasInvalidList(fields: TemplateField[]): boolean {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const f of fields) {
|
||||||
|
const name = f.name?.trim().toLowerCase();
|
||||||
|
if (!name) return true;
|
||||||
|
if (seen.has(name)) return true;
|
||||||
|
seen.add(name);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Parse / Serialize markdown ------------------------------------------
|
// --- Parse / Serialize markdown ------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* Fiche de personnage joueur (PJ) d'une campagne.
|
* Fiche de personnage joueur (PJ) d'une campagne.
|
||||||
* MVP : markdownContent libre. Évolution prévue vers des fiches templatées
|
* Refonte 2026-04-30 : abandon du markdownContent au profit d'un systeme
|
||||||
* par GameSystem (stats structurées selon le JDR joué).
|
* template-based pilote par le GameSystem de la campagne.
|
||||||
|
* - portraitImageId / headerImageId : champs universels hard-codes
|
||||||
|
* - values : Map<champ template TEXT/NUMBER, valeur>
|
||||||
|
* - imageValues : Map<champ template IMAGE, liste d'IDs d'images>
|
||||||
*/
|
*/
|
||||||
export interface Character {
|
export interface Character {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
markdownContent?: string | null;
|
portraitImageId?: string | null;
|
||||||
|
headerImageId?: string | null;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
imageValues?: Record<string, string[]>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CharacterCreate {
|
export interface CharacterCreate {
|
||||||
name: string;
|
name: string;
|
||||||
markdownContent?: string | null;
|
portraitImageId?: string | null;
|
||||||
|
headerImageId?: string | null;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
imageValues?: Record<string, string[]>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
|
import { TemplateField } from './template.model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
* Interface TypeScript pour GameSystemDTO (jumeau du DTO Java).
|
||||||
*
|
*
|
||||||
* rulesMarkdown : markdown monolithique, sections découpées par titres H2
|
* rulesMarkdown : markdown monolithique, sections decoupees par titres H2.
|
||||||
* (## Combat, ## Classes, etc.). Le découpage et la sélection des sections
|
* characterTemplate / npcTemplate : champs templates pilotant le rendu des
|
||||||
* à injecter dans le prompt IA sont faits côté backend Java.
|
* fiches PJ / PNJ d'une campagne adossee a ce systeme (cf. refonte 2026-04-30).
|
||||||
*/
|
*/
|
||||||
export interface GameSystem {
|
export interface GameSystem {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
rulesMarkdown?: string | null;
|
rulesMarkdown?: string | null;
|
||||||
|
characterTemplate?: TemplateField[];
|
||||||
|
npcTemplate?: TemplateField[];
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload de création/mise à jour (sans id). */
|
/** Payload de creation/mise a jour (sans id). */
|
||||||
export interface GameSystemCreate {
|
export interface GameSystemCreate {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
rulesMarkdown?: string | null;
|
rulesMarkdown?: string | null;
|
||||||
|
characterTemplate?: TemplateField[];
|
||||||
|
npcTemplate?: TemplateField[];
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export interface BetaStatusDTO {
|
|||||||
anyUnknown: boolean;
|
anyUnknown: boolean;
|
||||||
images: Array<{
|
images: Array<{
|
||||||
image: string;
|
image: string;
|
||||||
localDigest: string | null;
|
localVersion: string | null;
|
||||||
remoteDigest: string | null;
|
remoteVersion: string | null;
|
||||||
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
* Fiche de personnage non-joueur (PNJ) d'une campagne.
|
||||||
* MVP : markdownContent libre (description, motivation, stats, notes MJ).
|
* Refonte 2026-04-30 : meme structure que Character (template-based).
|
||||||
* Évolution prévue : templating partagé PJ/PNJ piloté par GameSystem.
|
|
||||||
*/
|
*/
|
||||||
export interface Npc {
|
export interface Npc {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
markdownContent?: string | null;
|
portraitImageId?: string | null;
|
||||||
|
headerImageId?: string | null;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
imageValues?: Record<string, string[]>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NpcCreate {
|
export interface NpcCreate {
|
||||||
name: string;
|
name: string;
|
||||||
markdownContent?: string | null;
|
portraitImageId?: string | null;
|
||||||
|
headerImageId?: string | null;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
imageValues?: Record<string, string[]>;
|
||||||
campaignId: string;
|
campaignId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// Interfaces TypeScript pour TemplateDTO (Backend Java).
|
// Interfaces TypeScript pour TemplateDTO (Backend Java).
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type d'un champ de Template. Miroir de com.loremind.domain.lorecontext.FieldType.
|
* Type d'un champ de Template. Miroir de com.loremind.domain.shared.template.FieldType.
|
||||||
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
* - 'TEXT' : champ textuel libre (rendu en textarea)
|
||||||
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
* - 'IMAGE' : galerie d'images (rendu en app-image-gallery)
|
||||||
|
* - 'NUMBER' : valeur numerique (rendu en input number)
|
||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE';
|
export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Variante de rendu pour un champ IMAGE. Miroir de
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
|
|||||||
@@ -3,19 +3,18 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
|
import { BehaviorSubject, Observable, catchError, of, tap } from 'rxjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflet de UpdateCheckService.UpdateStatus cote backend.
|
* Reflet de UpdateCheckService.UpdateStatus cote backend (post-refactor v0.8.x).
|
||||||
*
|
*
|
||||||
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
|
* Etat tri-state par image : UP_TO_DATE / UPDATE_AVAILABLE / UNKNOWN.
|
||||||
* UNKNOWN signale que la comparaison est impossible (baseline absente ou
|
* Comparaison faite par version semver (BuildProperties cote backend), plus
|
||||||
* remote injoignable) — l'UI doit afficher un avertissement plutot que
|
* fiable que les digests qui ne survivaient pas a un restart sans pull.
|
||||||
* d'annoncer "a jour" silencieusement.
|
|
||||||
*/
|
*/
|
||||||
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
export type ImageStatusKind = 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||||
|
|
||||||
export interface ImageStatus {
|
export interface ImageStatus {
|
||||||
image: string;
|
image: string;
|
||||||
localDigest: string | null;
|
localVersion: string | null;
|
||||||
remoteDigest: string | null;
|
remoteVersion: string | null;
|
||||||
status: ImageStatusKind;
|
status: ImageStatusKind;
|
||||||
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
|
/** Conserve pour back-compat ; equivalent a (status === 'UPDATE_AVAILABLE'). */
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
@@ -27,6 +26,8 @@ export interface UpdateStatus {
|
|||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
/** True si au moins une image a status === 'UNKNOWN'. */
|
/** True si au moins une image a status === 'UNKNOWN'. */
|
||||||
anyUnknown: boolean;
|
anyUnknown: boolean;
|
||||||
|
/** Version courante du binaire (BuildProperties). null si build-info absent. */
|
||||||
|
currentVersion: string | null;
|
||||||
images: ImageStatus[];
|
images: ImageStatus[];
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
94
web/src/app/services/version-checker.service.ts
Normal file
94
web/src/app/services/version-checker.service.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecte qu'une nouvelle version de LoreMind a ete deployee pendant qu'un
|
||||||
|
* onglet utilisateur est deja ouvert.
|
||||||
|
*
|
||||||
|
* Strategie : polling toutes les {@link POLL_INTERVAL_MS} sur /api/version.
|
||||||
|
* La version observee au boot est figee comme reference. Si la version polled
|
||||||
|
* differe, {@link hasUpdate} passe a true et l'UI peut afficher un bandeau
|
||||||
|
* proposant un reload.
|
||||||
|
*
|
||||||
|
* Pourquoi pas un Service Worker ? Trop lourd pour le besoin (offline pas
|
||||||
|
* pertinent, lifecycle complexe). Polling simple = ~15 lignes, fait le job.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VersionCheckerService {
|
||||||
|
private static readonly POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
private static readonly INITIAL_DELAY_MS = 30 * 1000; // 30s avant premier poll
|
||||||
|
|
||||||
|
private readonly _bootVersion = signal<string | null>(null);
|
||||||
|
private readonly _remoteVersion = signal<string | null>(null);
|
||||||
|
private readonly _hasUpdate = computed(
|
||||||
|
() => {
|
||||||
|
const boot = this._bootVersion();
|
||||||
|
const remote = this._remoteVersion();
|
||||||
|
return boot !== null && remote !== null && boot !== remote;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/** True quand la version remote differe de celle observee au boot. */
|
||||||
|
readonly hasUpdate = this._hasUpdate;
|
||||||
|
/** Version observee a l'ouverture du tab (figee). */
|
||||||
|
readonly bootVersion = this._bootVersion.asReadonly();
|
||||||
|
/** Derniere version observee sur le backend. */
|
||||||
|
readonly remoteVersion = this._remoteVersion.asReadonly();
|
||||||
|
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Demarre le polling. A appeler une fois au boot de l'app.
|
||||||
|
* Ignore si deja demarre (idempotent).
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
if (this.timer !== null) return;
|
||||||
|
void this.fetchInitial();
|
||||||
|
this.timer = setInterval(
|
||||||
|
() => void this.poll(),
|
||||||
|
VersionCheckerService.POLL_INTERVAL_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
clearInterval(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recharge l'app (force re-fetch index.html + assets). */
|
||||||
|
reload(): void {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchInitial(): Promise<void> {
|
||||||
|
// Petit delai pour laisser le bootstrap se finir avant le premier appel.
|
||||||
|
await new Promise(r => setTimeout(r, VersionCheckerService.INITIAL_DELAY_MS));
|
||||||
|
const v = await this.fetchVersion();
|
||||||
|
if (v && this._bootVersion() === null) {
|
||||||
|
this._bootVersion.set(v);
|
||||||
|
this._remoteVersion.set(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async poll(): Promise<void> {
|
||||||
|
const v = await this.fetchVersion();
|
||||||
|
if (v) this._remoteVersion.set(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchVersion(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const resp = await firstValueFrom(
|
||||||
|
this.http.get<{ version: string }>('/api/version'),
|
||||||
|
);
|
||||||
|
return resp?.version ?? null;
|
||||||
|
} catch {
|
||||||
|
// Backend down / restart en cours — on ignore, on retentera au prochain tick
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="dff" *ngIf="fields?.length; else emptyTpl">
|
||||||
|
<div class="dff-field" *ngFor="let f of fields; trackBy: trackByName">
|
||||||
|
<label>{{ f.name }}</label>
|
||||||
|
|
||||||
|
<ng-container [ngSwitch]="f.type">
|
||||||
|
<textarea
|
||||||
|
*ngSwitchCase="'TEXT'"
|
||||||
|
rows="4"
|
||||||
|
[ngModel]="values[f.name] || ''"
|
||||||
|
(ngModelChange)="onTextChange(f, $event)"
|
||||||
|
[name]="'val-' + f.name"
|
||||||
|
placeholder="Renseignez {{ f.name }}…">
|
||||||
|
</textarea>
|
||||||
|
|
||||||
|
<input
|
||||||
|
*ngSwitchCase="'NUMBER'"
|
||||||
|
type="number"
|
||||||
|
[ngModel]="values[f.name] || ''"
|
||||||
|
(ngModelChange)="onTextChange(f, $event)"
|
||||||
|
[name]="'val-' + f.name"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<app-image-gallery
|
||||||
|
*ngSwitchCase="'IMAGE'"
|
||||||
|
[editable]="true"
|
||||||
|
[layout]="f.layout || 'GALLERY'"
|
||||||
|
[imageIds]="imagesFor(f)"
|
||||||
|
(imageIdsChange)="onImageIdsChange(f, $event)">
|
||||||
|
</app-image-gallery>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #emptyTpl>
|
||||||
|
<div class="dff-empty">
|
||||||
|
Aucun champ defini dans le template de ce systeme. Editez le GameSystem pour ajouter des champs.
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.dff {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dff-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-mvp {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dff-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { TemplateField } from '../../services/template.model';
|
||||||
|
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formulaire dynamique pilote par une liste de TemplateField.
|
||||||
|
*
|
||||||
|
* Inputs :
|
||||||
|
* - fields : structure (provient du GameSystem.characterTemplate / npcTemplate)
|
||||||
|
* - values : Record<champName, string> pour les types TEXT et NUMBER
|
||||||
|
* - imageValues : Record<champName, string[]> pour le type IMAGE
|
||||||
|
*
|
||||||
|
* Pour les champs IMAGE, delegue au composant <app-image-gallery editable>
|
||||||
|
* qui gere l'upload, la suppression et le respect du layout.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dynamic-fields-form',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ImageGalleryComponent],
|
||||||
|
templateUrl: './dynamic-fields-form.component.html',
|
||||||
|
styleUrls: ['./dynamic-fields-form.component.scss']
|
||||||
|
})
|
||||||
|
export class DynamicFieldsFormComponent {
|
||||||
|
@Input() fields: TemplateField[] = [];
|
||||||
|
@Input() values: Record<string, string> = {};
|
||||||
|
@Input() imageValues: Record<string, string[]> = {};
|
||||||
|
|
||||||
|
@Output() valuesChange = new EventEmitter<Record<string, string>>();
|
||||||
|
@Output() imageValuesChange = new EventEmitter<Record<string, string[]>>();
|
||||||
|
|
||||||
|
onTextChange(field: TemplateField, value: string): void {
|
||||||
|
this.values = { ...this.values, [field.name]: value };
|
||||||
|
this.valuesChange.emit(this.values);
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageIdsChange(field: TemplateField, ids: string[]): void {
|
||||||
|
this.imageValues = { ...this.imageValues, [field.name]: ids };
|
||||||
|
this.imageValuesChange.emit(this.imageValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesFor(field: TemplateField): string[] {
|
||||||
|
return this.imageValues[field.name] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByName = (_: number, f: TemplateField) => f.name;
|
||||||
|
}
|
||||||
60
web/src/app/shared/persona-view/persona-view.component.html
Normal file
60
web/src/app/shared/persona-view/persona-view.component.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<div class="pv" *ngIf="persona">
|
||||||
|
|
||||||
|
<!-- Bandeau / Header -->
|
||||||
|
<div class="pv-banner" *ngIf="persona.headerImageId">
|
||||||
|
<img [src]="contentUrl(persona.headerImageId)" alt="" />
|
||||||
|
<div class="pv-banner-fade"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- En-tete : portrait + titre -->
|
||||||
|
<div class="pv-hero" [class.no-banner]="!persona.headerImageId">
|
||||||
|
<div class="pv-portrait" *ngIf="persona.portraitImageId">
|
||||||
|
<img [src]="contentUrl(persona.portraitImageId)" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pv-title-block">
|
||||||
|
<h1 class="pv-name">{{ persona.name }}</h1>
|
||||||
|
<p *ngIf="subtitle" class="pv-subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats numeriques en bandeau si presentes -->
|
||||||
|
<div *ngIf="textFields.length && hasAnyNumber(textFields)" class="pv-stat-band">
|
||||||
|
<div *ngFor="let f of textFields" class="pv-stat" [class.pv-stat-number]="f.isNumber">
|
||||||
|
<ng-container *ngIf="f.isNumber">
|
||||||
|
<span class="pv-stat-label">{{ f.name }}</span>
|
||||||
|
<span class="pv-stat-value">{{ f.value }}</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sections texte -->
|
||||||
|
<div class="pv-sections">
|
||||||
|
<ng-container *ngFor="let f of textFields; let first = first">
|
||||||
|
<section *ngIf="!f.isNumber" class="pv-section">
|
||||||
|
<h2 class="pv-section-title">{{ f.name }}</h2>
|
||||||
|
<div class="pv-section-body">
|
||||||
|
<p [class.with-dropcap]="first" class="pv-paragraph">
|
||||||
|
{{ firstParagraph(f.value) }}
|
||||||
|
</p>
|
||||||
|
<p *ngIf="restAfterFirstParagraph(f.value)" class="pv-paragraph">
|
||||||
|
{{ restAfterFirstParagraph(f.value) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Galeries d'images templates -->
|
||||||
|
<section *ngFor="let img of imageFields" class="pv-section pv-section-images">
|
||||||
|
<h2 class="pv-section-title">{{ img.field.name }}</h2>
|
||||||
|
<app-image-gallery [imageIds]="img.ids" [layout]="img.field.layout || 'GALLERY'" [editable]="false">
|
||||||
|
</app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Etat vide -->
|
||||||
|
<div *ngIf="textFields.length === 0 && imageFields.length === 0" class="pv-empty">
|
||||||
|
<lucide-icon [img]="BookOpen" [size]="32"></lucide-icon>
|
||||||
|
<p>Cette fiche est encore vide.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
228
web/src/app/shared/persona-view/persona-view.component.scss
Normal file
228
web/src/app/shared/persona-view/persona-view.component.scss
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// Vue WorldAnvil-style : bandeau, portrait latteral, sections elegantes, drop cap.
|
||||||
|
|
||||||
|
.pv {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bandeau ----------------------------------------------------------------
|
||||||
|
|
||||||
|
.pv-banner {
|
||||||
|
position: relative;
|
||||||
|
height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-banner-fade {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 60%,
|
||||||
|
rgba(15, 17, 23, 0.85) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hero (portrait + titre) ------------------------------------------------
|
||||||
|
|
||||||
|
.pv-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
gap: 28px;
|
||||||
|
padding: 20px 32px;
|
||||||
|
margin-top: -90px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&.no-banner {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-portrait {
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
background: #1a1d24;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-title-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-name {
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin: 0;
|
||||||
|
color: #f3f4f6;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-subtitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #b5b9c4;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bandeau de stats (NUMBER) ---------------------------------------------
|
||||||
|
|
||||||
|
.pv-stat-band {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
margin: 16px 32px 0;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-stat {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.pv-stat-number {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-stat-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-stat-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f3f4f6;
|
||||||
|
font-family: 'Cinzel', Georgia, serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sections ---------------------------------------------------------------
|
||||||
|
|
||||||
|
.pv-sections {
|
||||||
|
padding: 32px 32px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-section {
|
||||||
|
margin-bottom: 36px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-section-title {
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #d1a878;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(209, 168, 120, 0.25);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Petit ornement central
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -3px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: #d1a878;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-section-body {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: #d6d8de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-paragraph {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
&.with-dropcap::first-letter {
|
||||||
|
float: left;
|
||||||
|
font-family: 'Cinzel', 'EB Garamond', Georgia, serif;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
line-height: 0.9;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #d1a878;
|
||||||
|
padding: 4px 8px 0 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Etat vide --------------------------------------------------------------
|
||||||
|
|
||||||
|
.pv-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-style: italic;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Responsive -------------------------------------------------------------
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.pv-hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
margin-top: -60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-portrait {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-name {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pv-banner {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
web/src/app/shared/persona-view/persona-view.component.ts
Normal file
88
web/src/app/shared/persona-view/persona-view.component.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LucideAngularModule, BookOpen } from 'lucide-angular';
|
||||||
|
import { TemplateField } from '../../services/template.model';
|
||||||
|
import { ImageService } from '../../services/image.service';
|
||||||
|
import { ImageGalleryComponent } from '../image-gallery/image-gallery.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affichage type "WorldAnvil" d'une fiche PJ ou PNJ.
|
||||||
|
*
|
||||||
|
* Layout :
|
||||||
|
* - Bandeau (headerImageId) en haut, pleine largeur
|
||||||
|
* - Bloc 2 colonnes : portrait a gauche, infos textuelles a droite
|
||||||
|
* - Sections suivantes pour chaque champ template TEXT/NUMBER/IMAGE
|
||||||
|
* - Drop cap sur la 1re lettre du 1er paragraphe TEXT
|
||||||
|
*
|
||||||
|
* Composant pur de presentation : ne fetche rien, recoit (persona, templateFields).
|
||||||
|
*/
|
||||||
|
export interface PersonaLike {
|
||||||
|
name: string;
|
||||||
|
portraitImageId?: string | null;
|
||||||
|
headerImageId?: string | null;
|
||||||
|
values?: Record<string, string>;
|
||||||
|
imageValues?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-persona-view',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule, ImageGalleryComponent],
|
||||||
|
templateUrl: './persona-view.component.html',
|
||||||
|
styleUrls: ['./persona-view.component.scss']
|
||||||
|
})
|
||||||
|
export class PersonaViewComponent {
|
||||||
|
readonly BookOpen = BookOpen;
|
||||||
|
|
||||||
|
@Input() persona: PersonaLike | null = null;
|
||||||
|
@Input() templateFields: TemplateField[] = [];
|
||||||
|
|
||||||
|
/** Sous-titre optionnel sous le nom (ex: "Champion d'Aerimor"). */
|
||||||
|
@Input() subtitle?: string;
|
||||||
|
|
||||||
|
constructor(private imageService: ImageService) {}
|
||||||
|
|
||||||
|
contentUrl(id: string): string {
|
||||||
|
return this.imageService.contentUrl(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs TEXT/NUMBER non vides, dans l'ordre du template. */
|
||||||
|
get textFields(): { name: string; value: string; isNumber: boolean }[] {
|
||||||
|
if (!this.persona?.values) return [];
|
||||||
|
return this.templateFields
|
||||||
|
.filter(f => (f.type === 'TEXT' || f.type === 'NUMBER'))
|
||||||
|
.map(f => ({
|
||||||
|
name: f.name,
|
||||||
|
value: this.persona!.values?.[f.name] ?? '',
|
||||||
|
isNumber: f.type === 'NUMBER'
|
||||||
|
}))
|
||||||
|
.filter(x => x.value && x.value.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs IMAGE non vides, dans l'ordre du template. */
|
||||||
|
get imageFields(): { field: TemplateField; ids: string[] }[] {
|
||||||
|
if (!this.persona?.imageValues) return [];
|
||||||
|
return this.templateFields
|
||||||
|
.filter(f => f.type === 'IMAGE')
|
||||||
|
.map(f => ({ field: f, ids: this.persona!.imageValues?.[f.name] ?? [] }))
|
||||||
|
.filter(x => x.ids.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyNumber(fields: { isNumber: boolean }[]): boolean {
|
||||||
|
return fields.some(f => f.isNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Premier paragraphe d'un texte (utilise pour la drop cap). */
|
||||||
|
firstParagraph(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const paragraphs = text.split(/\n\s*\n/);
|
||||||
|
return paragraphs[0]?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reste du texte apres le 1er paragraphe. */
|
||||||
|
restAfterFirstParagraph(text: string): string {
|
||||||
|
if (!text) return '';
|
||||||
|
const paragraphs = text.split(/\n\s*\n/);
|
||||||
|
return paragraphs.slice(1).join('\n\n').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="sip">
|
||||||
|
<div class="sip-frame" [style.aspectRatio]="aspectRatio">
|
||||||
|
<ng-container *ngIf="imageId; else uploadTpl">
|
||||||
|
<img [src]="contentUrl(imageId)" alt="" />
|
||||||
|
<button type="button" class="sip-remove" (click)="remove()" title="Retirer l'image">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #uploadTpl>
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<small *ngIf="hint" class="sip-hint">{{ hint }}</small>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
.sip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sip-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sip-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 120ms;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sip-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
|
||||||
|
import { ImageService } from '../../services/image.service';
|
||||||
|
import { Image } from '../../services/image.model';
|
||||||
|
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picker d'image unique : preview + upload + suppression.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* <app-single-image-picker [imageId]="portraitId" (imageIdChange)="portraitId = $event">
|
||||||
|
* </app-single-image-picker>
|
||||||
|
*
|
||||||
|
* Comportements :
|
||||||
|
* - Si imageId est defini : affiche la miniature avec un bouton X pour retirer
|
||||||
|
* - Sinon : affiche le bouton d'upload (compact mode)
|
||||||
|
*
|
||||||
|
* Le composant ne supprime pas l'image cote backend — il decouple juste le
|
||||||
|
* lien (passe imageId a null). L'image reste accessible via d'autres entites.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-single-image-picker',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, LucideAngularModule, ImageUploaderComponent],
|
||||||
|
templateUrl: './single-image-picker.component.html',
|
||||||
|
styleUrls: ['./single-image-picker.component.scss']
|
||||||
|
})
|
||||||
|
export class SingleImagePickerComponent {
|
||||||
|
readonly X = X;
|
||||||
|
readonly ImageIcon = ImageIcon;
|
||||||
|
|
||||||
|
@Input() imageId: string | null = null;
|
||||||
|
|
||||||
|
/** Texte d'aide affiche sous le picker (ex: "Format conseille : 400×400"). */
|
||||||
|
@Input() hint?: string;
|
||||||
|
|
||||||
|
/** Aspect ratio de la preview (CSS aspect-ratio property). */
|
||||||
|
@Input() aspectRatio = '1 / 1';
|
||||||
|
|
||||||
|
@Output() imageIdChange = new EventEmitter<string | null>();
|
||||||
|
|
||||||
|
constructor(private imageService: ImageService) {}
|
||||||
|
|
||||||
|
contentUrl(id: string): string {
|
||||||
|
return this.imageService.contentUrl(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUploaded(img: Image): void {
|
||||||
|
if (img?.id) {
|
||||||
|
this.imageId = img.id;
|
||||||
|
this.imageIdChange.emit(this.imageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(): void {
|
||||||
|
this.imageId = null;
|
||||||
|
this.imageIdChange.emit(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<div class="tfe">
|
||||||
|
<div class="tfe-header">
|
||||||
|
<h3 class="tfe-label">{{ label }}</h3>
|
||||||
|
<p *ngIf="hint" class="tfe-hint">{{ hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tfe-list">
|
||||||
|
<div class="tfe-row" *ngFor="let f of fields; let i = index" [class.invalid]="isDuplicate(f, i) || !f.name.trim()">
|
||||||
|
<div class="tfe-row-controls">
|
||||||
|
<button type="button" class="btn-arrow" (click)="moveUp(i)" [disabled]="i === 0" title="Monter">
|
||||||
|
<lucide-icon [img]="ArrowUp" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-arrow" (click)="moveDown(i)" [disabled]="i === fields.length - 1" title="Descendre">
|
||||||
|
<lucide-icon [img]="ArrowDown" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="tfe-name"
|
||||||
|
[(ngModel)]="f.name"
|
||||||
|
[name]="'name-' + i"
|
||||||
|
(ngModelChange)="onFieldChanged()"
|
||||||
|
placeholder="Nom du champ (ex: Histoire, PV...)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="tfe-type"
|
||||||
|
[(ngModel)]="f.type"
|
||||||
|
[name]="'type-' + i"
|
||||||
|
(ngModelChange)="onFieldChanged()">
|
||||||
|
<option *ngFor="let opt of typeOptions" [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="tfe-layout"
|
||||||
|
*ngIf="f.type === 'IMAGE'"
|
||||||
|
[(ngModel)]="f.layout"
|
||||||
|
[name]="'layout-' + i"
|
||||||
|
(ngModelChange)="onFieldChanged()">
|
||||||
|
<option *ngFor="let opt of layoutOptions" [value]="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button type="button" class="btn-remove" (click)="remove(i)" title="Supprimer ce champ">
|
||||||
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="fields.length === 0" class="tfe-empty">
|
||||||
|
Aucun champ pour l'instant — ajoutez-en avec les boutons ci-dessous.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tfe-add">
|
||||||
|
<span class="tfe-add-label">Ajouter :</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
*ngFor="let s of suggestions"
|
||||||
|
[class.disabled]="isSuggestionUsed(s)"
|
||||||
|
[disabled]="isSuggestionUsed(s)"
|
||||||
|
(click)="addSuggestion(s)">
|
||||||
|
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||||
|
{{ s }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip-custom" (click)="addBlank('TEXT')">
|
||||||
|
<lucide-icon [img]="Type" [size]="12"></lucide-icon>
|
||||||
|
Texte
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip-custom" (click)="addBlank('NUMBER')">
|
||||||
|
<lucide-icon [img]="Hash" [size]="12"></lucide-icon>
|
||||||
|
Nombre
|
||||||
|
</button>
|
||||||
|
<button type="button" class="chip chip-custom" (click)="addBlank('IMAGE')">
|
||||||
|
<lucide-icon [img]="ImageIcon" [size]="12"></lucide-icon>
|
||||||
|
Image(s)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
.tfe {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-header {
|
||||||
|
.tfe-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
.tfe-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #aaa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr 130px auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: border-color 120ms;
|
||||||
|
|
||||||
|
&.invalid {
|
||||||
|
border-color: rgba(255, 100, 100, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-row-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-arrow,
|
||||||
|
.btn-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text-muted, #aaa);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
border-color: rgba(255, 100, 100, 0.4);
|
||||||
|
color: rgba(255, 100, 100, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-name,
|
||||||
|
.tfe-type,
|
||||||
|
.tfe-layout {
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-empty {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tfe-add {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.tfe-add-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #aaa);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--color-text-muted, #ccc);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 120ms;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: var(--color-text, #fff);
|
||||||
|
}
|
||||||
|
&.disabled,
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-custom {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { LucideAngularModule, Plus, Trash2, ArrowUp, ArrowDown, Type, Image as ImageIcon, Hash } from 'lucide-angular';
|
||||||
|
import { TemplateField, FieldType, ImageLayout } from '../../services/template.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editeur reutilisable d'une liste de TemplateField.
|
||||||
|
* Pilote l'ajout / suppression / reordonnancement / changement de type / renommage.
|
||||||
|
*
|
||||||
|
* Emet `fieldsChange` a chaque modification pour permettre un binding 2-way :
|
||||||
|
* <app-template-fields-editor [fields]="myFields" (fieldsChange)="myFields = $event">
|
||||||
|
*
|
||||||
|
* Validation locale : duplicats de noms (case-insensitive) marques visuellement,
|
||||||
|
* mais c'est le parent qui decide du blocage du submit.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-template-fields-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||||
|
templateUrl: './template-fields-editor.component.html',
|
||||||
|
styleUrls: ['./template-fields-editor.component.scss']
|
||||||
|
})
|
||||||
|
export class TemplateFieldsEditorComponent {
|
||||||
|
readonly Plus = Plus;
|
||||||
|
readonly Trash2 = Trash2;
|
||||||
|
readonly ArrowUp = ArrowUp;
|
||||||
|
readonly ArrowDown = ArrowDown;
|
||||||
|
readonly Type = Type;
|
||||||
|
readonly ImageIcon = ImageIcon;
|
||||||
|
readonly Hash = Hash;
|
||||||
|
|
||||||
|
/** Liste des champs (binding parent). */
|
||||||
|
@Input() fields: TemplateField[] = [];
|
||||||
|
|
||||||
|
/** Suggestions de noms de champs (chips ajout rapide). */
|
||||||
|
@Input() suggestions: string[] = [];
|
||||||
|
|
||||||
|
/** Label de la section (ex: "Champs de la fiche PJ"). */
|
||||||
|
@Input() label = 'Champs du template';
|
||||||
|
|
||||||
|
/** Hint affichee sous le label. */
|
||||||
|
@Input() hint?: string;
|
||||||
|
|
||||||
|
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
|
||||||
|
|
||||||
|
readonly typeOptions: { value: FieldType; label: string }[] = [
|
||||||
|
{ value: 'TEXT', label: 'Texte' },
|
||||||
|
{ value: 'NUMBER', label: 'Nombre' },
|
||||||
|
{ value: 'IMAGE', label: 'Image(s)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly layoutOptions: { value: ImageLayout; label: string }[] = [
|
||||||
|
{ value: 'GALLERY', label: 'Galerie' },
|
||||||
|
{ value: 'HERO', label: 'Bandeau' },
|
||||||
|
{ value: 'MASONRY', label: 'Mosaique' },
|
||||||
|
{ value: 'CAROUSEL', label: 'Carrousel' }
|
||||||
|
];
|
||||||
|
|
||||||
|
isDuplicate(field: TemplateField, index: number): boolean {
|
||||||
|
if (!field.name?.trim()) return false;
|
||||||
|
const lower = field.name.trim().toLowerCase();
|
||||||
|
return this.fields.some((f, i) => i !== index && f.name?.trim().toLowerCase() === lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSuggestionUsed(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return this.fields.some(f => f.name?.trim().toLowerCase() === lower);
|
||||||
|
}
|
||||||
|
|
||||||
|
addSuggestion(name: string): void {
|
||||||
|
if (this.isSuggestionUsed(name)) return;
|
||||||
|
this.emit([...this.fields, { name, type: 'TEXT', layout: null }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
addBlank(type: FieldType): void {
|
||||||
|
const layout: ImageLayout | null = type === 'IMAGE' ? 'GALLERY' : null;
|
||||||
|
this.emit([...this.fields, { name: '', type, layout }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(index: number): void {
|
||||||
|
const next = [...this.fields];
|
||||||
|
next.splice(index, 1);
|
||||||
|
this.emit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveUp(index: number): void {
|
||||||
|
if (index <= 0) return;
|
||||||
|
const next = [...this.fields];
|
||||||
|
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||||
|
this.emit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveDown(index: number): void {
|
||||||
|
if (index >= this.fields.length - 1) return;
|
||||||
|
const next = [...this.fields];
|
||||||
|
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||||
|
this.emit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notifie les changements internes (input/select sur un champ existant). */
|
||||||
|
onFieldChanged(): void {
|
||||||
|
// Quand le type passe a IMAGE, layout = GALLERY ; sinon null.
|
||||||
|
for (const f of this.fields) {
|
||||||
|
if (f.type === 'IMAGE' && !f.layout) f.layout = 'GALLERY';
|
||||||
|
if (f.type !== 'IMAGE') f.layout = null;
|
||||||
|
}
|
||||||
|
this.emit([...this.fields]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(fields: TemplateField[]): void {
|
||||||
|
this.fields = fields;
|
||||||
|
this.fieldsChange.emit(fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div
|
||||||
|
class="update-banner"
|
||||||
|
*ngIf="versionChecker.hasUpdate() && !dismissed"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div class="banner-content">
|
||||||
|
<lucide-icon [img]="RefreshCw" [size]="16"></lucide-icon>
|
||||||
|
<span>
|
||||||
|
Une nouvelle version de LoreMind est disponible
|
||||||
|
(<strong>{{ versionChecker.remoteVersion() }}</strong>).
|
||||||
|
Recharge pour profiter des dernieres ameliorations.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button type="button" class="btn-reload" (click)="reload()">
|
||||||
|
Recharger
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-dismiss"
|
||||||
|
(click)="dismiss()"
|
||||||
|
title="Fermer (sera reaffiche au prochain demarrage)"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<lucide-icon [img]="X" [size]="16"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
.update-banner {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(90deg, #6d28d9, #7c3aed);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reload {
|
||||||
|
background: #fff;
|
||||||
|
color: #6d28d9;
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dismiss {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decale le contenu de l'app vers le bas quand le bandeau est present
|
||||||
|
// (bandeau fixed, ne pousse pas naturellement le layout).
|
||||||
|
:host:has(.update-banner) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user