Unverified Commit d9ff9e7e authored by Kourser's avatar Kourser
Browse files

Kits : importer chronogramme, personnages et phases depuis un CSV

Un chronogramme se prépare dans un tableur, à plusieurs. Le saisir
ensuite inject par inject dans l'éditeur n'était pas tenable.

Les colonnes sont NOMMÉES et non positionnelles : la première ligne les
déclare, dans l'ordre voulu, avec ou sans accents, en français comme en
anglais. Une ligne fautive n'emporte pas le fichier — les valides
passent, les autres reviennent avec leur numéro de ligne du tableur et
leur motif, de quoi corriger à la source. Expéditeurs et destinataires
se donnent par NOM de personnage : personne ne connaît les références
internes d'un kit, et un nom mal orthographié est refusé plutôt que de
produire un message sans émetteur, qu'on découvrirait en pleine
animation.

L'import ajoute et n'efface rien, dit avant le clic : vider une section
sur une fausse manœuvre serait irréparable.

Le lecteur de CSV est neuf. Celui de l'annuaire d'exercice découpait sur
« ; » ou « , » sans voir les guillemets : un intitulé comme
« Directeur, adjoint » décalait toutes les colonnes en silence. Celui-ci
tient les champs entre guillemets, les guillemets doublés, les retours à
la ligne internes, la marque d'octets d'Excel, et devine le séparateur
sur la seule ligne d'en-tête — sinon un corps de message plein de
virgules l'emporterait sur des colonnes en points-virgules.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 77e6c855
Loading
Loading
Loading
Loading
+53 −0
Original line number Diff line number Diff line
import { devinerSeparateur, parseCsv, parseCsvTable } from './csv';

describe('parseCsv', () => {
  it('respecte les virgules a l’interieur d’un champ entre guillemets', () => {
    const [ligne] = parseCsv('nom;fonction\n"Riviere, Paul";"Directeur, adjoint"').slice(1);
    expect(ligne).toEqual(['Riviere, Paul', 'Directeur, adjoint']);
  });

  it('rend un guillemet double comme un guillemet litteral', () => {
    const [ligne] = parseCsv('titre\n"Le ""Groupe"" en crise"').slice(1);
    expect(ligne).toEqual(['Le "Groupe" en crise']);
  });

  it('garde un retour a la ligne contenu dans un champ', () => {
    const [ligne] = parseCsv('corps\n"Premiere ligne\nSeconde ligne"').slice(1);
    expect(ligne).toEqual(['Premiere ligne\nSeconde ligne']);
  });

  it('ecarte les lignes vides, y compris celle que laisse un tableur en fin de fichier', () => {
    expect(parseCsv('a;b\n1;2\n\n;\n')).toHaveLength(2);
  });

  it('retire la marque d’ordre des octets ajoutee par Excel', () => {
    expect(parseCsv('nom;fonction')[0][0]).toBe('nom');
  });
});

describe('devinerSeparateur', () => {
  it('se decide sur la premiere ligne, pas sur les corps de message', () => {
    // Le corps est plein de virgules ; les colonnes, elles, sont separees par
    // des points-virgules.
    const csv = 'titre;corps\nAlerte;"un, deux, trois, quatre, cinq"';
    expect(devinerSeparateur(csv)).toBe(';');
  });

  it('reconnait la virgule et la tabulation', () => {
    expect(devinerSeparateur('a,b,c\n1,2,3')).toBe(',');
    expect(devinerSeparateur('a\tb\tc')).toBe('\t');
  });
});

describe('parseCsvTable', () => {
  it('retrouve une colonne quels que soient sa casse, ses accents et son rang', () => {
    const { lignes } = parseCsvTable('Fonction;NOM\nDSI;Thomas Blanc');
    expect(lignes[0].valeur('nom')).toBe('Thomas Blanc');
    expect(lignes[0].valeur('fonction')).toBe('DSI');
  });

  it('numerote les lignes comme le tableur, en-tete comprise', () => {
    const { lignes } = parseCsvTable('nom\nA\nB');
    expect(lignes.map((l) => l.numero)).toEqual([2, 3]);
  });
});
+138 −0
Original line number Diff line number Diff line
/**
 * Lecture de CSV — guillemets compris.
 *
 * L'import de personnages d'un exercice se contentait d'un `split(/[;,]/)` : le
 * premier corps d'inject contenant une virgule, ou un intitule comme
 * « Directeur, adjoint », decalait toutes les colonnes sans rien signaler. Un
 * fichier produit par un tableur en contient toujours.
 *
 * Ce qui est traite : le separateur devine (point-virgule, virgule, tabulation),
 * les champs entre guillemets, les guillemets doubles a l'interieur (""), les
 * retours a la ligne DANS un champ, et la marque d'ordre des octets qu'Excel
 * ajoute en tete de fichier.
 */

/** Separateurs acceptes, par ordre de frequence dans les tableurs francophones. */
const SEPARATEURS = [';', ',', '\t'] as const;

/**
 * Devine le separateur sur la premiere ligne, hors guillemets.
 *
 * Compter sur tout le fichier serait trompeur : un corps de message plein de
 * virgules l'emporterait sur des colonnes separees par des points-virgules.
 */
export function devinerSeparateur(csv: string): string {
  let dansGuillemets = false;
  const comptes = new Map<string, number>(SEPARATEURS.map((s) => [s, 0]));
  for (let i = 0; i < csv.length; i += 1) {
    const c = csv[i];
    if (c === '"') {
      dansGuillemets = !dansGuillemets;
    } else if (!dansGuillemets && (c === '\n' || c === '\r')) {
      break;
    } else if (!dansGuillemets && comptes.has(c)) {
      comptes.set(c, (comptes.get(c) ?? 0) + 1);
    }
  }
  let meilleur: string = SEPARATEURS[0];
  for (const s of SEPARATEURS) {
    if ((comptes.get(s) ?? 0) > (comptes.get(meilleur) ?? 0)) meilleur = s;
  }
  return meilleur;
}

/** Lignes du CSV, chacune decoupee en champs. Les lignes vides sont ecartees. */
export function parseCsv(csv: string, separateur = devinerSeparateur(csv)): string[][] {
  // \uFEFF : marque d'ordre des octets, invisible, qu'Excel place en tete.
  const texte = csv.replace(/^\uFEFF/, '');
  const lignes: string[][] = [];
  let ligne: string[] = [];
  let champ = '';
  let dansGuillemets = false;

  const finDeChamp = (): void => {
    ligne.push(champ.trim());
    champ = '';
  };
  const finDeLigne = (): void => {
    finDeChamp();
    // Une ligne entierement vide n'est pas une donnee : un tableur en laisse
    // toujours une a la fin du fichier.
    if (ligne.some((c) => c.length > 0)) lignes.push(ligne);
    ligne = [];
  };

  for (let i = 0; i < texte.length; i += 1) {
    const c = texte[i];
    if (dansGuillemets) {
      if (c === '"') {
        // Deux guillemets de suite : un guillemet litteral, et on reste dedans.
        if (texte[i + 1] === '"') {
          champ += '"';
          i += 1;
        } else {
          dansGuillemets = false;
        }
      } else {
        champ += c;
      }
      continue;
    }
    if (c === '"' && champ.trim() === '') {
      dansGuillemets = true;
      champ = '';
    } else if (c === separateur) {
      finDeChamp();
    } else if (c === '\n') {
      finDeLigne();
    } else if (c !== '\r') {
      champ += c;
    }
  }
  if (champ.length > 0 || ligne.length > 0) finDeLigne();
  return lignes;
}

/** Nom de colonne compare sans casse, sans accent et sans ponctuation. */
export function normaliserEntete(nom: string): string {
  return nom
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '');
}

/** Une ligne de donnees, avec son numero dans le fichier (en-tete compris). */
export interface LigneCsv {
  numero: number;
  valeur(...noms: string[]): string;
  /** Vrai si la ligne ne porte aucune valeur exploitable. */
  vide: boolean;
}

/**
 * Table indexee par ses en-tetes.
 *
 * L'ordre des colonnes n'a alors plus d'importance, et un fichier peut en porter
 * que le produit ignore — deux proprietes qui comptent quand la source est le
 * tableur de quelqu'un d'autre.
 */
export function parseCsvTable(csv: string): { entetes: string[]; lignes: LigneCsv[] } {
  const brut = parseCsv(csv);
  if (brut.length === 0) return { entetes: [], lignes: [] };

  const entetes = brut[0].map(normaliserEntete);
  const lignes = brut.slice(1).map((cellules, index) => ({
    // +2 : l'en-tete occupe la ligne 1, et les humains comptent depuis 1.
    numero: index + 2,
    vide: cellules.every((c) => c.length === 0),
    valeur(...noms: string[]): string {
      for (const nom of noms) {
        const col = entetes.indexOf(normaliserEntete(nom));
        if (col !== -1 && (cellules[col] ?? '').length > 0) return cellules[col];
      }
      return '';
    },
  }));
  return { entetes, lignes };
}
+18 −0
Original line number Diff line number Diff line
@@ -214,3 +214,21 @@ export class KitPhaseDto {
  @Min(0)
  order?: number;
}

/**
 * Import d'un CSV dans un kit.
 *
 * Le contenu voyage en TEXTE et non en televersement : le fichier est lu par le
 * navigateur, ce qui permet de coller directement un extrait de tableur sans
 * passer par un enregistrement sur disque. La borne haute vaut pour un
 * chronogramme tres fourni, corps de messages compris.
 */
export class ImportKitCsvDto {
  @IsIn(['characters', 'injects', 'phases'])
  kind!: 'characters' | 'injects' | 'phases';

  @IsString()
  @MinLength(1)
  @MaxLength(2_000_000)
  csv!: string;
}
+99 −0
Original line number Diff line number Diff line
import { lireInjects, lireOffset, lirePersonnages, lirePhases } from './kit-import';

const PERSONNAGES = [
  { ref: 'c_pdg', name: 'Paul Rivière' },
  { ref: 'c_dsi', name: 'Thomas Blanc' },
];

describe('lireOffset', () => {
  it('accepte les trois ecritures des chronogrammes', () => {
    expect(lireOffset('90')).toBe(90);
    expect(lireOffset('1:30')).toBe(90);
    expect(lireOffset('T+90')).toBe(90);
    expect(lireOffset('1h30')).toBe(90);
  });

  it('refuse ce qui n’est pas un instant', () => {
    expect(lireOffset('bientôt')).toBeNull();
    expect(lireOffset('1:75')).toBeNull();
  });
});

describe('lirePersonnages', () => {
  it('lit les colonnes quel que soit leur ordre', () => {
    const r = lirePersonnages('fonction;nom;nature\nPDG;Paul Rivière;joueur');
    expect(r.valeurs).toEqual([
      { name: 'Paul Rivière', title: 'PDG', orgUnit: undefined, kind: 'PLAYER', simEmail: undefined, avatarColor: undefined },
    ]);
  });

  it('comprend « PNJ » comme « animé »', () => {
    const r = lirePersonnages('nom;nature\nJournaliste;PNJ');
    expect(r.valeurs[0].kind).toBe('NPC');
  });

  it('signale la colonne obligatoire absente plutot que d’importer n’importe quoi', () => {
    const r = lirePersonnages('fonction;service\nPDG;Direction');
    expect(r.entetesManquants).toEqual(['nom']);
    expect(r.valeurs).toHaveLength(0);
  });
});

describe('lirePhases', () => {
  it('rend les phases avec leur debut en minutes', () => {
    const r = lirePhases('nom;debut;description\nAlerte;0;Découverte\nMobilisation;1:00;Cellule réunie');
    expect(r.valeurs).toEqual([
      { name: 'Alerte', description: 'Découverte', startsAtMinutes: 0 },
      { name: 'Mobilisation', description: 'Cellule réunie', startsAtMinutes: 60 },
    ]);
  });
});

describe('lireInjects', () => {
  const entete = 'T+;canal;titre;objet;corps;expediteur;destinataires';

  it('resout l’expediteur par son NOM comme par sa reference', () => {
    const r = lireInjects(`${entete}\n30;courriel;Alerte;Incident;Un incident;Paul Rivière;c_dsi`, PERSONNAGES);
    expect(r.refus).toEqual([]);
    expect(r.valeurs[0]).toMatchObject({
      offsetMinutes: 30,
      channel: 'EMAIL',
      senderRef: 'c_pdg',
      targetRefs: ['c_dsi'],
    });
  });

  it('accepte les canaux ecrits en francais', () => {
    const r = lireInjects(`${entete}\n0;presse;Article;;Texte;;`, PERSONNAGES);
    expect(r.valeurs[0].channel).toBe('NEWS');
  });

  it('refuse la ligne dont l’expediteur est inconnu, et garde les autres', () => {
    const csv = `${entete}\n0;chat;Bonne ligne;;Texte;Thomas Blanc;\n10;chat;Mauvaise;;Texte;Inconnu;`;
    const r = lireInjects(csv, PERSONNAGES);
    // Une faute sur une ligne ne doit pas coûter les trente-neuf autres.
    expect(r.valeurs).toHaveLength(1);
    expect(r.refus).toEqual([{ ligne: 3, motif: 'expéditeur introuvable : « Inconnu »' }]);
  });

  it('signale un instant illisible avec le numero de ligne du tableur', () => {
    const r = lireInjects(`${entete}\ndemain;chat;Titre;;Texte;;`, PERSONNAGES);
    expect(r.refus[0].ligne).toBe(2);
    expect(r.refus[0].motif).toMatch(/instant illisible/);
  });

  it('sépare une liste de destinataires sur « | »', () => {
    const r = lireInjects(`${entete}\n0;email;Titre;Objet;Texte;;Paul Rivière|Thomas Blanc`, PERSONNAGES);
    expect(r.valeurs[0].targetRefs).toEqual(['c_pdg', 'c_dsi']);
  });
});

describe('lireInjects, noms contenant une virgule', () => {
  it('ne coupe pas un destinataire sur la virgule de son nom', () => {
    const gens = [{ ref: 'c_dsi', name: 'Blanc, Thomas' }];
    const csv = 'T+;canal;titre;destinataires\n0;email;Titre;"Blanc, Thomas"';
    const r = lireInjects(csv, gens);
    expect(r.refus).toEqual([]);
    expect(r.valeurs[0].targetRefs).toEqual(['c_dsi']);
  });
});
+245 −0
Original line number Diff line number Diff line
import { parseCsvTable, type LigneCsv } from '../common/csv';
import type { KitCharacterDto, KitInjectDto, KitPhaseDto } from './dto/kit.dto';

/**
 * Lecture d'un CSV de kit : chronogramme, personnages, phases.
 *
 * Logique PURE, sans base : elle prend le contenu du fichier et l'etat du kit,
 * elle rend ce qu'il faudrait creer et ce qui n'a pas pu l'etre. C'est ce qui
 * permet de la tester sur les cas qui arrivent vraiment — colonne manquante,
 * canal ecrit en francais, expediteur inconnu — sans monter un exercice.
 *
 * Principe de conduite : une ligne fautive ne fait pas echouer le fichier. Un
 * chronogramme de quarante injects rejete en bloc pour une faute de frappe
 * obligerait a tout recommencer ; les lignes valides passent, les autres sont
 * rendues avec leur numero et leur motif.
 */

/** Ce qu'une ligne n'a pas permis de faire, et pourquoi. */
export interface RefusImport {
  ligne: number;
  motif: string;
}

export interface ResultatImport<T> {
  valeurs: T[];
  refus: RefusImport[];
}

/** Personnage deja present dans le kit, pour resoudre les references. */
export interface PersonnageConnu {
  ref: string;
  name: string;
}

/** Canal d'inject : les intitules francais sont acceptes, ce sont eux qu'on tape. */
const CANAUX: Record<string, KitInjectDto['channel']> = {
  email: 'EMAIL',
  courriel: 'EMAIL',
  mail: 'EMAIL',
  messagerie: 'EMAIL',
  chat: 'CHAT',
  tchat: 'CHAT',
  social: 'SOCIAL',
  reseausocial: 'SOCIAL',
  reseauxsociaux: 'SOCIAL',
  news: 'NEWS',
  presse: 'NEWS',
  article: 'NEWS',
  call: 'CALL',
  appel: 'CALL',
  telephone: 'CALL',
};

const RESEAUX: Record<string, NonNullable<KitInjectDto['network']>> = {
  x: 'X',
  twitter: 'X',
  linkedin: 'LINKEDIN',
  instagram: 'INSTAGRAM',
};

const NATURES: Record<string, KitCharacterDto['kind']> = {
  player: 'PLAYER',
  joueur: 'PLAYER',
  npc: 'NPC',
  pnj: 'NPC',
  anime: 'NPC',
};

function cle(valeur: string): string {
  return valeur
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '')
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '');
}

/**
 * Instant d'un inject, en minutes depuis le debut.
 *
 * Trois ecritures acceptees, parce que les trois se rencontrent dans les
 * chronogrammes existants : « 90 », « 1:30 », « T+90 ». Rendre `null` plutot que
 * zero : un instant illisible n'est pas un inject a l'ouverture.
 */
export function lireOffset(valeur: string): number | null {
  const texte = valeur.trim().replace(/^t\s*\+?\s*/i, '');
  const enHeures = /^(\d+)\s*[:h]\s*(\d{1,2})$/i.exec(texte);
  if (enHeures) {
    const minutes = Number(enHeures[2]);
    if (minutes > 59) return null;
    return Number(enHeures[1]) * 60 + minutes;
  }
  if (/^\d+$/.test(texte)) return Number(texte);
  return null;
}

/**
 * Liste de references, separees par « | » — et par rien d'autre.
 *
 * Ni la virgule ni le point-virgule : ce sont les separateurs du CSV lui-meme, et
 * un nom entre guillemets a parfaitement le droit d'en contenir. « Blanc, Thomas »
 * est un destinataire, pas deux.
 */
function lireListe(valeur: string): string[] {
  return valeur
    .split('|')
    .map((v) => v.trim())
    .filter((v) => v.length > 0);
}

function traiter<T>(
  csv: string,
  colonnesRequises: string[][],
  ligneVersValeur: (l: LigneCsv) => T | string,
): ResultatImport<T> & { entetesManquants: string[] } {
  const { entetes, lignes } = parseCsvTable(csv);
  if (entetes.length === 0) {
    return { valeurs: [], refus: [], entetesManquants: colonnesRequises.map((c) => c[0]) };
  }
  const manquants = colonnesRequises
    .filter((noms) => !noms.some((n) => entetes.includes(cle(n))))
    .map((noms) => noms[0]);
  if (manquants.length > 0) {
    return { valeurs: [], refus: [], entetesManquants: manquants };
  }

  const valeurs: T[] = [];
  const refus: RefusImport[] = [];
  for (const ligne of lignes) {
    if (ligne.vide) continue;
    const issue = ligneVersValeur(ligne);
    if (typeof issue === 'string') refus.push({ ligne: ligne.numero, motif: issue });
    else valeurs.push(issue);
  }
  return { valeurs, refus, entetesManquants: [] };
}

/** Colonnes : nom, fonction, service, nature (joueur/PNJ), email simulé. */
export function lirePersonnages(csv: string) {
  return traiter<KitCharacterDto>(csv, [['nom', 'name']], (l) => {
    const name = l.valeur('nom', 'name');
    if (!name) return 'nom absent';
    const nature = l.valeur('nature', 'type', 'kind');
    return {
      name,
      title: l.valeur('fonction', 'titre', 'title') || undefined,
      orgUnit: l.valeur('service', 'entite', 'orgunit', 'direction') || undefined,
      // Par defaut JOUEUR : c'est le cas courant, et un PNJ pris pour un joueur
      // se voit tout de suite dans la liste, alors que l'inverse se decouvre en
      // pleine animation.
      kind: NATURES[cle(nature)] ?? 'PLAYER',
      simEmail: l.valeur('email', 'emailsimule', 'simemail') || undefined,
      avatarColor: l.valeur('couleur', 'avatarcolor') || undefined,
    };
  });
}

/** Colonnes : nom, description, début (minutes). */
export function lirePhases(csv: string) {
  return traiter<KitPhaseDto>(csv, [['nom', 'name']], (l) => {
    const name = l.valeur('nom', 'name');
    if (!name) return 'nom absent';
    const debut = l.valeur('debut', 'start', 'offset', 'startsatminutes', 't');
    const startsAtMinutes = debut ? lireOffset(debut) : 0;
    if (startsAtMinutes === null) return `début illisible : « ${debut} »`;
    return {
      name,
      description: l.valeur('description') || undefined,
      startsAtMinutes,
    };
  });
}

/**
 * Colonnes : T+, canal, titre, corps, objet, expéditeur, destinataires…
 *
 * L'expediteur et les destinataires se donnent par NOM de personnage autant que
 * par reference : personne ne connait par cœur les refs internes du kit, et un
 * chronogramme redige dans un tableur nomme les gens.
 */
export function lireInjects(csv: string, personnages: PersonnageConnu[]) {
  const parRef = new Map(personnages.map((p) => [cle(p.ref), p.ref]));
  const parNom = new Map(personnages.map((p) => [cle(p.name), p.ref]));
  const resoudre = (valeur: string): string | null =>
    parRef.get(cle(valeur)) ?? parNom.get(cle(valeur)) ?? null;

  return traiter<KitInjectDto>(
    csv,
    [
      ['T+', 'offset', 'minute'],
      ['canal', 'channel'],
      ['titre', 'title'],
    ],
    (l) => {
      const offsetMinutes = lireOffset(l.valeur('T+', 'offset', 'minute', 'offsetminutes', 'heure'));
      if (offsetMinutes === null) return 'instant illisible (attendu : 90, 1:30 ou T+90)';

      const canal = l.valeur('canal', 'channel');
      const channel = CANAUX[cle(canal)];
      if (!channel) return `canal inconnu : « ${canal} »`;

      const title = l.valeur('titre', 'title');
      if (!title) return 'titre absent';

      const expediteur = l.valeur('expediteur', 'sender', 'de', 'senderref');
      let senderRef: string | undefined;
      if (expediteur) {
        const trouve = resoudre(expediteur);
        // Refus plutot qu'inject anonyme : un expediteur mal orthographie donne
        // un message sans emetteur, qu'on ne remarque qu'une fois l'exercice
        // lance.
        if (!trouve) return `expéditeur introuvable : « ${expediteur} »`;
        senderRef = trouve;
      }

      const destinataires = lireListe(l.valeur('destinataires', 'pour', 'targets', 'targetrefs'));
      const targetRefs: string[] = [];
      for (const d of destinataires) {
        const trouve = resoudre(d);
        if (!trouve) return `destinataire introuvable : « ${d} »`;
        targetRefs.push(trouve);
      }

      const reseau = l.valeur('reseau', 'network');
      const likes = l.valeur('likes', 'jaime');
      return {
        offsetMinutes,
        channel,
        title,
        body: l.valeur('corps', 'body', 'message', 'contenu'),
        subject: l.valeur('objet', 'subject') || undefined,
        senderRef,
        targetRefs: targetRefs.length > 0 ? targetRefs : undefined,
        attachmentRefs: lireListe(l.valeur('piecesjointes', 'attachments', 'attachmentrefs')),
        channelRef: l.valeur('canalchat', 'channelref') || undefined,
        network: reseau ? RESEAUX[cle(reseau)] : undefined,
        authorName: l.valeur('auteur', 'authorname') || undefined,
        authorHandle: l.valeur('pseudo', 'authorhandle') || undefined,
        likes: /^\d+$/.test(likes) ? Number(likes) : undefined,
        to: l.valeur('appelea', 'to') || undefined,
        newsSource: l.valeur('source', 'newssource') || undefined,
        newsCategory: l.valeur('rubrique', 'newscategory') || undefined,
      };
    },
  );
}
Loading