Compare commits
6 Commits
51eb907078
...
v1.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
| ad3ea0e9e6 | |||
| 0c44e42a22 | |||
| 1c8b24c5dd | |||
| 177967af55 | |||
| 42c4b53ced | |||
| 3e9e828225 |
@@ -82,9 +82,11 @@ jobs:
|
||||
if: failure()
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.e2e.yml logs --no-color
|
||||
|
||||
# v3 obligatoire : l'API artifacts v4 n'est pas supportée par Gitea
|
||||
# (GHESNotSupportedError — constaté sur le job MegaLinter).
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: web/playwright-report/
|
||||
|
||||
@@ -6,7 +6,9 @@ name: Qualité & Sécurité
|
||||
# assainie, on pourra l'ajouter aux checks requis de la branch protection.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# beta inclus : c'est la branche de dev, le feedback qualité doit y vivre
|
||||
# (ci.yml/e2e.yml, eux, restent volontairement sur main + PR).
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -27,11 +29,14 @@ jobs:
|
||||
- name: MegaLinter
|
||||
uses: oxsecurity/megalinter/flavors/cupcake@v9
|
||||
env:
|
||||
# push sur main → tout le dépôt ; PR → seulement les fichiers modifiés.
|
||||
VALIDATE_ALL_CODEBASE: ${{ github.event_name == 'push' }}
|
||||
# main → scan complet du dépôt ; beta et PR → seulement les fichiers
|
||||
# modifiés par rapport à main (rapide, feedback ciblé).
|
||||
VALIDATE_ALL_CODEBASE: ${{ github.ref == 'refs/heads/main' }}
|
||||
# v3 obligatoire : l'API artifacts v4 (@actions/artifact 2.x) n'est pas
|
||||
# supportée par Gitea (GHESNotSupportedError constaté avec v4).
|
||||
- name: Publier les rapports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: megalinter-reports
|
||||
path: megalinter-reports/
|
||||
@@ -61,17 +66,39 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Pour résoudre pom.xml (versions héritées du parent Spring Boot), Trivy
|
||||
# télécharge les BOMs depuis Maven Central — qui finit par rate-limiter
|
||||
# l'IP du runner (429). Parade officielle : peupler ~/.m2 AVANT le scan,
|
||||
# Trivy lit le cache local en priorité. Le cache setup-java (clé pom.xml)
|
||||
# rend l'étape quasi gratuite d'un run à l'autre.
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
cache: maven
|
||||
- name: Précharger le cache Maven (~/.m2)
|
||||
working-directory: core
|
||||
run: |
|
||||
chmod +x ./mvnw
|
||||
./mvnw -B -q dependency:go-offline
|
||||
- name: Installer Trivy
|
||||
run: curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Scanne les manifestes du dépôt (pom.xml, requirements*.txt,
|
||||
# package-lock.json) contre les bases CVE. --ignore-unfixed : on ne
|
||||
# bloque que sur les vulnérabilités qui ONT un correctif publié.
|
||||
# --offline-scan : AUCUNE requête vers Maven Central pendant la
|
||||
# résolution — Trivy allait y chercher les POMs parents même avec le
|
||||
# cache rempli, et l'IP du runner finissait rate-limitée (429, 30 min).
|
||||
# Tout est déjà dans ~/.m2 grâce au dependency:go-offline ci-dessus ;
|
||||
# contrepartie : une dépendance absente du cache serait ignorée en
|
||||
# silence (impossible ici, go-offline échouerait d'abord).
|
||||
- name: Scan des dépendances (HIGH/CRITICAL bloquants)
|
||||
run: |
|
||||
trivy fs . \
|
||||
--scanners vuln \
|
||||
--severity HIGH,CRITICAL \
|
||||
--ignore-unfixed \
|
||||
--offline-scan \
|
||||
--skip-dirs node_modules \
|
||||
--skip-dirs docusaurus \
|
||||
--exit-code 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -129,3 +129,5 @@ web/coverage/
|
||||
brain/htmlcov/
|
||||
brain/.coverage
|
||||
foundry-module/
|
||||
plan-promotion-loremind.md
|
||||
post-reddit-foundryvtt.md
|
||||
|
||||
@@ -35,3 +35,9 @@ REPORT_OUTPUT_FOLDER: megalinter-reports
|
||||
# Phase de rodage : passer temporairement à true pour rendre le job
|
||||
# informatif (rapport sans échec CI) le temps de purger l'existant.
|
||||
DISABLE_ERRORS: false
|
||||
|
||||
# PMD : ruleset projet à la racine (java-pmd-ruleset.xml, détecté
|
||||
# automatiquement) — orienté bugs réels, sans style. Non-bloquant le temps
|
||||
# de purger le backlog (~65 findings : PreserveStackTrace, EmptyCatchBlock,
|
||||
# CheckResultSet, RelianceOnDefaultCharset…). Repasser à false ensuite.
|
||||
JAVA_PMD_DISABLE_ERRORS: true
|
||||
|
||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
description="Backend IA pour la génération de contenu narratif.",
|
||||
version="1.0.2-beta",
|
||||
version="1.0.3",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
15
core/pom.xml
15
core/pom.xml
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>1.0.2-beta</version>
|
||||
<version>1.0.3</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
>= 3.18 : corrige CVE-2025-48924 (recursion infinie ClassUtils.getClass).
|
||||
Propriete reconnue par le BOM Spring Boot → s'applique partout. -->
|
||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||
<!-- Overrides CVE (detectes par Trivy en CI — job quality.yml) :
|
||||
proprietes reconnues par le BOM Spring Boot, comme ci-dessus. -->
|
||||
<!-- >= 2.21.4 : CVE-2026-54512 / CVE-2026-54513 (execution de code
|
||||
arbitraire via contournement du PolymorphicTypeValidator). -->
|
||||
<jackson-bom.version>2.21.4</jackson-bom.version>
|
||||
<!-- >= 4.1.135 : lot de CVE netty (DoS codec/handler, bypass de
|
||||
verification hostname CVE-2026-50010, DNS CVE-2026-45674/47691). -->
|
||||
<netty.version>4.1.135.Final</netty.version>
|
||||
<!-- >= 10.1.55 : 3 CRITICAL Tomcat (CVE-2026-41293 headers HTTP/2 non
|
||||
valides, CVE-2026-43512 bypass auth digest, CVE-2026-43515). -->
|
||||
<tomcat.version>10.1.55</tomcat.version>
|
||||
<!-- >= 42.7.11 : CVE-2026-42198 (DoS client via SCRAM-SHA-256). -->
|
||||
<postgresql.version>42.7.11</postgresql.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
@@ -106,6 +106,11 @@ public class QuestService {
|
||||
/** Le chapitre est-il un CONTENEUR de cette quête (jumeau hub ou hébergé en arc SYSTEM) ? */
|
||||
private boolean isContainerOf(Quest quest, Chapter chapter) {
|
||||
if (Objects.equals(quest.getArcId(), chapter.getArcId())) return true;
|
||||
return inSystemArc(chapter);
|
||||
}
|
||||
|
||||
/** Le chapitre vit-il dans l'arc technique SYSTEM (masqué partout dans l'appli) ? */
|
||||
private boolean inSystemArc(Chapter chapter) {
|
||||
return chapter.getArcId() != null && arcRepository.findById(chapter.getArcId())
|
||||
.map(a -> a.getType() == ArcType.SYSTEM)
|
||||
.orElse(false);
|
||||
@@ -156,6 +161,18 @@ public class QuestService {
|
||||
/**
|
||||
* Supprime la quête et, en cascade, ses {@code QuestProgression} dans toutes les Parties.
|
||||
*
|
||||
* <p>Nettoyage du CONTENEUR (chapitre jumeau, jamais un chapitre simplement LIÉ —
|
||||
* isContainerOf exclut les liens transversaux) :
|
||||
* <ul>
|
||||
* <li>jumeau de HUB non vide : GARDÉ — il redevient un chapitre visible de l'arc,
|
||||
* aucune perte de contenu ;</li>
|
||||
* <li>conteneur d'arc SYSTEM (quête libre) : supprimé AVEC ses scènes — une fois la
|
||||
* quête partie il est invisible partout dans l'appli et pourrirait en fantôme
|
||||
* (réapparitions dans les exports). L'impact est annoncé au préalable par
|
||||
* {@link #getDeletionImpact} (dialogue de confirmation côté front) ;</li>
|
||||
* <li>conteneur encore référencé par une autre quête : jamais touché.</li>
|
||||
* </ul></p>
|
||||
*
|
||||
* <p>Limite connue (nettoyage prévu Phase 5) : les {@code Prerequisite.QuestCompleted}
|
||||
* d'autres quêtes qui pointaient celle-ci restent pendants, sans être signalés ni
|
||||
* nettoyés. Échec sûr aujourd'hui : un prérequis vers une quête supprimée n'est jamais
|
||||
@@ -167,28 +184,47 @@ public class QuestService {
|
||||
progressionRepository.deleteByQuestId(id);
|
||||
questRepository.deleteById(id);
|
||||
if (quest == null) return;
|
||||
// Nettoyage du CONTENEUR (jumeau hub ou hébergé en arc SYSTEM) : un chapitre VIDE
|
||||
// (aucune scène), plus référencé par aucune autre quête, ne doit pas réapparaître
|
||||
// comme « chapitre vide » fantôme. S'il contient des scènes, on le GARDE (aucune
|
||||
// perte de contenu). Les chapitres simplement LIÉS (quête transversale pointant du
|
||||
// contenu réel d'un autre arc) ne sont JAMAIS touchés — isContainerOf les exclut.
|
||||
List<Quest> remaining = questRepository.findByCampaignId(quest.getCampaignId());
|
||||
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||
chapterRepository.findById(node.nodeId()).ifPresent(ch -> {
|
||||
boolean container = isContainerOf(quest, ch);
|
||||
boolean empty = sceneRepository.findByChapterId(ch.getId()).isEmpty();
|
||||
boolean referencedElsewhere = remaining.stream()
|
||||
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||
&& ch.getId().equals(n.nodeId())));
|
||||
if (container && empty && !referencedElsewhere) {
|
||||
chapterRepository.deleteById(ch.getId());
|
||||
}
|
||||
if (!container || referencedElsewhere) return;
|
||||
var scenes = sceneRepository.findByChapterId(ch.getId());
|
||||
if (!scenes.isEmpty() && !inSystemArc(ch)) return; // jumeau de hub : reste visible
|
||||
for (var scene : scenes) sceneRepository.deleteById(scene.getId());
|
||||
chapterRepository.deleteById(ch.getId());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Scènes qui tomberont avec la quête (conteneurs d'arc SYSTEM exclusifs à cette quête). */
|
||||
public record DeletionImpact(int scenes) {}
|
||||
|
||||
public DeletionImpact getDeletionImpact(String questId) {
|
||||
Quest quest = questRepository.findById(questId).orElse(null);
|
||||
if (quest == null) return new DeletionImpact(0);
|
||||
List<Quest> others = questRepository.findByCampaignId(quest.getCampaignId()).stream()
|
||||
.filter(q -> !questId.equals(q.getId()))
|
||||
.toList();
|
||||
int scenes = 0;
|
||||
for (QuestNodeRef node : nullSafeNodes(quest.getNodes())) {
|
||||
if (node.nodeType() != NodeType.CHAPTER) continue;
|
||||
Chapter ch = chapterRepository.findById(node.nodeId()).orElse(null);
|
||||
if (ch == null || !isContainerOf(quest, ch) || !inSystemArc(ch)) continue;
|
||||
boolean referencedElsewhere = others.stream()
|
||||
.anyMatch(q -> nullSafeNodes(q.getNodes()).stream()
|
||||
.anyMatch(n -> n.nodeType() == NodeType.CHAPTER
|
||||
&& ch.getId().equals(n.nodeId())));
|
||||
if (!referencedElsewhere) scenes += sceneRepository.findByChapterId(ch.getId()).size();
|
||||
}
|
||||
return new DeletionImpact(scenes);
|
||||
}
|
||||
|
||||
private static List<QuestNodeRef> nullSafeNodes(List<QuestNodeRef> nodes) {
|
||||
return nodes != null ? nodes : List.of();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.loremind.infrastructure.transfer.foundry;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||
import com.loremind.domain.campaigncontext.structure.Room;
|
||||
import com.loremind.domain.campaigncontext.structure.RoomBranch;
|
||||
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
|
||||
@@ -40,6 +43,7 @@ public class FoundryExportService {
|
||||
private final CampaignJpaRepository campaignRepo;
|
||||
private final ArcJpaRepository arcRepo;
|
||||
private final ChapterJpaRepository chapterRepo;
|
||||
private final QuestJpaRepository questRepo;
|
||||
private final SceneJpaRepository sceneRepo;
|
||||
private final NpcJpaRepository npcRepo;
|
||||
private final EnemyJpaRepository enemyRepo;
|
||||
@@ -55,6 +59,7 @@ public class FoundryExportService {
|
||||
public FoundryExportService(CampaignJpaRepository campaignRepo,
|
||||
ArcJpaRepository arcRepo,
|
||||
ChapterJpaRepository chapterRepo,
|
||||
QuestJpaRepository questRepo,
|
||||
SceneJpaRepository sceneRepo,
|
||||
NpcJpaRepository npcRepo,
|
||||
EnemyJpaRepository enemyRepo,
|
||||
@@ -69,6 +74,7 @@ public class FoundryExportService {
|
||||
this.campaignRepo = campaignRepo;
|
||||
this.arcRepo = arcRepo;
|
||||
this.chapterRepo = chapterRepo;
|
||||
this.questRepo = questRepo;
|
||||
this.sceneRepo = sceneRepo;
|
||||
this.npcRepo = npcRepo;
|
||||
this.enemyRepo = enemyRepo;
|
||||
@@ -154,7 +160,20 @@ public class FoundryExportService {
|
||||
List<FoundryBundle.Quest> quests = new ArrayList<>();
|
||||
List<FoundryBundle.Scene> scenes = new ArrayList<>();
|
||||
|
||||
Set<String> liveContainerIds = liveQuestContainerIds(campaign);
|
||||
for (ArcJpaEntity arc : sortByOrder(arcRepo.findByCampaignId(campaign.getId()), ArcJpaEntity::getOrder)) {
|
||||
List<ChapterJpaEntity> chapters = sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder);
|
||||
// L'arc technique SYSTEM (« Quetes libres ») est masque dans l'appli : seuls
|
||||
// les conteneurs des quetes VIVANTES partent dans l'export. Un conteneur
|
||||
// orphelin (quete supprimee, chapitre garde par deleteQuest car il contenait
|
||||
// des scenes) est invisible cote LoreMind -> il ne doit pas reapparaitre
|
||||
// cote Foundry. Arc omis entierement s'il ne reste rien.
|
||||
if (arc.getType() == ArcType.SYSTEM) {
|
||||
chapters = chapters.stream()
|
||||
.filter(ch -> liveContainerIds.contains(str(ch.getId())))
|
||||
.toList();
|
||||
if (chapters.isEmpty()) continue;
|
||||
}
|
||||
// Sans journaux, arcs/quetes ne servent que d'ossature (dossiers des Scenes) :
|
||||
// leurs illustrations ne sont pas embarquees.
|
||||
arcs.add(new FoundryBundle.Arc(
|
||||
@@ -163,7 +182,7 @@ public class FoundryExportService {
|
||||
arc.getThemes(), arc.getStakes(), arc.getGmNotes(), arc.getRewards(), arc.getResolution(),
|
||||
opts.journals() ? assets.images(arc.getIllustrationImageIds()) : List.of()));
|
||||
|
||||
for (ChapterJpaEntity ch : sortByOrder(chapterRepo.findByArcId(arc.getId()), ChapterJpaEntity::getOrder)) {
|
||||
for (ChapterJpaEntity ch : chapters) {
|
||||
quests.add(new FoundryBundle.Quest(
|
||||
str(ch.getId()), str(arc.getId()), ch.getName(), ch.getDescription(), ch.getOrder(),
|
||||
ch.getIcon(), ch.getPlayerObjectives(), ch.getNarrativeStakes(), ch.getGmNotes(),
|
||||
@@ -177,6 +196,17 @@ public class FoundryExportService {
|
||||
return new ArcsQuestsScenes(arcs, quests, scenes);
|
||||
}
|
||||
|
||||
/** Ids des chapitres-conteneurs references par les quetes vivantes de la campagne. */
|
||||
private Set<String> liveQuestContainerIds(CampaignJpaEntity campaign) {
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (QuestJpaEntity q : questRepo.findByCampaignId(campaign.getId())) {
|
||||
for (QuestNodeRef n : q.getNodes() != null ? q.getNodes() : List.<QuestNodeRef>of()) {
|
||||
if (n.nodeType() == NodeType.CHAPTER) ids.add(n.nodeId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** PNJ : purement journal — hors perimetre sans les journaux. */
|
||||
private List<FoundryBundle.Persona> buildNpcs(CampaignJpaEntity campaign, ExportOptions opts,
|
||||
List<TemplateField> npcTemplate, AssetRegistry assets) {
|
||||
|
||||
@@ -86,6 +86,16 @@ public class QuestController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */
|
||||
@GetMapping("/{questId}/deletion-impact")
|
||||
public ResponseEntity<QuestService.DeletionImpact> getDeletionImpact(@PathVariable String campaignId,
|
||||
@PathVariable String questId) {
|
||||
if (questService.getQuestById(questId).isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok(questService.getDeletionImpact(questId));
|
||||
}
|
||||
|
||||
/** Réordonne les quêtes de la campagne : order = position. */
|
||||
@PutMapping("/reorder")
|
||||
public ResponseEntity<Void> reorder(@PathVariable String campaignId, @RequestBody ReorderRequest req) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package db.migration;
|
||||
|
||||
import org.flywaydb.core.api.migration.BaseJavaMigration;
|
||||
import org.flywaydb.core.api.migration.Context;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Nettoyage des CONTENEURS ORPHELINS des quêtes libres (arc technique SYSTEM).
|
||||
*
|
||||
* <p>Jusqu'à la 1.0.2, supprimer une quête libre GARDAIT son chapitre-conteneur
|
||||
* s'il contenait des scènes (garde anti-perte de contenu) — mais l'arc SYSTEM
|
||||
* étant masqué partout dans l'appli, ce contenu devenait INVISIBLE et
|
||||
* irrécupérable, tout en ressortant dans l'export Foundry (« quêtes fantômes »).
|
||||
* {@code QuestService.deleteQuest} cascade désormais ces conteneurs ; cette
|
||||
* migration répare l'existant SANS perte :</p>
|
||||
* <ul>
|
||||
* <li>conteneur orphelin AVEC scènes → une quête libre est RECRÉÉE dessus (même
|
||||
* nom que le chapitre) : le contenu redevient visible sous « Quêtes », et
|
||||
* l'utilisateur le garde ou le supprime via le flux normal (qui annonce
|
||||
* l'impact) ;</li>
|
||||
* <li>conteneur orphelin VIDE → supprimé (fantôme sans contenu).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>« Orphelin » = chapitre d'un arc SYSTEM dont l'id n'apparaît dans les
|
||||
* {@code nodes} d'AUCUNE quête de la campagne. Détection prudente par motif
|
||||
* {@code "nodeId":"<id>"} : une collision d'id avec un nœud SCENE fait considérer
|
||||
* le chapitre comme référencé → il est laissé en place (aucun risque de perte).</p>
|
||||
*
|
||||
* <p>Migration en Java (et non SQL) : la corrélation chapitre ↔ nodes JSON des
|
||||
* quêtes demande une itération par candidat, illisible en SQL portable H2/PG.</p>
|
||||
*/
|
||||
@SuppressWarnings("java:S101") // Nommage V24__... IMPOSE par la convention Flyway des migrations Java.
|
||||
public class V24__Reattach_orphan_free_quest_containers extends BaseJavaMigration {
|
||||
|
||||
private record Candidate(long chapterId, String name, long campaignId) {}
|
||||
|
||||
@Override
|
||||
public void migrate(Context context) throws Exception {
|
||||
Connection conn = context.getConnection();
|
||||
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT c.id, c.name, a.campaign_id FROM chapters c "
|
||||
+ "JOIN arcs a ON c.arc_id = a.id WHERE a.type = 'SYSTEM'");
|
||||
ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
candidates.add(new Candidate(rs.getLong(1), rs.getString(2), rs.getLong(3)));
|
||||
}
|
||||
}
|
||||
|
||||
for (Candidate ch : candidates) {
|
||||
if (isReferencedByAQuest(conn, ch)) continue; // conteneur d'une quête vivante
|
||||
if (countScenes(conn, ch.chapterId()) == 0) {
|
||||
deleteEmptyChapter(conn, ch.chapterId());
|
||||
} else {
|
||||
recreateFreeQuest(conn, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isReferencedByAQuest(Connection conn, Candidate ch) throws Exception {
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM quests WHERE campaign_id = ? AND nodes LIKE ?")) {
|
||||
ps.setLong(1, ch.campaignId());
|
||||
ps.setString(2, "%\"nodeId\":\"" + ch.chapterId() + "\"%");
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getLong(1) > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int countScenes(Connection conn, long chapterId) throws Exception {
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT COUNT(*) FROM scenes WHERE chapter_id = ?")) {
|
||||
ps.setLong(1, chapterId);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteEmptyChapter(Connection conn, long chapterId) throws Exception {
|
||||
try (PreparedStatement ps = conn.prepareStatement("DELETE FROM chapters WHERE id = ?")) {
|
||||
ps.setLong(1, chapterId);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Recrée une quête LIBRE (arc_id NULL) pointant le conteneur, à la suite de l'ordre. */
|
||||
private void recreateFreeQuest(Connection conn, Candidate ch) throws Exception {
|
||||
int order;
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT COALESCE(MAX(\"order\"), -1) + 1 FROM quests WHERE campaign_id = ?")) {
|
||||
ps.setLong(1, ch.campaignId());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
rs.next();
|
||||
order = rs.getInt(1);
|
||||
}
|
||||
}
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT INTO quests (campaign_id, \"order\", name, description, prerequisites, "
|
||||
+ "nodes, related_page_ids, illustration_image_ids, created_at, updated_at) "
|
||||
+ "VALUES (?, ?, ?, '', '[]', ?, '[]', '[]', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")) {
|
||||
ps.setLong(1, ch.campaignId());
|
||||
ps.setInt(2, order);
|
||||
ps.setString(3, ch.name());
|
||||
ps.setString(4, "[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"" + ch.chapterId() + "\",\"order\":0}]");
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,13 +230,112 @@ class QuestServiceTest {
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-real").arcId("arc-lin").name("Réel").build()));
|
||||
when(arcRepository.findById("arc-lin"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-lin").type(ArcType.LINEAR).build()));
|
||||
when(sceneRepository.findByChapterId("chap-real")).thenReturn(List.of());
|
||||
// NB : pas de stub sceneRepository — un chapitre non-conteneur est écarté avant toute lecture des scènes.
|
||||
|
||||
service.deleteQuest("q-1");
|
||||
|
||||
verify(chapterRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteQuest_freeQuest_cascadesScenesOfSystemContainer() {
|
||||
// Conteneur d'arc SYSTEM : INVISIBLE une fois la quête supprimée -> il part avec
|
||||
// ses scènes (sinon il pourrit en fantôme inaccessible qui ressort dans les exports).
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of());
|
||||
when(chapterRepository.findById("chap-free"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
|
||||
when(arcRepository.findById("arc-sys"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
|
||||
when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of(
|
||||
Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(),
|
||||
Scene.builder().id("s-2").chapterId("chap-free").name("S2").build()));
|
||||
|
||||
service.deleteQuest("q-1");
|
||||
|
||||
verify(sceneRepository).deleteById("s-1");
|
||||
verify(sceneRepository).deleteById("s-2");
|
||||
verify(chapterRepository).deleteById("chap-free");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletionImpact_freeQuestWithScenes_countsThem() {
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest)); // filtrée par id
|
||||
when(chapterRepository.findById("chap-free"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
|
||||
when(arcRepository.findById("arc-sys"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
|
||||
when(sceneRepository.findByChapterId("chap-free")).thenReturn(List.of(
|
||||
Scene.builder().id("s-1").chapterId("chap-free").name("S1").build(),
|
||||
Scene.builder().id("s-2").chapterId("chap-free").name("S2").build()));
|
||||
|
||||
assertEquals(2, service.getDeletionImpact("q-1").scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletionImpact_unknownQuest_returnsZero() {
|
||||
when(questRepository.findById("inconnue")).thenReturn(Optional.empty());
|
||||
|
||||
assertEquals(0, service.getDeletionImpact("inconnue").scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletionImpact_systemContainerReferencedByAnotherQuest_reportsZero() {
|
||||
// Conteneur PARTAGÉ (autre quête le référence) : il survivra -> impact 0.
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest, other));
|
||||
when(chapterRepository.findById("chap-free"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
|
||||
when(arcRepository.findById("arc-sys"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
|
||||
|
||||
assertEquals(0, service.getDeletionImpact("q-1").scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteQuest_freeQuest_systemContainerReferencedElsewhere_keptWithScenes() {
|
||||
// Même partagé en arc SYSTEM, un conteneur encore référencé n'est JAMAIS cascadé.
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").name("Libre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
Quest other = Quest.builder().id("q-2").campaignId("camp").name("Autre")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-free", 0))).build();
|
||||
when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other));
|
||||
when(chapterRepository.findById("chap-free"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-free").arcId("arc-sys").name("Libre").build()));
|
||||
when(arcRepository.findById("arc-sys"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-sys").type(ArcType.SYSTEM).build()));
|
||||
|
||||
service.deleteQuest("q-1");
|
||||
|
||||
verify(chapterRepository, never()).deleteById(anyString());
|
||||
verify(sceneRepository, never()).deleteById(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletionImpact_hubQuest_reportsZero() {
|
||||
// Jumeau de hub : GARDÉ à la suppression (il reste visible dans l'arc) -> impact 0.
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q")
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, "chap-1", 0))).build();
|
||||
when(questRepository.findById("q-1")).thenReturn(Optional.of(quest));
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(quest));
|
||||
when(chapterRepository.findById("chap-1"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build()));
|
||||
when(arcRepository.findById("arc-h"))
|
||||
.thenReturn(Optional.of(Arc.builder().id("arc-h").type(ArcType.HUB).build()));
|
||||
|
||||
assertEquals(0, service.getDeletionImpact("q-1").scenes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteQuest_keepsContainerReferencedByAnotherQuest() {
|
||||
Quest quest = Quest.builder().id("q-1").campaignId("camp").arcId("arc-h").name("Q")
|
||||
@@ -247,7 +346,7 @@ class QuestServiceTest {
|
||||
when(questRepository.findByCampaignId("camp")).thenReturn(List.of(other));
|
||||
when(chapterRepository.findById("chap-1"))
|
||||
.thenReturn(Optional.of(Chapter.builder().id("chap-1").arcId("arc-h").name("Q").build()));
|
||||
when(sceneRepository.findByChapterId("chap-1")).thenReturn(List.of());
|
||||
// NB : pas de stub sceneRepository — un conteneur encore référencé est écarté avant toute lecture.
|
||||
|
||||
service.deleteQuest("q-1");
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -119,4 +121,77 @@ class FlywayMigrationTest {
|
||||
assertEquals("[]", rs.getString(2));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V24 : les conteneurs ORPHELINS de l'arc SYSTEM (quête libre supprimée avant que
|
||||
* la suppression ne cascade) sont réparés — rattachés à une quête recréée s'ils
|
||||
* ont des scènes (contenu redevenu visible), supprimés s'ils sont vides. Le
|
||||
* conteneur d'une quête vivante n'est pas touché.
|
||||
*/
|
||||
@Test
|
||||
void v24_reattachesOrphanSystemContainers_andDropsEmptyOnes() throws SQLException {
|
||||
String url = "jdbc:h2:mem:flyway_v24_test;MODE=PostgreSQL;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1";
|
||||
|
||||
// 1) Schéma arrêté AVANT V24…
|
||||
Flyway.configure()
|
||||
.dataSource(url, "sa", "")
|
||||
.locations("classpath:db/migration")
|
||||
.target(MigrationVersion.fromVersion("23"))
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
// 2) …peuplé : un arc SYSTEM avec un orphelin PLEIN (1 scène), un orphelin
|
||||
// VIDE, et le conteneur d'une quête VIVANTE.
|
||||
try (Connection conn = DriverManager.getConnection(url, "sa", "");
|
||||
Statement st = conn.createStatement()) {
|
||||
st.executeUpdate("insert into campaigns (id, name, arcs_count, created_at, updated_at) "
|
||||
+ "values (1, 'C', 0, now(), now())");
|
||||
st.executeUpdate("insert into arcs (id, name, campaign_id, \"order\", type, created_at, updated_at) "
|
||||
+ "values (9, 'Quêtes libres', 1, 9999, 'SYSTEM', now(), now())");
|
||||
st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
|
||||
+ "values (41, 'Orphelin plein', 9, 0, now(), now())");
|
||||
st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
|
||||
+ "values (42, 'Orphelin vide', 9, 1, now(), now())");
|
||||
st.executeUpdate("insert into chapters (id, name, arc_id, \"order\", created_at, updated_at) "
|
||||
+ "values (43, 'Vivant', 9, 2, now(), now())");
|
||||
st.executeUpdate("insert into scenes (id, name, chapter_id, \"order\", created_at, updated_at) "
|
||||
+ "values (1, 'S', 41, 0, now(), now())");
|
||||
st.executeUpdate("insert into quests (campaign_id, \"order\", name, nodes, created_at, updated_at) "
|
||||
+ "values (1, 0, 'Vivant', '[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"43\",\"order\":0}]', now(), now())");
|
||||
}
|
||||
|
||||
// 3) Fin de la chaîne : V24 répare.
|
||||
Flyway.configure()
|
||||
.dataSource(url, "sa", "")
|
||||
.locations("classpath:db/migration")
|
||||
.load()
|
||||
.migrate();
|
||||
|
||||
try (Connection conn = DriverManager.getConnection(url, "sa", "");
|
||||
Statement st = conn.createStatement()) {
|
||||
|
||||
// L'orphelin PLEIN est rattaché : quête LIBRE (arc_id NULL) recréée dessus.
|
||||
try (ResultSet rs = st.executeQuery(
|
||||
"select arc_id, nodes from quests where name = 'Orphelin plein'")) {
|
||||
assertTrue(rs.next(), "une quête aurait dû être recréée sur le conteneur orphelin");
|
||||
assertNull(rs.getObject(1));
|
||||
assertEquals("[{\"nodeType\":\"CHAPTER\",\"nodeId\":\"41\",\"order\":0}]", rs.getString(2));
|
||||
}
|
||||
|
||||
// L'orphelin VIDE a disparu ; l'orphelin plein et le conteneur vivant restent.
|
||||
try (ResultSet rs = st.executeQuery("select id from chapters where arc_id = 9 order by id")) {
|
||||
assertTrue(rs.next());
|
||||
assertEquals(41, rs.getInt(1));
|
||||
assertTrue(rs.next());
|
||||
assertEquals(43, rs.getInt(1));
|
||||
assertFalse(rs.next(), "l'orphelin vide (42) aurait dû être supprimé");
|
||||
}
|
||||
|
||||
// Pas de doublon : la quête vivante n'a pas été re-rattachée.
|
||||
try (ResultSet rs = st.executeQuery("select count(*) from quests")) {
|
||||
rs.next();
|
||||
assertEquals(2, rs.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.loremind.infrastructure.transfer.foundry;
|
||||
|
||||
import com.loremind.domain.campaigncontext.quest.NodeType;
|
||||
import com.loremind.domain.campaigncontext.quest.QuestNodeRef;
|
||||
import com.loremind.domain.campaigncontext.structure.ArcType;
|
||||
import com.loremind.domain.campaigncontext.structure.SceneBattlemap;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.persistence.entity.*;
|
||||
@@ -30,6 +33,7 @@ class FoundryExportServiceTest {
|
||||
@Autowired private CampaignJpaRepository campaignRepo;
|
||||
@Autowired private ArcJpaRepository arcRepo;
|
||||
@Autowired private ChapterJpaRepository chapterRepo;
|
||||
@Autowired private QuestJpaRepository questRepo;
|
||||
@Autowired private SceneJpaRepository sceneRepo;
|
||||
@Autowired private NpcJpaRepository npcRepo;
|
||||
@Autowired private GameSystemJpaRepository gameSystemRepo;
|
||||
@@ -251,4 +255,60 @@ class FoundryExportServiceTest {
|
||||
assertThrows(java.util.NoSuchElementException.class,
|
||||
() -> service.buildBundle("999999999", "2026-06-25T00:00:00Z"));
|
||||
}
|
||||
|
||||
/**
|
||||
* L'arc technique SYSTEM heberge les conteneurs des quetes libres. Un conteneur
|
||||
* ORPHELIN (quete supprimee, chapitre garde par deleteQuest car il contenait des
|
||||
* scenes) est invisible dans l'appli : il ne doit pas partir dans le bundle.
|
||||
*/
|
||||
@Test
|
||||
void buildBundle_systemArc_exportsOnlyLiveQuestContainers() {
|
||||
CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
|
||||
.name("Quetes libres").description("d").arcsCount(1).build());
|
||||
ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder()
|
||||
.campaignId(camp.getId()).name("Quêtes libres").order(9999)
|
||||
.type(ArcType.SYSTEM).build());
|
||||
ChapterJpaEntity live = chapterRepo.save(ChapterJpaEntity.builder()
|
||||
.arcId(system.getId()).name("Quete vivante").order(0).build());
|
||||
ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder()
|
||||
.arcId(system.getId()).name("test de quete").order(1).build());
|
||||
sceneRepo.save(SceneJpaEntity.builder()
|
||||
.chapterId(live.getId()).name("Scene vivante").order(0).build());
|
||||
sceneRepo.save(SceneJpaEntity.builder()
|
||||
.chapterId(orphan.getId()).name("Scene fantome").order(0).build());
|
||||
questRepo.save(QuestJpaEntity.builder()
|
||||
.campaignId(camp.getId()).name("Quete vivante").order(0)
|
||||
.nodes(List.of(new QuestNodeRef(NodeType.CHAPTER, String.valueOf(live.getId()), 0)))
|
||||
.build());
|
||||
|
||||
FoundryBundle.Data data = service.buildBundle(
|
||||
String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data();
|
||||
|
||||
assertEquals(1, data.arcs().size());
|
||||
assertEquals(1, data.quests().size());
|
||||
assertEquals("Quete vivante", data.quests().get(0).name());
|
||||
assertEquals(1, data.scenes().size());
|
||||
assertEquals("Scene vivante", data.scenes().get(0).name());
|
||||
}
|
||||
|
||||
/** Sans quete vivante, l'arc SYSTEM disparait entierement du bundle. */
|
||||
@Test
|
||||
void buildBundle_systemArcWithoutLiveQuest_isDroppedEntirely() {
|
||||
CampaignJpaEntity camp = campaignRepo.save(CampaignJpaEntity.builder()
|
||||
.name("Fantomes").description("d").arcsCount(1).build());
|
||||
ArcJpaEntity system = arcRepo.save(ArcJpaEntity.builder()
|
||||
.campaignId(camp.getId()).name("Quêtes libres").order(9999)
|
||||
.type(ArcType.SYSTEM).build());
|
||||
ChapterJpaEntity orphan = chapterRepo.save(ChapterJpaEntity.builder()
|
||||
.arcId(system.getId()).name("test").order(0).build());
|
||||
sceneRepo.save(SceneJpaEntity.builder()
|
||||
.chapterId(orphan.getId()).name("test").order(0).build());
|
||||
|
||||
FoundryBundle.Data data = service.buildBundle(
|
||||
String.valueOf(camp.getId()), "2026-07-09T00:00:00Z").data();
|
||||
|
||||
assertTrue(data.arcs().isEmpty());
|
||||
assertTrue(data.quests().isEmpty());
|
||||
assertTrue(data.scenes().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
73
java-pmd-ruleset.xml
Normal file
73
java-pmd-ruleset.xml
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
name="LoreMind"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
|
||||
<description>
|
||||
Ruleset PMD LoreMind — détecté automatiquement par MegaLinter (JAVA_PMD).
|
||||
Philosophie : bugs et pièges réels uniquement. Les catégories de STYLE
|
||||
(codestyle/design/documentation) sont exclues : le défaut MegaLinter y
|
||||
générait ~11 000 violations hors sujet (conventions incompatibles avec
|
||||
l'idiome Lombok/slf4j/Spring du projet : champ `log`, DI constructeur…).
|
||||
</description>
|
||||
|
||||
<!-- Bugs probables : null, equals/hashCode, ressources, casts douteux… -->
|
||||
<rule ref="category/java/errorprone.xml">
|
||||
<!-- Chaque littéral dupliqué = une violation : du bruit, pas un bug. -->
|
||||
<exclude name="AvoidDuplicateLiterals"/>
|
||||
<exclude name="AvoidLiteralsInIfCondition"/>
|
||||
<!-- Entités JPA/DTO : pas de sérialisation Java native ici. -->
|
||||
<exclude name="MissingSerialVersionUID"/>
|
||||
<!-- `this.name = name` en DI/setters : idiome standard. -->
|
||||
<exclude name="AvoidFieldNameMatchingMethodName"/>
|
||||
<exclude name="AvoidFieldNameMatchingTypeName"/>
|
||||
<!-- `while ((x = read()) != null)` : idiome de lecture standard. -->
|
||||
<exclude name="AssignmentInOperand"/>
|
||||
<!-- catch(Exception) best-effort : choix assumé du projet (imports,
|
||||
exports, best-effort serveur) — pas un gate. -->
|
||||
<exclude name="AvoidCatchingGenericException"/>
|
||||
<!-- TODO backlog : réactiver et passer les toLowerCase/UpperCase en
|
||||
Locale.ROOT (27 sites) — bug potentiel (locale turque) mais
|
||||
changement de comportement à valider posément. -->
|
||||
<exclude name="UseLocaleWithCaseConversions"/>
|
||||
</rule>
|
||||
|
||||
<!-- Concurrence -->
|
||||
<rule ref="category/java/multithreading.xml">
|
||||
<!-- Règle d'époque J2EE : les threads sont légitimes (sidecar, SSE…). -->
|
||||
<exclude name="DoNotUseThreads"/>
|
||||
<!-- Flaguerait CHAQUE HashMap, y compris locales à une méthode (×135). -->
|
||||
<exclude name="UseConcurrentHashMap"/>
|
||||
</rule>
|
||||
|
||||
<!-- Performance (sans les micro-optimisations StringBuilder : le rendu
|
||||
PDF/HTML fait des milliers d'appends, aucun n'est un point chaud). -->
|
||||
<rule ref="category/java/performance.xml">
|
||||
<exclude name="AvoidInstantiatingObjectsInLoops"/>
|
||||
<exclude name="AppendCharacterWithChar"/>
|
||||
<exclude name="ConsecutiveLiteralAppends"/>
|
||||
<exclude name="ConsecutiveAppendsShouldReuse"/>
|
||||
<exclude name="InsufficientStringBufferDeclaration"/>
|
||||
</rule>
|
||||
|
||||
<!-- Sécurité -->
|
||||
<rule ref="category/java/security.xml"/>
|
||||
|
||||
<!-- Bonnes pratiques (sous-ensemble non-style) -->
|
||||
<rule ref="category/java/bestpractices.xml">
|
||||
<!-- slf4j paramétré ({} + args) rend le guard inutile. -->
|
||||
<exclude name="GuardLogStatement"/>
|
||||
<!-- Les ports hexagonaux à méthode unique ne sont pas des lambdas. -->
|
||||
<exclude name="ImplicitFunctionalInterface"/>
|
||||
<!-- Déclarer List vs ArrayList : style, pas un bug. -->
|
||||
<exclude name="LooseCoupling"/>
|
||||
<!-- StringBuilder en champ : pattern builder assumé (export PDF). -->
|
||||
<exclude name="AvoidStringBufferField"/>
|
||||
<!-- Style de tests : à l'appréciation du projet, pas un gate. -->
|
||||
<exclude name="UnitTestAssertionsShouldIncludeMessage"/>
|
||||
<exclude name="UnitTestContainsTooManyAsserts"/>
|
||||
<exclude name="UnitTestShouldIncludeAssert"/>
|
||||
</rule>
|
||||
|
||||
</ruleset>
|
||||
@@ -1,6 +1,8 @@
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /build
|
||||
RUN npm install -g npm@latest
|
||||
# PAS de mise à jour de npm ici : « npm@latest » est une cible mouvante qui finit
|
||||
# par exiger un Node plus récent que l'image (npm 12 -> Node >= 22) et casse le
|
||||
# build. Le npm livré avec l'image suffit (npm ci supporte lockfileVersion 3).
|
||||
COPY package*.json ./
|
||||
RUN npm ci --include=dev --ignore-scripts --no-audit --no-fund --no-progress
|
||||
COPY . .
|
||||
|
||||
@@ -42,6 +42,23 @@ module.exports = defineConfig([
|
||||
// Math.random() est légitime ici : tables aléatoires / jets de dés = le
|
||||
// domaine métier. Aucun usage cryptographique côté front.
|
||||
"sonarjs/pseudo-random": "off",
|
||||
// 9 fonctions historiques dépassent le seuil de 15 (jusqu'à 39 sur les
|
||||
// graphes campagne/chapitre). Les refactorer "pour le lint" sans filet de
|
||||
// tests serait plus risqué qu'utile → warn (visible, non bloquant).
|
||||
// Backlog : campaign-graph (×3), chapter-graph (×2), campaign-import,
|
||||
// settings, persona-view, sse.util. Repasser en "error" une fois résorbé.
|
||||
"sonarjs/cognitive-complexity": "warn",
|
||||
// Convention TS standard : un préfixe `_` marque un paramètre/variable
|
||||
// volontairement inutilisé (ex. paramètres conservés pour ne pas casser
|
||||
// les appelants — cf. campaign-tree.helper.ts).
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
1697
web/package-lock.json
generated
1697
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "1.0.2-beta",
|
||||
"version": "1.0.3",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
@@ -20,15 +20,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.16",
|
||||
"@angular/animations": "^21.2.17",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.16",
|
||||
"@angular/compiler": "^21.2.16",
|
||||
"@angular/core": "^21.2.16",
|
||||
"@angular/forms": "^21.2.16",
|
||||
"@angular/platform-browser": "^21.2.16",
|
||||
"@angular/platform-browser-dynamic": "^21.2.16",
|
||||
"@angular/router": "^21.2.16",
|
||||
"@angular/common": "^21.2.17",
|
||||
"@angular/compiler": "^21.2.17",
|
||||
"@angular/core": "^21.2.17",
|
||||
"@angular/forms": "^21.2.17",
|
||||
"@angular/platform-browser": "^21.2.17",
|
||||
"@angular/platform-browser-dynamic": "^21.2.17",
|
||||
"@angular/router": "^21.2.17",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"dompurify": "^3.4.1",
|
||||
@@ -41,7 +41,7 @@
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^21.2.14",
|
||||
"@angular/cli": "^21.2.14",
|
||||
"@angular/compiler-cli": "^21.2.16",
|
||||
"@angular/compiler-cli": "^21.2.17",
|
||||
"@emnapi/core": "^1.11.0",
|
||||
"@emnapi/runtime": "^1.11.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -25,7 +25,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
templateUrl: './arc-create.component.html',
|
||||
styleUrls: ['./arc-create.component.scss']
|
||||
})
|
||||
export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
export class ArcCreateComponent implements OnInit {
|
||||
readonly BookOpen = BookOpen;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
|
||||
@@ -88,11 +88,4 @@ export class ArcCreateComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,5 +183,5 @@
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="'arcEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
@@ -42,7 +42,7 @@ import { FieldProposal } from '../../../services/entity-assist.model';
|
||||
templateUrl: './arc-edit.component.html',
|
||||
styleUrls: ['./arc-edit.component.scss']
|
||||
})
|
||||
export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
export class ArcEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
@@ -221,11 +221,4 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -35,7 +35,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
templateUrl: './arc-view.component.html',
|
||||
styleUrls: ['./arc-view.component.scss']
|
||||
})
|
||||
export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
export class ArcViewComponent implements OnInit {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Plus = Plus;
|
||||
@@ -162,8 +162,4 @@ export class ArcViewComponent implements OnInit, OnDestroy {
|
||||
error: () => console.error('Impossible de récupérer les dépendances de l\'arc')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
private readonly DRAG_THRESHOLD = 4;
|
||||
|
||||
// Adjacence (ids de nœuds) — sert à recalculer les arêtes après un drag.
|
||||
private adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
||||
private adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
|
||||
|
||||
// Disposition personnalisée : positions sauvegardées sur la campagne, sauvegarde
|
||||
// différée après un drag (évite un PUT par pixel déplacé).
|
||||
@@ -174,7 +174,10 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
if (this.draggingId) return; // pas de changement de focus en plein drag
|
||||
this.hoveredId = node.id;
|
||||
this.hoverNeighbors = new Set(
|
||||
this.adjacency.flatMap(e => e.a === node.id ? [e.b] : e.b === node.id ? [e.a] : []));
|
||||
this.adjacency.flatMap(e => {
|
||||
if (e.a === node.id) return [e.b];
|
||||
return e.b === node.id ? [e.a] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
onNodeLeave(): void {
|
||||
@@ -230,7 +233,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
for (const [arcId, chapters] of Object.entries(treeData.chaptersByArc)) {
|
||||
for (const ch of chapters) arcOfChapter.set(ch.id!, arcId);
|
||||
}
|
||||
const linkedScenes: Array<{ scene: Scene; chapterId: string }> = [];
|
||||
const linkedScenes: { scene: Scene; chapterId: string }[] = [];
|
||||
if (!this.hiddenKinds.has('scene')) {
|
||||
for (const [chapterId, scenes] of Object.entries(treeData.scenesByChapter)) {
|
||||
if (!arcOfChapter.has(chapterId)) continue;
|
||||
@@ -268,7 +271,7 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Arêtes. Les liens page↔page sont dé-dupliqués par paire non-orientée
|
||||
// (A→B et B→A = un seul trait) ; les liens entité→page portent le type de l'entité.
|
||||
const adjacency: Array<{ key: string; kind: NodeKind; a: string; b: string }> = [];
|
||||
const adjacency: { key: string; kind: NodeKind; a: string; b: string }[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
for (const p of pages) {
|
||||
for (const targetId of p.relatedPageIds ?? []) {
|
||||
@@ -530,14 +533,16 @@ export class CampaignGraphComponent implements OnInit, OnDestroy {
|
||||
if (Math.abs(dx) >= needX || Math.abs(dy) >= needY) continue;
|
||||
const overlapX = needX - Math.abs(dx);
|
||||
const overlapY = needY - Math.abs(dy);
|
||||
// Départage déterministe quand les centres coïncident (dx/dy = 0).
|
||||
const tieBreak = i % 2 === 0 ? 1 : -1;
|
||||
if (overlapX / needX < overlapY / needY) {
|
||||
const shift = overlapX / 2 + 0.5;
|
||||
const sign = dx !== 0 ? Math.sign(dx) : (i % 2 === 0 ? 1 : -1);
|
||||
const sign = dx !== 0 ? Math.sign(dx) : tieBreak;
|
||||
a.x -= sign * shift;
|
||||
b.x += sign * shift;
|
||||
} else {
|
||||
const shift = overlapY / 2 + 0.5;
|
||||
const sign = dy !== 0 ? Math.sign(dy) : (i % 2 === 0 ? 1 : -1);
|
||||
const sign = dy !== 0 ? Math.sign(dy) : tieBreak;
|
||||
a.y -= sign * shift;
|
||||
b.y += sign * shift;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ export function buildCampaignTree(
|
||||
// Clé TYPE|ID obligatoire : chaque table a sa propre séquence IDENTITY (une quête
|
||||
// id 6 et une scène id 6 coexistent) — sans le type, un manque de quête allumerait
|
||||
// à tort la pastille d'une scène/PNJ portant le même numéro.
|
||||
const sevRank = (s: ReadinessSeverity): number => (s === 'BLOCKING' ? 2 : s === 'RECOMMENDED' ? 1 : 0);
|
||||
const sevRank = (s: ReadinessSeverity): number => {
|
||||
if (s === 'BLOCKING') return 2;
|
||||
return s === 'RECOMMENDED' ? 1 : 0;
|
||||
};
|
||||
const gapsByKey = new Map<string, ReadinessGap[]>();
|
||||
for (const g of readinessGaps) {
|
||||
const key = `${g.entityType}|${g.entityId}`;
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
[placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||
(keydown.escape)="cancelCreateGameSystem()"
|
||||
autofocus
|
||||
|
||||
/>
|
||||
<div class="inline-create-actions">
|
||||
<button type="button" class="btn-inline-primary"
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface CampaignCreatePayload {
|
||||
styleUrls: ['./campaign-create.component.scss']
|
||||
})
|
||||
export class CampaignCreateComponent implements OnInit {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() created = new EventEmitter<CampaignCreatePayload>();
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
@@ -131,6 +131,6 @@ export class CampaignCreateComponent implements OnInit {
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
[placeholder]="'campaignDetail.gameSystemNamePlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); submitCreateGameSystem()"
|
||||
(keydown.escape)="cancelCreateGameSystem()"
|
||||
autofocus
|
||||
|
||||
/>
|
||||
<div class="inline-create-actions">
|
||||
<button type="button" class="btn-inline-primary"
|
||||
|
||||
@@ -52,6 +52,19 @@ interface NpcNode { name: string; description: string; selected: boolean; existi
|
||||
* Flux : upload → progression streamée → arbre éditable (revue) → création.
|
||||
* Rien n'est créé tant que l'utilisateur n'a pas validé « Créer dans la campagne ».
|
||||
*/
|
||||
|
||||
/** Nettoie les pièces (rooms) d'une scène pour le payload de création. */
|
||||
function cleanRooms(rooms: { name: string; description: string; enemies: string; loot: string }[]) {
|
||||
return rooms
|
||||
.filter(r => r.name.trim())
|
||||
.map(r => ({
|
||||
name: r.name.trim(),
|
||||
description: r.description.trim(),
|
||||
enemies: r.enemies.trim(),
|
||||
loot: r.loot.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-campaign-import',
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe, FileDropDirective],
|
||||
@@ -401,14 +414,7 @@ export class CampaignImportComponent implements OnInit {
|
||||
playerNarration: s.playerNarration.trim(),
|
||||
gmNotes: s.gmNotes.trim(),
|
||||
existingId: s.existingId ?? null,
|
||||
rooms: s.rooms
|
||||
.filter(r => r.name.trim())
|
||||
.map(r => ({
|
||||
name: r.name.trim(),
|
||||
description: r.description.trim(),
|
||||
enemies: r.enemies.trim(),
|
||||
loot: r.loot.trim()
|
||||
}))
|
||||
rooms: cleanRooms(s.rooms)
|
||||
}))
|
||||
}))
|
||||
})),
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
@if (showCreateModal) {
|
||||
<app-campaign-create
|
||||
(close)="onModalClose()"
|
||||
(closed)="onModalClose()"
|
||||
(created)="onCampaignCreated($event)">
|
||||
</app-campaign-create>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
templateUrl: './chapter-create.component.html',
|
||||
styleUrls: ['./chapter-create.component.scss']
|
||||
})
|
||||
export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
export class ChapterCreateComponent implements OnInit {
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
@@ -88,11 +88,4 @@ export class ChapterCreateComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,5 +151,5 @@
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="'chapterEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
@@ -52,7 +52,7 @@ import { SceneDraftPanelComponent } from '../../../shared/scene-draft-panel/scen
|
||||
templateUrl: './chapter-edit.component.html',
|
||||
styleUrls: ['./chapter-edit.component.scss']
|
||||
})
|
||||
export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
export class ChapterEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
@@ -220,6 +220,4 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, ElementRef, ViewChild, HostListener, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -29,7 +29,7 @@ interface GraphEdge { key: string; label: string; kind: LinkType; x1: number; y1
|
||||
templateUrl: './chapter-graph.component.html',
|
||||
styleUrls: ['./chapter-graph.component.scss']
|
||||
})
|
||||
export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
export class ChapterGraphComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Plus = Plus;
|
||||
readonly ZoomIn = ZoomIn;
|
||||
@@ -507,7 +507,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
scene.graphX = node.x;
|
||||
scene.graphY = node.y;
|
||||
this.campaignService.updateScene(nodeId, { ...scene, order: scene.order ?? 0 })
|
||||
.subscribe({ error: () => {} });
|
||||
.subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
@@ -548,7 +548,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.campaignService.createScene(payload).subscribe({
|
||||
next: () => this.load(),
|
||||
error: () => {}
|
||||
error: () => { /* best-effort : erreur ignorée volontairement */ }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
node.name = name;
|
||||
node.displayName = this.truncate(name);
|
||||
scene.name = name;
|
||||
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => {} });
|
||||
this.campaignService.updateScene(id, { ...scene, order: scene.order ?? 0 }).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
|
||||
cancelRename(): void {
|
||||
@@ -635,7 +635,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
if (existing.some(b => b.targetSceneId === toId)) return;
|
||||
const next: SceneBranch[] = [...existing, { label: '', targetSceneId: toId, condition: '', kind: 'EXIT' }];
|
||||
this.campaignService.updateScene(fromId, { ...scene, order: scene.order ?? 0, branches: next })
|
||||
.subscribe({ next: () => this.load(), error: () => {} });
|
||||
.subscribe({ next: () => this.load(), error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
|
||||
// ─────────────── Sélection / édition / suppression d'un lien ───────────────
|
||||
@@ -698,7 +698,7 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
? { ...b, label: this.editEdgeLabel.trim(), condition: this.editEdgeCondition.trim(), kind: this.editEdgeKind }
|
||||
: b);
|
||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
|
||||
/** Supprime (sépare) le lien sélectionné. */
|
||||
@@ -709,10 +709,6 @@ export class ChapterGraphComponent implements OnInit, OnDestroy {
|
||||
const { scene, branchIndex } = found;
|
||||
const branches = (scene.branches ?? []).filter((_, i) => i !== branchIndex);
|
||||
this.campaignService.updateScene(scene.id!, { ...scene, order: scene.order ?? 0, branches })
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => {} });
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
.subscribe({ next: () => { this.selectedEdgeKey = null; this.load(); }, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -30,7 +30,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
templateUrl: './chapter-view.component.html',
|
||||
styleUrls: ['./chapter-view.component.scss']
|
||||
})
|
||||
export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
export class ChapterViewComponent implements OnInit {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Network = Network;
|
||||
readonly Trash2 = Trash2;
|
||||
@@ -147,11 +147,4 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
error: () => console.error('Impossible de récupérer les dépendances du chapitre')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,6 @@
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="'characterEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
entityType="character"
|
||||
[entityId]="characterId"
|
||||
[isOpen]="chatOpen"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
}
|
||||
|
||||
@@ -57,7 +57,11 @@ export class ItemCatalogViewComponent implements OnInit {
|
||||
map.get(cat)!.push(it);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
|
||||
.sort(([a], [b]) => {
|
||||
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
|
||||
if (b === '—') return -1;
|
||||
return a.localeCompare(b, 'fr');
|
||||
})
|
||||
.map(([category, items]) => ({ category, items }));
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,6 @@
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="'npcEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
}
|
||||
|
||||
@@ -43,6 +43,6 @@
|
||||
entityType="npc"
|
||||
[entityId]="npcId"
|
||||
[isOpen]="chatOpen"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import { Page } from '../../../services/page.model';
|
||||
import { PersonaViewComponent } from '../../../shared/persona-view/persona-view.component';
|
||||
import { AiChatDrawerComponent } from '../../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
|
||||
/** Indexe des pages par id (les pages venant du serveur ont toujours un id). */
|
||||
function indexPagesById(pages: Page[]): Map<string, Page> {
|
||||
return new Map(pages.map(p => [p.id!, p]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue lecture seule "WorldAnvil" d'une fiche PNJ.
|
||||
* Route : /campaigns/:campaignId/npcs/:npcId
|
||||
@@ -89,7 +94,7 @@ export class NpcViewComponent implements OnInit, OnDestroy {
|
||||
if (camp.loreId) {
|
||||
this.loreId = camp.loreId;
|
||||
this.pageService.getByLoreId(camp.loreId).subscribe(pages => {
|
||||
this.lorePagesById = new Map(pages.map(p => [p.id!, p]));
|
||||
this.lorePagesById = indexPagesById(pages);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -34,7 +34,7 @@ import { SessionPrepPanelComponent } from '../../../shared/session-prep-panel/se
|
||||
templateUrl: './playthrough-detail.component.html',
|
||||
styleUrls: ['./playthrough-detail.component.scss']
|
||||
})
|
||||
export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
export class PlaythroughDetailComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Play = Play;
|
||||
readonly Flag = Flag;
|
||||
@@ -155,6 +155,4 @@ export class PlaythroughDetailComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -26,7 +26,7 @@ import { PlaythroughFlagsManagerComponent } from '../../../shared/playthrough-fl
|
||||
templateUrl: './playthrough-flags-page.component.html',
|
||||
styleUrls: ['./playthrough-flags-page.component.scss']
|
||||
})
|
||||
export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
export class PlaythroughFlagsPageComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
|
||||
campaignId = '';
|
||||
@@ -75,6 +75,4 @@ export class PlaythroughFlagsPageComponent implements OnInit, OnDestroy {
|
||||
back(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -41,7 +41,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
templateUrl: './quest-edit.component.html',
|
||||
styleUrls: ['./quest-edit.component.scss']
|
||||
})
|
||||
export class QuestEditComponent implements OnInit, OnDestroy {
|
||||
export class QuestEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
selectedIcon: string | null = null;
|
||||
@@ -232,6 +232,4 @@ export class QuestEditComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests']);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
templateUrl: './quest-view.component.html',
|
||||
styleUrls: ['./quest-view.component.scss']
|
||||
})
|
||||
export class QuestViewComponent implements OnInit, OnDestroy {
|
||||
export class QuestViewComponent implements OnInit {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly BookOpen = BookOpen;
|
||||
@@ -181,14 +181,32 @@ export class QuestViewComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'quests', this.questId, 'edit']);
|
||||
}
|
||||
|
||||
/** Suppression simple : une quête n'a pas d'enfants (les nœuds sont des références faibles). */
|
||||
/**
|
||||
* Suppression avec annonce de l'impact réel : le conteneur de scènes d'une quête
|
||||
* LIBRE (arc SYSTEM, invisible dans l'arbre) part en cascade avec ses scènes —
|
||||
* le nombre est affiché dans la confirmation. Les nœuds simplement liés et les
|
||||
* jumeaux de hub (qui restent visibles dans leur arc) ne sont pas concernés.
|
||||
*/
|
||||
deleteQuest(): void {
|
||||
if (!this.quest) return;
|
||||
const quest = this.quest;
|
||||
if (!quest?.id) return;
|
||||
this.questService.deletionImpact(this.campaignId, quest.id).subscribe({
|
||||
next: impact => this.confirmDeleteQuest(quest, impact.scenes),
|
||||
error: () => this.confirmDeleteQuest(quest, 0) // impact indisponible : confirmation simple
|
||||
});
|
||||
}
|
||||
|
||||
private confirmDeleteQuest(quest: Quest, scenes: number): void {
|
||||
const details = [
|
||||
...(scenes > 0
|
||||
? [this.translate.instant('questView.deleteImpactScenes', { count: scenes })]
|
||||
: []),
|
||||
this.translate.instant('questView.irreversible')
|
||||
];
|
||||
this.confirmDialog.confirm({
|
||||
title: this.translate.instant('questView.deleteTitle'),
|
||||
message: this.translate.instant('questView.deleteMessage', { name: quest.name }),
|
||||
details: [this.translate.instant('questView.irreversible')],
|
||||
details,
|
||||
confirmLabel: this.translate.instant('common.delete'),
|
||||
variant: 'danger'
|
||||
}).then(ok => {
|
||||
@@ -199,8 +217,4 @@ export class QuestViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -24,7 +24,7 @@ import { CAMPAIGN_ICON_OPTIONS } from '../../campaign-icons';
|
||||
templateUrl: './scene-create.component.html',
|
||||
styleUrls: ['./scene-create.component.scss']
|
||||
})
|
||||
export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
export class SceneCreateComponent implements OnInit {
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
selectedIcon: string | null = null;
|
||||
|
||||
@@ -90,11 +90,4 @@ export class SceneCreateComponent implements OnInit, OnDestroy {
|
||||
cancel(): void {
|
||||
this.router.navigate(['/campaigns', this.campaignId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,5 +385,5 @@
|
||||
[isOpen]="chatOpen"
|
||||
[welcomeMessage]="'sceneEdit.chatWelcome' | translate"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
(close)="chatOpen = false">
|
||||
(closed)="chatOpen = false">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
@@ -67,7 +67,7 @@ interface BattlemapEdit {
|
||||
templateUrl: './scene-edit.component.html',
|
||||
styleUrls: ['./scene-edit.component.scss']
|
||||
})
|
||||
export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
export class SceneEditComponent implements OnInit {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly campaignIconOptions = CAMPAIGN_ICON_OPTIONS;
|
||||
@@ -251,7 +251,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
if (entry.mediaFileId) {
|
||||
this.storedFileService.getById(entry.mediaFileId)
|
||||
.subscribe({ next: f => entry.mediaName = f.filename, error: () => {} });
|
||||
.subscribe({ next: f => entry.mediaName = f.filename, error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
if (entry.dataFileId) {
|
||||
this.storedFileService.getById(entry.dataFileId).subscribe({
|
||||
@@ -260,7 +260,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
||||
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) entry.source = 'DUNGEONDRAFT';
|
||||
},
|
||||
error: () => {}
|
||||
error: () => { /* best-effort : erreur ignorée volontairement */ }
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
@@ -480,12 +480,14 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
onDd2vttSelected(bm: BattlemapEdit, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) void this.uploadDd2vtt(bm, file);
|
||||
// Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
|
||||
if (file) this.uploadDd2vtt(bm, file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onDd2vttDropped(bm: BattlemapEdit, files: File[]): void {
|
||||
if (files[0]) void this.uploadDd2vtt(bm, files[0]);
|
||||
// Fire-and-forget : uploadDd2vtt gère lui-même ses erreurs.
|
||||
if (files[0]) this.uploadDd2vtt(bm, files[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -552,13 +554,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private extForType(type: string): string {
|
||||
return type === 'image/jpeg' ? '.jpg' : type === 'image/webp' ? '.webp' : '.png';
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
if (type === 'image/jpeg') return '.jpg';
|
||||
return type === 'image/webp' ? '.webp' : '.png';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -31,7 +31,7 @@ import { ConfirmDialogService } from '../../../shared/confirm-dialog/confirm-dia
|
||||
templateUrl: './scene-view.component.html',
|
||||
styleUrls: ['./scene-view.component.scss']
|
||||
})
|
||||
export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
export class SceneViewComponent implements OnInit {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||
@@ -160,11 +160,4 @@ export class SceneViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +361,10 @@ export class GameSystemEditComponent implements OnInit {
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^##\s+(.+?)\s*$/);
|
||||
// `\S` force un début de titre non-blanc : frontière déterministe entre
|
||||
// `\s+` et la capture (l'ancien `.+?`+`\s*$` backtrackait). Le trim()
|
||||
// en aval retire les espaces de fin.
|
||||
const match = line.match(/^##\s+(\S.*)$/);
|
||||
if (match) {
|
||||
flush();
|
||||
current = { title: match[1].trim(), content: '', collapsed: false };
|
||||
|
||||
@@ -154,10 +154,14 @@ export class BlockGridBuilderComponent implements OnDestroy {
|
||||
|
||||
addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); }
|
||||
updateLabel(block: TemplateField, i: number, value: string): void {
|
||||
if (!block.labels) return; block.labels[i] = value; this.emit();
|
||||
if (!block.labels) return;
|
||||
block.labels[i] = value;
|
||||
this.emit();
|
||||
}
|
||||
removeLabel(block: TemplateField, i: number): void {
|
||||
if (!block.labels) return; block.labels = block.labels.filter((_, k) => k !== i); this.emit();
|
||||
if (!block.labels) return;
|
||||
block.labels = block.labels.filter((_, k) => k !== i);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// --- Glisser : création / déplacement / redimensionnement ---------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnDestroy, OnInit, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin } from 'rxjs';
|
||||
@@ -33,7 +33,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
templateUrl: './folder-view.component.html',
|
||||
styleUrls: ['./folder-view.component.scss']
|
||||
})
|
||||
export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
export class FolderViewComponent implements OnInit {
|
||||
readonly Folder = Folder;
|
||||
readonly FileText = FileText;
|
||||
readonly Pencil = Pencil;
|
||||
@@ -237,11 +237,4 @@ export class FolderViewComponent implements OnInit, OnDestroy {
|
||||
error: () => console.error('Impossible de récupérer les dépendances du dossier')
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
styleUrls: ['./lore-create.component.scss']
|
||||
})
|
||||
export class LoreCreateComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() created = new EventEmitter<{ name: string; description: string }>();
|
||||
|
||||
readonly BookCopy = BookCopy;
|
||||
@@ -32,6 +32,6 @@ export class LoreCreateComponent {
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.close.emit();
|
||||
this.closed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
@@ -11,7 +11,7 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { popReturnTo } from '../return-stack.helper';
|
||||
import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||
import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-lore-node-create',
|
||||
@@ -19,7 +19,7 @@ import { LORE_ICON_OPTIONS, IconOption, resolveIcon } from '../lore-icons';
|
||||
templateUrl: './lore-node-create.component.html',
|
||||
styleUrls: ['./lore-node-create.component.scss']
|
||||
})
|
||||
export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
||||
export class LoreNodeCreateComponent implements OnInit {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
@@ -109,11 +109,4 @@ export class LoreNodeCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
@@ -38,7 +38,7 @@ import { LORE_ICON_OPTIONS, IconOption } from '../lore-icons';
|
||||
templateUrl: './lore-node-edit.component.html',
|
||||
styleUrls: ['./lore-node-edit.component.scss']
|
||||
})
|
||||
export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
export class LoreNodeEditComponent implements OnInit {
|
||||
|
||||
readonly iconOptions: IconOption[] = LORE_ICON_OPTIONS;
|
||||
|
||||
@@ -147,11 +147,4 @@ export class LoreNodeEditComponent implements OnInit, OnDestroy {
|
||||
if (!key) return null;
|
||||
return this.iconOptions.find(o => o.key === key)?.icon ?? null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
@if (showCreateModal) {
|
||||
<app-lore-create
|
||||
(close)="onModalClose()"
|
||||
(closed)="onModalClose()"
|
||||
(created)="onLoreCreated($event)">
|
||||
</app-lore-create>
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
[systemPromptAddon]="wizardSystemPrompt"
|
||||
[quickSuggestions]="wizardSuggestions"
|
||||
[primaryAction]="wizardPrimaryAction"
|
||||
(close)="closeWizard()"
|
||||
(closed)="closeWizard()"
|
||||
(assistantReply)="onWizardReply($event)"
|
||||
(primaryActionClick)="applyWizardAndCreate()">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
@@ -32,7 +32,7 @@ import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-d
|
||||
templateUrl: './page-create.component.html',
|
||||
styleUrls: ['./page-create.component.scss']
|
||||
})
|
||||
export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
export class PageCreateComponent implements OnInit {
|
||||
readonly FileText = FileText;
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Plus = Plus;
|
||||
@@ -165,7 +165,7 @@ export class PageCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private restoreDraft(): void {
|
||||
let raw: string | null = null;
|
||||
let raw: string | null;
|
||||
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem(this.draftKey);
|
||||
@@ -328,7 +328,9 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
||||
* Retourne null si absent ou JSON invalide.
|
||||
*/
|
||||
private extractValuesBlock(reply: string): Record<string, string> | null {
|
||||
const match = reply.match(/<values>\s*([\s\S]*?)\s*<\/values>/i);
|
||||
// Pas de \s* autour de la capture (backtracking quadratique) :
|
||||
// JSON.parse tolère nativement les blancs en tête/queue.
|
||||
const match = reply.match(/<values>([\s\S]*?)<\/values>/i);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
@@ -342,11 +344,4 @@ Les clés du JSON doivent correspondre EXACTEMENT aux noms de champs indiqués.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,6 @@
|
||||
[isOpen]="chatOpen"
|
||||
[quickSuggestions]="chatQuickSuggestions"
|
||||
[primaryAction]="chatPrimaryAction"
|
||||
(close)="chatOpen = false"
|
||||
(closed)="chatOpen = false"
|
||||
(primaryActionClick)="onChatFillRequested()">
|
||||
</app-ai-chat-drawer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -44,7 +44,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
templateUrl: './page-edit.component.html',
|
||||
styleUrls: ['./page-edit.component.scss']
|
||||
})
|
||||
export class PageEditComponent implements OnInit, OnDestroy {
|
||||
export class PageEditComponent implements OnInit {
|
||||
readonly Sparkles = Sparkles;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
@@ -84,7 +84,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
||||
keyValueValues: Record<string, Record<string, string>> = {};
|
||||
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
||||
tableValues: Record<string, Array<Record<string, string>>> = {};
|
||||
tableValues: Record<string, Record<string, string>[]> = {};
|
||||
/** Étiquettes libres (Phase 5B). */
|
||||
tags: string[] = [];
|
||||
/** IDs des pages liées (Phase 5B). */
|
||||
@@ -200,7 +200,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
const imageBase: Record<string, string[]> = {};
|
||||
const framingBase: Record<string, Record<string, ImageFraming>> = {};
|
||||
const kvBase: Record<string, Record<string, string>> = {};
|
||||
const tableBase: Record<string, Array<Record<string, string>>> = {};
|
||||
const tableBase: Record<string, Record<string, string>[]> = {};
|
||||
// Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé,
|
||||
// puis par nom (pages dont les valeurs étaient encore rangées par nom — elles
|
||||
// sont ainsi migrées vers la clé id à la prochaine sauvegarde).
|
||||
@@ -259,7 +259,9 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
addTableRow(fieldName: string, columns: string[] | null | undefined): void {
|
||||
const row: Record<string, string> = {};
|
||||
for (const col of columns ?? []) row[col] = '';
|
||||
(this.tableValues[fieldName] ??= []).push(row);
|
||||
const rows = this.tableValues[fieldName] ?? [];
|
||||
rows.push(row);
|
||||
this.tableValues[fieldName] = rows;
|
||||
}
|
||||
|
||||
removeTableRow(fieldName: string, rowIndex: number): void {
|
||||
@@ -267,7 +269,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Lignes du tableau d'un champ — toujours un tableau (jamais undefined) pour le `@for`. */
|
||||
tableRows(fieldName: string): Array<Record<string, string>> {
|
||||
tableRows(fieldName: string): Record<string, string>[] {
|
||||
return this.tableValues[fieldName] ?? [];
|
||||
}
|
||||
|
||||
@@ -343,11 +345,4 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy, DestroyRef } from '@angular/core';
|
||||
import { Component, OnInit, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -36,7 +36,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
templateUrl: './page-view.component.html',
|
||||
styleUrls: ['./page-view.component.scss']
|
||||
})
|
||||
export class PageViewComponent implements OnInit, OnDestroy {
|
||||
export class PageViewComponent implements OnInit {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Trash2 = Trash2;
|
||||
|
||||
@@ -155,7 +155,7 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
||||
tableRowsOf(field: TemplateField): Array<Record<string, string>> {
|
||||
tableRowsOf(field: TemplateField): Record<string, string>[] {
|
||||
const v = this.page?.tableValues;
|
||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
||||
}
|
||||
@@ -196,11 +196,4 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
@@ -24,7 +24,7 @@ import { popReturnTo } from '../return-stack.helper';
|
||||
templateUrl: './template-create.component.html',
|
||||
styleUrls: ['./template-create.component.scss']
|
||||
})
|
||||
export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
export class TemplateCreateComponent implements OnInit {
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
nodes: LoreNode[] = [];
|
||||
@@ -85,7 +85,7 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private restoreDraft(): void {
|
||||
let raw: string | null = null;
|
||||
let raw: string | null;
|
||||
try { raw = sessionStorage.getItem(this.draftKey); } catch { return; }
|
||||
if (!raw) return;
|
||||
sessionStorage.removeItem(this.draftKey);
|
||||
@@ -146,11 +146,4 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.router.navigate(['/lore', this.loreId]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
// show(). Eviter d'appeler hide() ici previent le clignotement / la
|
||||
// disparition de la sidebar lors des navigations internes a la section.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, catchError, of } from 'rxjs';
|
||||
import { Observable, catchError, map, of } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Reflet de LicenseStatus (enum cote backend).
|
||||
@@ -44,13 +44,13 @@ export interface BetaStatusDTO {
|
||||
enabled: boolean;
|
||||
updateAvailable: boolean;
|
||||
anyUnknown: boolean;
|
||||
images: Array<{
|
||||
images: {
|
||||
image: string;
|
||||
localVersion: string | null;
|
||||
remoteVersion: string | null;
|
||||
status: 'UP_TO_DATE' | 'UPDATE_AVAILABLE' | 'UNKNOWN';
|
||||
updateAvailable: boolean;
|
||||
}>;
|
||||
}[];
|
||||
checkedAt: string;
|
||||
disabledReason: string | null;
|
||||
}
|
||||
@@ -86,10 +86,13 @@ export class LicenseService {
|
||||
|
||||
disconnect(): Observable<boolean> {
|
||||
return this.http.delete<void>(this.apiUrl, this.authOptions).pipe(
|
||||
// Convertit en boolean : true = succes, false = erreur
|
||||
// (catchError plus bas masque les detail HTTP)
|
||||
catchError(() => of(false as any))
|
||||
) as unknown as Observable<boolean>;
|
||||
// Convertit en boolean : true = succes, false = erreur.
|
||||
// (Avant : le cast `as unknown as` masquait l'absence de map() — le
|
||||
// succes emettait `undefined`, donc falsy. Consommateur actuel ignore
|
||||
// la valeur, mais le type est desormais honnete.)
|
||||
map(() => true),
|
||||
catchError(() => of(false))
|
||||
);
|
||||
}
|
||||
|
||||
refresh(): Observable<LicenseStatusDTO | null> {
|
||||
|
||||
@@ -48,7 +48,9 @@ export interface NotebookAction {
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
||||
// Pas de \s* avant la capture (backtracking quadratique) : la capture est
|
||||
// trim()ée avant JSON.parse de toute façon.
|
||||
const ACTION_RE = /```loremind-action([\s\S]*?)```/g;
|
||||
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,13 @@ import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChat
|
||||
import { LanguageService } from './language.service';
|
||||
import { parseSseStream } from '../shared/sse.util';
|
||||
|
||||
/** Normalise les sources renvoyées par le Brain (événement SSE `sources`). */
|
||||
function mapSseSources(sources: { source_id?: string; page?: number | null; score?: number }[] | undefined) {
|
||||
return (sources ?? []).map(s => ({
|
||||
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
|
||||
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
|
||||
@@ -96,12 +103,7 @@ export class NotebookService {
|
||||
} else if (event === 'sources') {
|
||||
try {
|
||||
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
|
||||
emit({
|
||||
type: 'sources',
|
||||
sources: (o.sources ?? []).map(s => ({
|
||||
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
|
||||
}))
|
||||
});
|
||||
emit({ type: 'sources', sources: mapSseSources(o.sources) });
|
||||
} catch { /* ignore */ }
|
||||
} else if (event === 'progress') {
|
||||
try {
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface Page {
|
||||
* Pour chaque champ TABLE (colonnes figees au template, lignes libres) :
|
||||
* fieldName → lignes ordonnees, chaque ligne = colonne → cellule.
|
||||
*/
|
||||
tableValues?: Record<string, Array<Record<string, string>>>;
|
||||
tableValues?: Record<string, Record<string, string>[]>;
|
||||
notes?: string | null;
|
||||
tags?: string[];
|
||||
relatedPageIds?: string[];
|
||||
|
||||
@@ -3,6 +3,11 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Quest, QuestCreate } from './campaign.model';
|
||||
|
||||
/** Impact d'une suppression : scènes du conteneur (quête libre) qui partiront avec. */
|
||||
export interface QuestDeletionImpact {
|
||||
scenes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service HTTP pour les Quêtes (Niveau 1). API nestée sous la campagne :
|
||||
* /api/campaigns/{campaignId}/quests. Si {@code playthroughId} est fourni, les
|
||||
@@ -39,6 +44,10 @@ export class QuestService {
|
||||
return this.http.delete<void>(`${this.base(campaignId)}/${questId}`);
|
||||
}
|
||||
|
||||
deletionImpact(campaignId: string, questId: string): Observable<QuestDeletionImpact> {
|
||||
return this.http.get<QuestDeletionImpact>(`${this.base(campaignId)}/${questId}/deletion-impact`);
|
||||
}
|
||||
|
||||
reorder(campaignId: string, orderedIds: string[]): Observable<void> {
|
||||
return this.http.put<void>(`${this.base(campaignId)}/reorder`, { orderedIds });
|
||||
}
|
||||
|
||||
@@ -46,9 +46,10 @@ export class VersionCheckerService {
|
||||
*/
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.fetchInitial();
|
||||
// Fire-and-forget : fetchInitial/poll gèrent leurs erreurs en interne.
|
||||
this.fetchInitial();
|
||||
this.timer = setInterval(
|
||||
() => void this.poll(),
|
||||
() => { this.poll(); },
|
||||
VersionCheckerService.POLL_INTERVAL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
name="editName"
|
||||
(keydown.enter)="saveRename()"
|
||||
(keydown.escape)="cancelRename()"
|
||||
autofocus />
|
||||
/>
|
||||
<button type="button" class="btn-icon" (click)="saveRename()" [disabled]="!editName.trim()" [title]="'common.validate' | translate">
|
||||
<lucide-icon [img]="Check" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@@ -281,11 +281,14 @@ export class SessionDetailComponent implements OnInit, OnDestroy {
|
||||
if (!this.session) return;
|
||||
const session = this.session;
|
||||
const entryCount = this.entries.length;
|
||||
const entriesDetail = entryCount === 0
|
||||
? this.translate.instant('sessionDetail.deleteConfirm.noEntries')
|
||||
: entryCount === 1
|
||||
? this.translate.instant('sessionDetail.deleteConfirm.entriesOne')
|
||||
: this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
|
||||
let entriesDetail: string;
|
||||
if (entryCount === 0) {
|
||||
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.noEntries');
|
||||
} else if (entryCount === 1) {
|
||||
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesOne');
|
||||
} else {
|
||||
entriesDetail = this.translate.instant('sessionDetail.deleteConfirm.entriesMany', { n: entryCount });
|
||||
}
|
||||
const details = [
|
||||
entriesDetail,
|
||||
this.translate.instant('sessionDetail.deleteConfirm.irreversible')
|
||||
|
||||
@@ -58,7 +58,9 @@ export class SessionDicePanelComponent {
|
||||
}
|
||||
const sumRolls = rolls.reduce((s, n) => s + n, 0);
|
||||
const total = sumRolls + this.modifier;
|
||||
const modPart = this.modifier === 0 ? '' : (this.modifier > 0 ? `+${this.modifier}` : `${this.modifier}`);
|
||||
let modPart = '';
|
||||
if (this.modifier > 0) modPart = `+${this.modifier}`;
|
||||
else if (this.modifier < 0) modPart = `${this.modifier}`;
|
||||
const notation = `${safeCount}d${this.selectedFace}${modPart}`;
|
||||
const detailsPart = rolls.length > 1 ? ` [${rolls.join(', ')}]` : '';
|
||||
const summary = `🎲 ${notation}${detailsPart} = ${total}`;
|
||||
|
||||
@@ -52,7 +52,11 @@ export class SessionItemCatalogsPanelComponent implements OnInit {
|
||||
map.get(cat)!.push(it);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => (a === '—' ? 1 : b === '—' ? -1 : a.localeCompare(b, 'fr')))
|
||||
.sort(([a], [b]) => {
|
||||
if (a === '—') return 1; // catégorie "sans catégorie" toujours en dernier
|
||||
if (b === '—') return -1;
|
||||
return a.localeCompare(b, 'fr');
|
||||
})
|
||||
.map(([category, items]) => ({ category, items }));
|
||||
}
|
||||
|
||||
|
||||
@@ -209,9 +209,10 @@ export class OllamaModelManagerComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(err: any, fallback: string): string {
|
||||
if (err?.error?.detail) return String(err.error.detail);
|
||||
if (err?.message) return err.message;
|
||||
private extractError(err: unknown, fallback: string): string {
|
||||
const e = err as { error?: { detail?: unknown }; message?: string } | null;
|
||||
if (e?.error?.detail) return String(e.error.detail);
|
||||
if (e?.message) return e.message;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class SettingsComponent implements OnInit {
|
||||
/** Catalogue Gemini (dynamique si cle configuree, repli statique sinon). */
|
||||
geminiModels: GeminiModel[] = [];
|
||||
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
||||
oneminProvider: string = '';
|
||||
oneminProvider = '';
|
||||
|
||||
loadingModels = false;
|
||||
saving = false;
|
||||
@@ -419,9 +419,10 @@ export class SettingsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private extractError(err: any, fallback: string): string {
|
||||
if (err?.error?.detail) return String(err.error.detail);
|
||||
if (err?.message) return err.message;
|
||||
private extractError(err: unknown, fallback: string): string {
|
||||
const e = err as { error?: { detail?: unknown }; message?: string } | null;
|
||||
if (e?.error?.detail) return String(e.error.detail);
|
||||
if (e?.message) return e.message;
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,11 +130,12 @@ export class UpdatesSectionComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
this.licenseError = '';
|
||||
this.licenseService.install(jwt).subscribe((res) => {
|
||||
if ((res as any)?.error) {
|
||||
this.licenseError = (res as any).error;
|
||||
// install() renvoie une union typée : le garde `in` suffit à discriminer.
|
||||
if ('error' in res) {
|
||||
this.licenseError = res.error;
|
||||
return;
|
||||
}
|
||||
this.licenseStatus = res as LicenseStatusDTO;
|
||||
this.licenseStatus = res;
|
||||
this.licenseJwtInput = '';
|
||||
this.licenseSuccess = this.translate.instant('updatesSection.patreonConnectedSuccess');
|
||||
if (this.licenseStatus.betaChannelEnabled) {
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
(keyup.enter)="submitRenameTitle()"
|
||||
(keyup.escape)="cancelRenameTitle()"
|
||||
(blur)="submitRenameTitle()"
|
||||
autofocus />
|
||||
/>
|
||||
}
|
||||
} @else {
|
||||
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>
|
||||
|
||||
@@ -88,7 +88,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
/** Persistance activee ? false = mode wizard ephemere. */
|
||||
@Input() persistent = true;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
@Output() primaryActionClick = new EventEmitter<void>();
|
||||
@Output() assistantReply = new EventEmitter<string>();
|
||||
|
||||
@@ -180,7 +180,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.isWide = !this.isWide;
|
||||
try {
|
||||
localStorage.setItem(this.LS_WIDE, this.isWide ? '1' : '0');
|
||||
} catch {}
|
||||
} catch { /* localStorage indisponible : ignoré */ }
|
||||
}
|
||||
|
||||
/** Debut du drag : enregistre la position de depart + abonne listeners globaux. */
|
||||
@@ -216,7 +216,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (this.customWidth !== null) {
|
||||
try {
|
||||
localStorage.setItem(this.LS_WIDTH, String(this.customWidth));
|
||||
} catch {}
|
||||
} catch { /* localStorage indisponible : ignoré */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
this.isWide = localStorage.getItem(this.LS_WIDE) === '1';
|
||||
} catch {}
|
||||
} catch { /* localStorage indisponible : ignoré */ }
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
@@ -374,7 +374,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
|
||||
onClose(): void {
|
||||
this.abortStream();
|
||||
this.close.emit();
|
||||
this.closed.emit();
|
||||
}
|
||||
|
||||
send(): void {
|
||||
@@ -450,7 +450,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
this.scrollToBottom();
|
||||
|
||||
if (convId) {
|
||||
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => {} });
|
||||
this.conversationService.appendMessage(convId, 'user', text).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
}
|
||||
|
||||
this.streamSub = this.buildStream().subscribe({
|
||||
@@ -477,7 +477,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
next: () => {
|
||||
if (wasEmpty) this.triggerAutoTitle(convId);
|
||||
},
|
||||
error: () => {},
|
||||
error: () => { /* best-effort : erreur ignorée volontairement */ },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -505,7 +505,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
c.id === convId ? { ...c, title } : c,
|
||||
);
|
||||
},
|
||||
error: () => {},
|
||||
error: () => { /* best-effort : erreur ignorée volontairement */ },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
*/
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
route?: string | any[];
|
||||
route?: string | unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,9 @@ export class DiceUtils {
|
||||
|
||||
/** Parse une formule simple `[N]dM`. Renvoie null si invalide. */
|
||||
static parse(formula: string | null | undefined): ParsedDice | null {
|
||||
const m = /^\s*(\d*)\s*[dD]\s*(\d+)\s*$/.exec(formula ?? '');
|
||||
// trim() préalable au lieu de \s* aux extrémités : `\s*(\d*)\s*` était
|
||||
// ambigu (quadratique) quand \d* est vide. Même langage accepté.
|
||||
const m = /^(\d*)\s*[dD]\s*(\d+)$/.exec((formula ?? '').trim());
|
||||
if (!m) return null;
|
||||
const count = m[1] ? parseInt(m[1], 10) : 1;
|
||||
const faces = parseInt(m[2], 10);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
|
||||
import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
||||
|
||||
@@ -17,7 +17,7 @@ import { LucideAngularModule, ChevronDown, ChevronUp } from 'lucide-angular';
|
||||
templateUrl: './expandable-section.component.html',
|
||||
styleUrls: ['./expandable-section.component.scss']
|
||||
})
|
||||
export class ExpandableSectionComponent {
|
||||
export class ExpandableSectionComponent implements OnInit {
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
|
||||
|
||||
@@ -12,24 +12,34 @@ import { PageService } from '../../services/page.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { CampaignService } from '../../services/campaign.service';
|
||||
import { NpcService } from '../../services/npc.service';
|
||||
import { CharacterService } from '../../services/character.service';
|
||||
import { CharacterService, CharacterSearchResult } from '../../services/character.service';
|
||||
import { RandomTableService } from '../../services/random-table.service';
|
||||
import { ItemCatalogService } from '../../services/item-catalog.service';
|
||||
import { EnemyService } from '../../services/enemy.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { Campaign } from '../../services/campaign.model';
|
||||
import { Npc } from '../../services/npc.model';
|
||||
import { RandomTable } from '../../services/random-table.model';
|
||||
import { ItemCatalog } from '../../services/item-catalog.model';
|
||||
import { Enemy } from '../../services/enemy.model';
|
||||
|
||||
type ResultKind =
|
||||
| 'lore' | 'node' | 'template' | 'page' | 'campaign'
|
||||
| 'npc' | 'character' | 'random-table' | 'item-catalog' | 'enemy';
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
/** Optionnel dans les DTOs (objets pas encore persistés) mais toujours
|
||||
* présent sur des résultats de recherche serveur. */
|
||||
id: string | undefined;
|
||||
kind: ResultKind;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
/** Tag affiché sous le titre (ex: "Lore", "Dossier", "Template", "Page"). */
|
||||
tag: string;
|
||||
/** Route Angular (array pour router.navigate). */
|
||||
route: any[];
|
||||
route: (string | undefined)[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,8 +150,8 @@ export class GlobalSearchComponent implements OnInit, OnDestroy {
|
||||
* noeuds/templates, et enfin les racines (campagnes, lores).
|
||||
*/
|
||||
private buildResults(r: {
|
||||
lores: any[]; nodes: any[]; templates: any[]; pages: any[]; campaigns: any[];
|
||||
npcs: any[]; characters: any[]; tables: any[]; catalogs: any[]; enemies: any[];
|
||||
lores: Lore[]; nodes: LoreNode[]; templates: Template[]; pages: Page[]; campaigns: Campaign[];
|
||||
npcs: Npc[]; characters: CharacterSearchResult[]; tables: RandomTable[]; catalogs: ItemCatalog[]; enemies: Enemy[];
|
||||
}): SearchResult[] {
|
||||
const { lores, nodes, templates, pages, campaigns, npcs, characters, tables, catalogs, enemies } = r;
|
||||
const pageResults: SearchResult[] = pages.map(p => ({
|
||||
|
||||
@@ -130,7 +130,7 @@ export class ImageBlockComponent implements OnDestroy {
|
||||
const id = this.currentId;
|
||||
if (!id) return;
|
||||
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
|
||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
||||
this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
const ids = this.imageIds.filter(i => i !== id);
|
||||
if (this.framing?.[id]) {
|
||||
const next = { ...this.framing };
|
||||
|
||||
@@ -96,7 +96,7 @@ export class ImageGalleryComponent {
|
||||
event.stopPropagation(); // Evite d'ouvrir le lightbox en cliquant sur X.
|
||||
// On supprime aussi cote serveur pour ne pas laisser d'image orpheline.
|
||||
// Best-effort : on n'attend pas le retour pour emettre la nouvelle liste.
|
||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
||||
this.imageService.delete(id).subscribe({ error: () => { /* best-effort : erreur ignorée volontairement */ } });
|
||||
this.imageIdsChange.emit(this.imageIds.filter(i => i !== id));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ export class MarkdownPipe implements PipeTransform {
|
||||
if (!value) return '';
|
||||
const html = marked.parse(value, { async: false, gfm: true, breaks: true }) as string;
|
||||
const clean = DOMPurify.sanitize(html);
|
||||
// Revue sécurité : le bypass est sûr ICI car le HTML vient d'être passé
|
||||
// par DOMPurify juste au-dessus (le sanitizer Angular, moins permissif,
|
||||
// casserait le rendu markdown). Ne jamais bypasser sans DOMPurify amont.
|
||||
// eslint-disable-next-line sonarjs/no-angular-bypass-sanitization
|
||||
return this.sanitizer.bypassSecurityTrustHtml(clean);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
// PLAFONNÉ : avec beaucoup de templates, c'est la liste du panneau qui défile
|
||||
// (cf. .panel-list) — sans plafond, le panneau grandissait jusqu'à écraser
|
||||
// complètement l'arbre au-dessus (sidebar en overflow: hidden).
|
||||
flex-shrink: 0;
|
||||
max-height: 45%;
|
||||
}
|
||||
|
||||
.panel-header-row {
|
||||
@@ -202,6 +207,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
// Ascenseur interne, symétrique de celui de l'arbre (.tree) : l'en-tête du
|
||||
// panneau et son bouton « + » restent toujours visibles.
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
|
||||
@@ -36,8 +36,9 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Mode normal : spacer + outils -->
|
||||
@if (!(layoutConfig$ | async)) {
|
||||
<!-- Mode normal : spacer + outils. `=== null` (et pas `!`) : l'async pipe
|
||||
émet null tant que rien n'est reçu, la négation brute serait ambiguë. -->
|
||||
@if ((layoutConfig$ | async) === null) {
|
||||
<div class="spacer"></div>
|
||||
}
|
||||
|
||||
|
||||
@@ -1707,6 +1707,7 @@
|
||||
"notProvided": "Not provided",
|
||||
"deleteTitle": "Delete quest",
|
||||
"deleteMessage": "Delete the quest \"{{name}}\"?",
|
||||
"deleteImpactScenes": "{{count}} scene(s) of this quest will be deleted with it.",
|
||||
"irreversible": "This action is irreversible.",
|
||||
"deletedPage": "(deleted page)",
|
||||
"deletedQuest": "(deleted quest)",
|
||||
|
||||
@@ -1707,6 +1707,7 @@
|
||||
"notProvided": "Non renseigné",
|
||||
"deleteTitle": "Supprimer la quête",
|
||||
"deleteMessage": "Supprimer la quête « {{name}} » ?",
|
||||
"deleteImpactScenes": "{{count}} scène(s) de cette quête seront supprimées avec elle.",
|
||||
"irreversible": "Cette action est irréversible.",
|
||||
"deletedPage": "(page supprimée)",
|
||||
"deletedQuest": "(quête supprimée)",
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"types": [],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
@@ -29,6 +31,7 @@
|
||||
"strictTemplates": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"e2e/**/*",
|
||||
"playwright.config.ts",
|
||||
"vitest.config.ts",
|
||||
|
||||
Reference in New Issue
Block a user