From 42c4b53ced4def1ecb57f2e6065fbf943ed3c1e1 Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 7 Jul 2026 01:05:00 +0200 Subject: [PATCH] =?UTF-8?q?Stack=20qualit=C3=A9=20CI=20(MegaLinter,=20Triv?= =?UTF-8?q?y,=20ESLint)=20et=20purge=20du=20backlog=20lint=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/e2e.yml | 4 +- .gitea/workflows/quality.yml | 22 +++++- .mega-linter.yml | 6 ++ java-pmd-ruleset.xml | 73 +++++++++++++++++++ web/eslint.config.js | 17 +++++ .../arc/arc-create/arc-create.component.ts | 11 +-- .../arc/arc-edit/arc-edit.component.html | 2 +- .../arc/arc-edit/arc-edit.component.ts | 11 +-- .../arc/arc-view/arc-view.component.ts | 8 +- .../campaign-graph.component.ts | 17 +++-- web/src/app/campaigns/campaign-tree.helper.ts | 5 +- .../campaign-create.component.html | 2 +- .../campaign-create.component.ts | 4 +- .../campaign-detail.component.html | 2 +- .../campaign-import.component.ts | 22 ++++-- .../app/campaigns/campaigns.component.html | 2 +- .../chapter-create.component.ts | 11 +-- .../chapter-edit/chapter-edit.component.html | 2 +- .../chapter-edit/chapter-edit.component.ts | 6 +- .../chapter-graph/chapter-graph.component.ts | 20 ++--- .../chapter-view/chapter-view.component.ts | 11 +-- .../character-edit.component.html | 2 +- .../character-view.component.html | 2 +- .../item-catalog-view.component.ts | 6 +- .../npc/npc-edit/npc-edit.component.html | 2 +- .../npc/npc-view/npc-view.component.html | 2 +- .../npc/npc-view/npc-view.component.ts | 7 +- .../playthrough-detail.component.ts | 6 +- .../playthrough-flags-page.component.ts | 6 +- .../quest/quest-edit/quest-edit.component.ts | 6 +- .../quest/quest-view/quest-view.component.ts | 8 +- .../scene-create/scene-create.component.ts | 11 +-- .../scene-edit/scene-edit.component.html | 2 +- .../scene/scene-edit/scene-edit.component.ts | 24 +++--- .../scene/scene-view/scene-view.component.ts | 11 +-- .../game-system-edit.component.ts | 5 +- .../block-grid-builder.component.ts | 8 +- .../lore/folder-view/folder-view.component.ts | 11 +-- .../lore/lore-create/lore-create.component.ts | 4 +- .../lore-node-create.component.ts | 13 +--- .../lore-node-edit.component.ts | 11 +-- web/src/app/lore/lore.component.html | 2 +- .../page-create/page-create.component.html | 2 +- .../lore/page-create/page-create.component.ts | 17 ++--- .../lore/page-edit/page-edit.component.html | 2 +- .../app/lore/page-edit/page-edit.component.ts | 21 ++---- .../app/lore/page-view/page-view.component.ts | 13 +--- .../template-create.component.ts | 13 +--- web/src/app/services/license.service.ts | 17 +++-- web/src/app/services/notebook-action.model.ts | 4 +- web/src/app/services/notebook.service.ts | 14 ++-- web/src/app/services/page.model.ts | 2 +- .../app/services/version-checker.service.ts | 5 +- .../session-detail.component.html | 2 +- .../session-detail.component.ts | 13 ++-- .../session-dice-panel.component.ts | 4 +- .../session-item-catalogs-panel.component.ts | 6 +- .../ollama-model-manager.component.ts | 7 +- web/src/app/settings/settings.component.ts | 9 ++- .../updates-section.component.ts | 7 +- .../ai-chat-drawer.component.html | 2 +- .../ai-chat-drawer.component.ts | 16 ++-- .../shared/breadcrumb/breadcrumb.component.ts | 2 +- web/src/app/shared/dice.utils.ts | 4 +- .../expandable-section.component.ts | 4 +- .../global-search/global-search.component.ts | 20 +++-- .../image-block/image-block.component.ts | 2 +- .../image-gallery/image-gallery.component.ts | 2 +- web/src/app/shared/markdown.pipe.ts | 4 + web/src/app/sidebar/sidebar.component.html | 5 +- web/tsconfig.json | 3 + 71 files changed, 353 insertions(+), 276 deletions(-) create mode 100644 java-pmd-ruleset.xml diff --git a/.gitea/workflows/e2e.yml b/.gitea/workflows/e2e.yml index d68aea6..0fa353a 100644 --- a/.gitea/workflows/e2e.yml +++ b/.gitea/workflows/e2e.yml @@ -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/ diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index 209cb9b..aa81b7b 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -32,9 +32,11 @@ jobs: # 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/ @@ -64,11 +66,29 @@ 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é. + # Si un 429 Maven Central réapparaît malgré le cache : ajouter + # --offline-scan (aucun accès réseau pendant la résolution, au prix + # d'ignorer silencieusement ce qui manquerait dans ~/.m2). - name: Scan des dépendances (HIGH/CRITICAL bloquants) run: | trivy fs . \ diff --git a/.mega-linter.yml b/.mega-linter.yml index 27b73fb..c8e6468 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -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 diff --git a/java-pmd-ruleset.xml b/java-pmd-ruleset.xml new file mode 100644 index 0000000..a27b321 --- /dev/null +++ b/java-pmd-ruleset.xml @@ -0,0 +1,73 @@ + + + + + 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…). + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/eslint.config.js b/web/eslint.config.js index 10eadb0..aca9a12 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -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: "^_", + }, + ], }, }, { diff --git a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts index a5b243c..c078e7e 100644 --- a/web/src/app/campaigns/arc/arc-create/arc-create.component.ts +++ b/web/src/app/campaigns/arc/arc-create/arc-create.component.ts @@ -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. - } } diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html index 245d1a4..a0f35fc 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.html @@ -183,5 +183,5 @@ [isOpen]="chatOpen" [welcomeMessage]="'arcEdit.chatWelcome' | translate" [quickSuggestions]="chatQuickSuggestions" - (close)="chatOpen = false"> + (closed)="chatOpen = false"> diff --git a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts index bd3bf2e..7ff9f6e 100644 --- a/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts +++ b/web/src/app/campaigns/arc/arc-edit/arc-edit.component.ts @@ -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. - } } diff --git a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts index 0b36288..f989202 100644 --- a/web/src/app/campaigns/arc/arc-view/arc-view.component.ts +++ b/web/src/app/campaigns/arc/arc-view/arc-view.component.ts @@ -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. - } } diff --git a/web/src/app/campaigns/campaign-graph/campaign-graph.component.ts b/web/src/app/campaigns/campaign-graph/campaign-graph.component.ts index 522f749..c714bba 100644 --- a/web/src/app/campaigns/campaign-graph/campaign-graph.component.ts +++ b/web/src/app/campaigns/campaign-graph/campaign-graph.component.ts @@ -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(); 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; } diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index fad34a4..3ff8a04 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -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(); for (const g of readinessGaps) { const key = `${g.entityType}|${g.entityId}`; diff --git a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html index 231838c..a7862f8 100644 --- a/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html +++ b/web/src/app/campaigns/campaign/campaign-create/campaign-create.component.html @@ -69,7 +69,7 @@ [placeholder]="'campaignCreate.gameSystemNamePlaceholder' | translate" (keydown.enter)="$event.preventDefault(); submitCreateGameSystem()" (keydown.escape)="cancelCreateGameSystem()" - autofocus + />
diff --git a/web/src/app/sessions/session-detail/session-detail.component.ts b/web/src/app/sessions/session-detail/session-detail.component.ts index e043774..3a1dae1 100644 --- a/web/src/app/sessions/session-detail/session-detail.component.ts +++ b/web/src/app/sessions/session-detail/session-detail.component.ts @@ -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') diff --git a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts index 21c0794..3a5d377 100644 --- a/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts +++ b/web/src/app/sessions/session-dice-panel/session-dice-panel.component.ts @@ -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}`; diff --git a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts index 349c47f..21b5b15 100644 --- a/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts +++ b/web/src/app/sessions/session-item-catalogs-panel/session-item-catalogs-panel.component.ts @@ -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 })); } diff --git a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts index 28f250c..e911cd3 100644 --- a/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts +++ b/web/src/app/settings/ollama-model-manager/ollama-model-manager.component.ts @@ -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; } } diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index 3b7067a..b69e565 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -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; } } diff --git a/web/src/app/settings/updates-section/updates-section.component.ts b/web/src/app/settings/updates-section/updates-section.component.ts index 430f37b..693b010 100644 --- a/web/src/app/settings/updates-section/updates-section.component.ts +++ b/web/src/app/settings/updates-section/updates-section.component.ts @@ -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) { diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html index 1d27b8f..50f2819 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html @@ -80,7 +80,7 @@ (keyup.enter)="submitRenameTitle()" (keyup.escape)="cancelRenameTitle()" (blur)="submitRenameTitle()" - autofocus /> + /> } } @else {

{{ 'aiChatDrawer.title' | translate }}

diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts index 9000de1..ced29db 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts @@ -88,7 +88,7 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy { /** Persistance activee ? false = mode wizard ephemere. */ @Input() persistent = true; - @Output() close = new EventEmitter(); + @Output() closed = new EventEmitter(); @Output() primaryActionClick = new EventEmitter(); @Output() assistantReply = new EventEmitter(); @@ -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 */ }, }); } diff --git a/web/src/app/shared/breadcrumb/breadcrumb.component.ts b/web/src/app/shared/breadcrumb/breadcrumb.component.ts index d7dcf4c..65b2486 100644 --- a/web/src/app/shared/breadcrumb/breadcrumb.component.ts +++ b/web/src/app/shared/breadcrumb/breadcrumb.component.ts @@ -10,7 +10,7 @@ import { TranslatePipe } from '@ngx-translate/core'; */ export interface BreadcrumbItem { label: string; - route?: string | any[]; + route?: string | unknown[]; } /** diff --git a/web/src/app/shared/dice.utils.ts b/web/src/app/shared/dice.utils.ts index d6da4a6..1d738a4 100644 --- a/web/src/app/shared/dice.utils.ts +++ b/web/src/app/shared/dice.utils.ts @@ -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); diff --git a/web/src/app/shared/expandable-section/expandable-section.component.ts b/web/src/app/shared/expandable-section/expandable-section.component.ts index ac7d919..3029628 100644 --- a/web/src/app/shared/expandable-section/expandable-section.component.ts +++ b/web/src/app/shared/expandable-section/expandable-section.component.ts @@ -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; diff --git a/web/src/app/shared/global-search/global-search.component.ts b/web/src/app/shared/global-search/global-search.component.ts index 281050d..55f0ddc 100644 --- a/web/src/app/shared/global-search/global-search.component.ts +++ b/web/src/app/shared/global-search/global-search.component.ts @@ -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 => ({ diff --git a/web/src/app/shared/image-block/image-block.component.ts b/web/src/app/shared/image-block/image-block.component.ts index 3d58bd6..591bd69 100644 --- a/web/src/app/shared/image-block/image-block.component.ts +++ b/web/src/app/shared/image-block/image-block.component.ts @@ -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 }; diff --git a/web/src/app/shared/image-gallery/image-gallery.component.ts b/web/src/app/shared/image-gallery/image-gallery.component.ts index cbac752..15f793a 100644 --- a/web/src/app/shared/image-gallery/image-gallery.component.ts +++ b/web/src/app/shared/image-gallery/image-gallery.component.ts @@ -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)); } diff --git a/web/src/app/shared/markdown.pipe.ts b/web/src/app/shared/markdown.pipe.ts index fdfec1c..3ff674c 100644 --- a/web/src/app/shared/markdown.pipe.ts +++ b/web/src/app/shared/markdown.pipe.ts @@ -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); } } diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index ab8e7bd..09b6deb 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -36,8 +36,9 @@ } - - @if (!(layoutConfig$ | async)) { + + @if ((layoutConfig$ | async) === null) {
} diff --git a/web/tsconfig.json b/web/tsconfig.json index 680f171..42443ae 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -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",