Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s

CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
This commit is contained in:
2026-07-07 01:05:00 +02:00
parent 3e9e828225
commit 42c4b53ced
71 changed files with 353 additions and 276 deletions

View File

@@ -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.
}
}

View File

@@ -183,5 +183,5 @@
[isOpen]="chatOpen"
[welcomeMessage]="'arcEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -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.
}
}

View File

@@ -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.
}
}

View File

@@ -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;
}

View File

@@ -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}`;

View File

@@ -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"

View File

@@ -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();
}
}

View File

@@ -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"

View File

@@ -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)
}))
}))
})),

View File

@@ -39,7 +39,7 @@
@if (showCreateModal) {
<app-campaign-create
(close)="onModalClose()"
(closed)="onModalClose()"
(created)="onCampaignCreated($event)">
</app-campaign-create>
}

View File

@@ -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.
}
}

View File

@@ -151,5 +151,5 @@
[isOpen]="chatOpen"
[welcomeMessage]="'chapterEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -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 {}
}

View File

@@ -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 */ } });
}
}

View File

@@ -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.
}
}

View File

@@ -100,6 +100,6 @@
[isOpen]="chatOpen"
[welcomeMessage]="'characterEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -26,6 +26,6 @@
entityType="character"
[entityId]="characterId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -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 }));
}

View File

@@ -131,6 +131,6 @@
[isOpen]="chatOpen"
[welcomeMessage]="'npcEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -43,6 +43,6 @@
entityType="npc"
[entityId]="npcId"
[isOpen]="chatOpen"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>
}

View File

@@ -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);
});
}
});

View File

@@ -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 {}
}

View File

@@ -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 {}
}

View File

@@ -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 {}
}

View File

@@ -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;
@@ -199,8 +199,4 @@ export class QuestViewComponent implements OnInit, OnDestroy {
});
});
}
ngOnDestroy(): void {
// Volontairement vide : la sidebar reste prise en charge par le composant suivant.
}
}

View File

@@ -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.
}
}

View File

@@ -385,5 +385,5 @@
[isOpen]="chatOpen"
[welcomeMessage]="'sceneEdit.chatWelcome' | translate"
[quickSuggestions]="chatQuickSuggestions"
(close)="chatOpen = false">
(closed)="chatOpen = false">
</app-ai-chat-drawer>

View File

@@ -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';
}
}

View File

@@ -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.
}
}

View File

@@ -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 };

View File

@@ -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 ---------------

View File

@@ -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.
}
}

View File

@@ -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();
}
}

View File

@@ -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.
}
}

View File

@@ -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.
}
}

View File

@@ -39,7 +39,7 @@
@if (showCreateModal) {
<app-lore-create
(close)="onModalClose()"
(closed)="onModalClose()"
(created)="onLoreCreated($event)">
</app-lore-create>
}

View File

@@ -111,7 +111,7 @@
[systemPromptAddon]="wizardSystemPrompt"
[quickSuggestions]="wizardSuggestions"
[primaryAction]="wizardPrimaryAction"
(close)="closeWizard()"
(closed)="closeWizard()"
(assistantReply)="onWizardReply($event)"
(primaryActionClick)="applyWizardAndCreate()">
</app-ai-chat-drawer>

View File

@@ -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.
}
}

View File

@@ -195,6 +195,6 @@
[isOpen]="chatOpen"
[quickSuggestions]="chatQuickSuggestions"
[primaryAction]="chatPrimaryAction"
(close)="chatOpen = false"
(closed)="chatOpen = false"
(primaryActionClick)="onChatFillRequested()">
</app-ai-chat-drawer>

View File

@@ -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.
}
}

View File

@@ -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.
}
}

View File

@@ -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.
}
}

View File

@@ -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> {

View File

@@ -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']);
/**

View File

@@ -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 {

View File

@@ -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[];

View File

@@ -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,
);
}

View File

@@ -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>

View File

@@ -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')

View File

@@ -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}`;

View File

@@ -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 }));
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -80,7 +80,7 @@
(keyup.enter)="submitRenameTitle()"
(keyup.escape)="cancelRenameTitle()"
(blur)="submitRenameTitle()"
autofocus />
/>
}
} @else {
<h2 class="header-title">{{ 'aiChatDrawer.title' | translate }}</h2>

View File

@@ -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 */ },
});
}

View File

@@ -10,7 +10,7 @@ import { TranslatePipe } from '@ngx-translate/core';
*/
export interface BreadcrumbItem {
label: string;
route?: string | any[];
route?: string | unknown[];
}
/**

View File

@@ -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);

View File

@@ -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;

View File

@@ -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 => ({

View File

@@ -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 };

View File

@@ -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));
}

View File

@@ -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);
}
}

View File

@@ -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>
}