Commit 6b0b2b8b authored by Kourser's avatar Kourser
Browse files

Exports CSV : neutraliser les formules avant de les servir

L'échappement des guillemets était correct, et recopié à l'identique
dans les trois exports. Il manquait l'autre moitié du problème : un
tableur traite une cellule commençant par « = », « + », « - » ou « @ »
comme une FORMULE.

Ces fichiers portent du texte saisi par les joueurs et les animateurs —
main courante, constats de RETEX. Une entrée commençant par
« =HYPERLINK(…) » devenait un lien actif chez qui ouvre le rapport, et
d'autres fonctions vont plus loin. Le texte d'un exercice n'a aucune
raison de s'exécuter à l'ouverture.

Parade habituelle : une apostrophe en tête, que le tableur consomme en
affichant la valeur telle qu'elle a été saisie. Les trois exports
passent maintenant par la même écriture.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent ff2036f3
Loading
Loading
Loading
Loading
+30 −0
Changes for apps/api/src/common/csv-export.spec.ts: 30 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { cellule, ligneCsv } from './csv-export';

describe('cellule', () => {
  it('échappe les guillemets et entoure la valeur', () => {
    expect(cellule('Le "Groupe" en crise')).toBe('"Le ""Groupe"" en crise"');
  });

  it('neutralise les quatre amorces de formule', () => {
    for (const amorce of ['=', '+', '-', '@']) {
      expect(cellule(`${amorce}HYPERLINK("http://ailleurs")`)).toBe(
        `"'${amorce}HYPERLINK(""http://ailleurs"")"`,
      );
    }
  });

  it('laisse intact un texte ordinaire, y compris un signe au milieu', () => {
    expect(cellule('Bilan = positif')).toBe('"Bilan = positif"');
  });

  it('traite une cellule absente comme vide', () => {
    expect(cellule(null)).toBe('""');
    expect(cellule(undefined)).toBe('""');
  });
});

describe('ligneCsv', () => {
  it('assemble au point-virgule', () => {
    expect(ligneCsv(['a', 'b'])).toBe('"a";"b"');
  });
});
+31 −0
Changes for apps/api/src/common/csv-export.ts: 31 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Ecriture d'une cellule CSV — echappement ET neutralisation des formules.
 *
 * L'echappement des guillemets etait deja fait, correctement, mais recopie a
 * l'identique dans trois exports. Il manquait la seconde moitie du probleme.
 *
 * Un tableur traite une cellule commencant par « = », « + », « - » ou « @ »
 * comme une FORMULE. Ces fichiers portent du texte saisi par les joueurs et les
 * animateurs — main courante, constats de RETEX. Une entree commencant par
 * « =HYPERLINK(...) » devient un lien actif chez qui ouvre l'export, et d'autres
 * fonctions vont plus loin. Le texte d'un exercice n'a aucune raison d'etre
 * execute a l'ouverture du rapport.
 *
 * La parade est celle recommandee : une apostrophe en tete, que le tableur
 * consomme en affichant la valeur telle qu'elle a ete saisie.
 */

/** Caracteres qui font d'une cellule une formule, tabulation et retour compris. */
const AMORCES_DE_FORMULE = /^[=+\-@\t\r]/;

/** Une cellule prete a etre ecrite : neutralisee, echappee, entre guillemets. */
export function cellule(valeur: string | null | undefined): string {
  const texte = valeur ?? '';
  const sur = AMORCES_DE_FORMULE.test(texte) ? `'${texte}` : texte;
  return `"${sur.replace(/"/g, '""')}"`;
}

/** Une ligne de cellules, separees par le point-virgule attendu par les tableurs francophones. */
export function ligneCsv(cellules: (string | null | undefined)[]): string {
  return cellules.map(cellule).join(';');
}
+8 −8
Changes for apps/api/src/engine/log.service.ts: 8 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -7,6 +7,7 @@ import { RT } from './engine.types';
import type { PlayerContext } from '../auth/auth.types';
import type { CreateLogEntryDto } from './dto/create-log-entry.dto';
import type { UpdateLogEntryDto } from './dto/update-log-entry.dto';
import { ligneCsv } from '../common/csv-export';

export interface LogEntryView {
  id: string;
@@ -171,7 +172,6 @@ export class LogService {
      orderBy: { createdAt: 'asc' },
    });
    const STATUS: Record<string, string> = { TODO: 'A faire', IN_PROGRESS: 'En cours', DONE: 'Fait' };
    const esc = (s: string): string => `"${s.replace(/"/g, '""')}"`;
    const fmt = (sec: number): string =>
      `${String(Math.floor(sec / 3600)).padStart(2, '0')}:${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;
    const rows = [
@@ -179,18 +179,18 @@ export class LogService {
        ';',
      ),
      ...entries.map((e, i) =>
        [
        ligneCsv([
          String(i + 1),
          fmt(e.atExerciseSec),
          e.createdAt.toISOString(),
          e.category,
          esc(e.authorLabel),
          esc(e.source ?? ''),
          esc(e.message),
          esc(e.assignee ?? ''),
          esc(e.dueLabel ?? ''),
          e.authorLabel,
          e.source,
          e.message,
          e.assignee,
          e.dueLabel,
          e.status ? STATUS[e.status] : '',
        ].join(';'),
        ]),
      ),
    ];
    return rows.join('\n');
+6 −13
Changes for apps/api/src/engine/report.service.ts: 6 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import { RetexService } from './retex.service';
import { ObservationService } from './observation.service';
import { detectFindings, type Finding, type ReportFacts } from './findings';
import { ordered as orderedPhases } from './phases';
import { ligneCsv } from '../common/csv-export';

function fmt(sec: number): string {
  const s = Math.max(0, Math.floor(sec));
@@ -275,36 +276,28 @@ export class ReportService {
   */
  async exportFindingsCsv(tenantId: string, exerciseId: string): Promise<string> {
    const report = await this.getReport(tenantId, exerciseId);
    const esc = (x: string): string => `"${x.replace(/"/g, '""')}"`;
    return [
      ['Constat', 'Mesure', 'Pourquoi', 'Piste d action', 'Elements'].join(';'),
      ...report.findings.map((f) =>
        [
          esc(f.title),
          esc(f.measure),
          esc(f.why),
          esc(f.action),
          esc(f.evidence.map((e) => e.label).join(' | ')),
        ].join(';'),
        ligneCsv([f.title, f.measure, f.why, f.action, f.evidence.map((e) => e.label).join(' | ')]),
      ),
    ].join('\n');
  }

  async exportCsv(tenantId: string, exerciseId: string): Promise<string> {
    const report = await this.getReport(tenantId, exerciseId);
    const esc = (s: string): string => `"${s.replace(/"/g, '""')}"`;
    const rows = [
      ['T+ prevu', 'T+ reel', 'Ecart (s)', 'Canal', 'Statut', 'Inject', 'Objet'].join(';'),
      ...report.injectTimeline.map((i) =>
        [
        ligneCsv([
          i.plannedOffsetSec != null ? fmt(i.plannedOffsetSec) : '',
          i.actualAtSec != null ? fmt(i.actualAtSec) : '',
          i.deltaSec != null ? String(i.deltaSec) : '',
          i.channel,
          i.status,
          esc(i.title),
          esc(i.subject ?? ''),
        ].join(';'),
          i.title,
          i.subject,
        ]),
      ),
    ];
    return rows.join('\n');