gestion_table_tournante_libre/src/csv/objectifs.js

572 lines
28 KiB
JavaScript
Raw Normal View History

2026-10-11 16:19:38 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le CSV des objectifs de table (§ 10.4) : un second fichier, que
// l'opérateur choisit d'importer comme tel, jamais une section du fichier
// des participants. Il garde son séparateur, son en-tête et son CSV des
// refus ; tout le § 10.1 vaut, sauf ce qui nomme les participants. Ce module
// en porte l'aperçu, l'import appliqué selon le mode, ce que remplacer
// touche et l'export, qui est le gabarit de l'import et sa sauvegarde.
//
// La lecture reprend, dans l'ordre, celle de l'aperçu des participants :
// 1. un caractère nul refuse le texte ;
// 2. choisirSeparateur choisit le séparateur sur la table d'en-têtes des
// objectifs ; un texte vide ou blanc est refusé AUCUN_ENTETE, tout autre
// texte sans séparateur SEPARATEUR_INTROUVABLE, sauf quand le seul
// défaut est un guillemet resté ouvert, que le point 3 refuse ;
// 3. un guillemet resté ouvert refuse le texte, au rang où il s'ouvre ;
// 4. aucun en-tête reconnu, puis aucune colonne associée à table
// (TABLE_NON_ASSOCIEE), puis à objectif (OBJECTIF_NON_ASSOCIE), puis un
// événement sans table (OBJECTIFS_SANS_TABLE) refusent ;
// 5. chaque enregistrement non vide qui suit l'en-tête est une ligne,
// valide ou refusée pour un seul motif (lireLigne) ; zéro ligne valide
// refuse (AUCUNE_LIGNE_VALIDE) ;
// 6. les étiquettes des lignes valides se réconcilient avec celles de
// l'événement : celles des personnes, exclues comprises, et celles des
// objectifs de table, sauf sous remplacer, qui retire ces objectifs.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (exporterRefus de import.js, tel quel), dont
// les colonnes ligne et motif restent inconnues de la table des en-têtes.
//
// La table d'une ligne se désigne par son numéro affiché, jamais par son
// identifiant : l'opérateur lit le numéro sur le plan. Une valeur inconnue
// de la règle refuse la ligne : la lire comme le défaut ferait servir un
// objectif sous une règle que personne n'a choisie.
import { ETIQUETTES_MAX, lireEtiquettes } from '../stockage/etiquettes.js';
import { associerSelon, reconnaitreSelon } from './colonnes.js';
import { contientNul } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { cleEtiquette, reconcilier } from './etiquettes.js';
import { JOINTURE_ETIQUETTES, SEPARATEUR, ecrireCsv } from './export.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
// Un champ et ses en-têtes, figés.
const champ = (nom, entetes) => Object.freeze([nom, Object.freeze(entetes)]);
/**
* La table des colonnes du CSV des objectifs (§ 10.4), seule source de ses
* champs et de leurs synonymes : chaque champ, dans l'ordre de l'export, avec
* ses en-têtes tels que le § 10.4 les écrit — son nom, puis ses synonymes.
* « règle » a la clé de « regle » : il ne fait que redire que l'accent
* n'importe pas. Le tableau des colonnes de la documentation s'en engendre.
* Figée à toute profondeur.
*
* @type {ReadonlyArray<readonly [string, ReadonlyArray<string>]>}
*/
export const ENTETES_DES_OBJECTIFS = Object.freeze([
champ('table', ['table']),
champ('tour', ['tour']),
champ('objectif', ['objectif', 'objectifs']),
champ('regle', ['regle', 'règle']),
]);
/** Champs du CSV des objectifs, dans l'ordre de l'export : son en-tête. */
export const CHAMPS_OBJECTIFS = Object.freeze(ENTETES_DES_OBJECTIFS.map(([nom]) => nom));
/**
* Champ des objectifs qu'un en-tête désigne, par la règle de reconnaitre
* (colonnes.js) ; null pour un en-tête inconnu, ceux des participants, ligne
* et motif compris.
*
* @type {(entete: string) => string|null}
*/
export const reconnaitreObjectif = reconnaitreSelon(ENTETES_DES_OBJECTIFS);
/**
* L'association des colonnes du CSV des objectifs, par la règle d'associer
* (colonnes.js), les champs dans l'ordre de CHAMPS_OBJECTIFS.
*
* @type {ReturnType<typeof associerSelon>}
*/
export const associerObjectifs = associerSelon(ENTETES_DES_OBJECTIFS);
/**
* Modes de l'import des objectifs, que l'aperçu et l'import reçoivent tous
* deux (§ 10.4). Ajouter n'en est pas un : un couple (table, tour) déjà
* pourvu serait une collision, que mettre à jour tranche.
*
* @type {readonly string[]}
*/
export const MODES_IMPORT_OBJECTIFS = Object.freeze(['mettreAJour', 'remplacer']);
/**
* Valeurs admises de la règle, par clé normalisée (cleNormalisee), comme
* celles d'« exclu » : une liste fermée. 'ou' et 'et' pour une règle propre ;
* la clé vide, null, pour un objectif qui suit la règle par défaut de
* l'événement. Une valeur absente de la liste refuse la ligne.
*
* @type {Map<string, 'ou'|'et'|null>}
*/
export const VALEURS_REGLE = new Map([
['ou', 'ou'],
['une', 'ou'],
['au moins une', 'ou'],
['et', 'et'],
['toutes', 'et'],
['', null],
]);
// Un entier écrit en chiffres décimaux, un signe admis : « 2.0 », « deux »
// et « 1,5 » n'en sont pas.
const ENTIER = /^[+-]?[0-9]+$/;
// L'ordre des objectifs d'une table (§ 8.9) : la soirée, de tour null,
// avant les tours, puis les tours croissants.
const ordreDesObjectifs = (a, b) => (a.tour ?? 0) - (b.tour ?? 0);
// Deux chaînes, comparées unité UTF-16 par unité : l'ordre où le fichier
// d'état range une liste d'étiquettes (§ 8.9).
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const rangees = (etiquettes) => [...etiquettes].sort(comparerTextes);
/**
* @typedef {Object} ChampsObjectif
* @property {number} numero le numéro affiché de la table visée
* @property {number} table son identifiant
* @property {number|null} tour null pour la soirée, sinon de 1 à R
* @property {'ou'|'et'|null} regle null quand l'objectif suit la règle par
* défaut de l'événement
* @property {string[]} etiquettes découpées par lireEtiquettes, dans
* l'ordre de la cellule, chacune sous l'orthographe que la
* réconciliation lui donne, sans deux de même clé, de 1 à
* ETIQUETTES_MAX
*
* @typedef {Object} LigneObjectifRefusee
* @property {number} ligne rang de l'enregistrement, en-tête compris
* @property {string} code CHAMPS_EN_TROP, TABLE_ABSENTE, TABLE_NON_ENTIERE,
* NUMERO_TABLE_INCONNU, PLUSIEURS_TABLES, TOUR_NON_ENTIER,
* TOUR_HORS_SOIREE, OBJECTIF_VIDE, ETIQUETTE_MAL_FORMEE,
* ETIQUETTES_TROP_NOMBREUSES, REGLE_INCONNUE ou OBJECTIF_EN_DOUBLE
* @property {string|null} valeur la cellule fautive, en NFC sans ses blancs
* de bord, sous les codes de la table, du tour et de la règle ;
* l'étiquette fautive, mise en forme, sous ETIQUETTE_MAL_FORMEE ;
* null sous les autres
* @property {number} [tours] TOUR_HORS_SOIREE : le nombre de tours
* @property {string} [raison] ETIQUETTE_MAL_FORMEE : 'controle' ou
* 'longueur'
* @property {number} [nombre] ETIQUETTES_TROP_NOMBREUSES : les
* étiquettes de clés distinctes de la cellule
* @property {number} [premiereLigne] OBJECTIF_EN_DOUBLE : la ligne valide
* qui donne la première l'objectif de ce couple
* @property {number} [numero] OBJECTIF_EN_DOUBLE : le numéro de la table
* @property {number|null} [tour] OBJECTIF_EN_DOUBLE : le tour, null pour la
* soirée
* @property {string[]} brut les champs tels que découpés
*
* @typedef {Object} ApercuObjectifs
* @property {'mettreAJour'|'remplacer'} mode le mode pour lequel l'aperçu
* est calculé : appliquerImportObjectifs ne l'applique que dans ce
* mode
* @property {string|null} separateur null quand aucun séparateur n'est
* établi, ou pour un fichier à une colonne
* @property {string[]} entetes tels qu'écrits
* @property {{colonnes: Object<string, number>, ambigus: Array<{champ: string, rangs: number[]}>,
* nonReconnues: number[]}} association ce que rend associerObjectifs
* @property {Array<{ligne: number, champs: ChampsObjectif, brut: string[]}>} lignes
* les lignes valides, dans l'ordre du texte
* @property {LigneObjectifRefusee[]} refusees rangées par ligne
* @property {import('./etiquettes.js').FusionEtiquettes[]} fusionsEtiquettes
* les fusions d'orthographes que rend reconcilier sur les lignes
* valides
* @property {Array<{ligne: number, rangs: number[]}>} multilignes chaque ligne
* valide dont une cellule s'étend sur plusieurs lignes du texte et
* porte le séparateur — la forme d'un guillemet égaré qui fond deux
* lignes en une —, avec les rangs croissants de ces cellules
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'TABLE_NON_ASSOCIEE'|'OBJECTIF_NON_ASSOCIE'|'OBJECTIFS_SANS_TABLE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusionsEtiquettes et multilignes
* sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang où
* le guillemet s'ouvre ; sous SEPARATEUR_INTROUVABLE, celui de
* l'enregistrement qui écarte le séparateur, quand il est nommé ;
* null sinon
*/
// Aperçu d'un texte refusé en entier : son mode, ce que la lecture a établi
// avant le refus, aucune ligne.
function refuserEn(
mode,
code,
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
ligne = null,
) {
return {
mode,
separateur,
entetes,
association,
lignes: [],
refusees: [],
fusionsEtiquettes: [],
multilignes: [],
refusGlobal: code,
refusGlobalLigne: ligne,
};
}
// Valeur de la cellule de rang donné : NFC, blancs de bord retirés. Null quand
// le champ n'a pas de colonne, que l'enregistrement s'arrête avant elle, ou
// que la cellule est vide ou blanche.
function valeurDe(brut, rang) {
if (rang === undefined || rang >= brut.length) return null;
const valeur = brut[rang].normalize('NFC').trim();
return valeur === '' ? null : valeur;
}
// La table qu'un numéro écrit désigne, ou le motif du refus : TABLE_ABSENTE
// pour une cellule vide, TABLE_NON_ENTIERE pour ce qui n'est pas un entier,
// NUMERO_TABLE_INCONNU quand aucune table ne porte ce numéro, PLUSIEURS_TABLES
// quand deux le portent : la ligne ne dit pas laquelle elle vise.
function tableDe(valeur, tables) {
if (valeur === null) return { refus: { code: 'TABLE_ABSENTE', valeur: null } };
if (!ENTIER.test(valeur)) return { refus: { code: 'TABLE_NON_ENTIERE', valeur } };
const numero = Number(valeur);
const portees = tables.filter((candidate) => candidate.numero === numero);
if (portees.length === 0) return { refus: { code: 'NUMERO_TABLE_INCONNU', valeur } };
if (portees.length > 1) return { refus: { code: 'PLUSIEURS_TABLES', valeur } };
return { table: portees[0] };
}
// Le tour qu'une cellule écrit : null pour une cellule vide, la soirée ; un
// entier de 1 à tours ; ou le motif du refus, TOUR_NON_ENTIER, ou
// TOUR_HORS_SOIREE qui nomme le nombre de tours.
function tourDe(valeur, tours) {
if (valeur === null) return { tour: null };
if (!ENTIER.test(valeur)) return { refus: { code: 'TOUR_NON_ENTIER', valeur } };
const tour = Number(valeur);
if (tour < 1 || tour > tours) return { refus: { code: 'TOUR_HORS_SOIREE', valeur, tours } };
return { tour };
}
// Les étiquettes de la cellule objectif (§ 4.5, § 10.1), par la découpe de
// la saisie : la cellule arrive telle que découpée, sans le trim de valeurDe,
// qui retirerait au bord une tabulation que lireEtiquettes refuse.
// OBJECTIF_VIDE pour une cellule absente, vide ou faite de blancs et de
// séparateurs ; puis ETIQUETTE_MAL_FORMEE, la première fautive avec sa
// raison ; puis ETIQUETTES_TROP_NOMBREUSES au-delà d'ETIQUETTES_MAX clés
// distinctes : deux morceaux de même clé ne font qu'une étiquette.
function etiquettesDe(cellule) {
const { etiquettes, fautes } = lireEtiquettes(cellule ?? '');
if (etiquettes.length === 0 && fautes.length === 0) return { refus: { code: 'OBJECTIF_VIDE', valeur: null } };
if (fautes.length > 0) {
const [{ valeur, raison }] = fautes;
return { refus: { code: 'ETIQUETTE_MAL_FORMEE', valeur, raison } };
}
const nombre = new Set(etiquettes.map(cleEtiquette)).size;
if (nombre > ETIQUETTES_MAX) return { refus: { code: 'ETIQUETTES_TROP_NOMBREUSES', valeur: null, nombre } };
return { etiquettes };
}
// Lit un enregistrement de données selon les colonnes associées, largeur
// étant le nombre d'en-têtes. Rend { champs } ou le motif du refus, un seul,
// dans l'ordre du § 10.4 : un champ non vide au-delà de l'en-tête ; le
// numéro de table ; le tour ; l'objectif ; la règle hors de sa liste fermée.
// Le doublon d'un couple (table, tour) se juge après, contre les lignes
// valides qui précèdent.
function lireLigne(brut, largeur, colonnes, { tables, tours }) {
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
const lire = (nom) => valeurDe(brut, colonnes[nom]);
const visee = tableDe(lire('table'), tables);
if (visee.refus !== undefined) return visee.refus;
const lu = tourDe(lire('tour'), tours);
if (lu.refus !== undefined) return lu.refus;
const objectif = etiquettesDe(colonnes.objectif < brut.length ? brut[colonnes.objectif] : undefined);
if (objectif.refus !== undefined) return objectif.refus;
const valeurRegle = lire('regle');
const regle = VALEURS_REGLE.get(cleNormalisee(valeurRegle ?? ''));
if (regle === undefined) return { code: 'REGLE_INCONNUE', valeur: valeurRegle };
return {
champs: { numero: visee.table.numero, table: visee.table.id, tour: lu.tour, regle, etiquettes: objectif.etiquettes },
};
}
// Cellules d'une ligne sur plusieurs lignes du texte qui portent le
// séparateur, ou toute fin de ligne sans séparateur : les lignes valides qui
// en ont, avec leurs rangs.
function multilignesDe(lignes, separateur) {
const multilignes = [];
for (const { ligne, brut } of lignes) {
const rangs = [];
brut.forEach((cellule, rang) => {
const surPlusieursLignes = cellule.includes('\n') || cellule.includes('\r');
if (surPlusieursLignes && (separateur === null || cellule.includes(separateur))) rangs.push(rang);
});
if (rangs.length > 0) multilignes.push({ ligne, rangs });
}
return multilignes;
}
// Les étiquettes de l'événement qui donnent leur orthographe à celles du
// fichier : celles des personnes, exclues comprises, puis celles des
// objectifs de table, sauf sous remplacer, qui les retire. reconcilier ne
// dépend pas de leur ordre.
function etiquettesExistantes({ participants, tables }, mode) {
return [
...participants.flatMap(({ etiquettes = [] }) => etiquettes),
...(mode === 'remplacer' ? [] : tables.flatMap(({ objectifs = [] }) => objectifs.flatMap(({ etiquettes }) => etiquettes))),
];
}
// La clé d'un couple (table, tour), la soirée sous tour null.
const cleDuCouple = (table, tour) => JSON.stringify([table, tour]);
/**
* Aperçu d'un texte décodé du CSV des objectifs (§ 10.4), contre la charge,
* pour un mode de l'import : rien n'est importé. Les lignes se lisent dans
* l'ordre du texte ; une ligne refusée ne refuse que soi. Le doublon d'un
* couple (table, tour) se juge contre les lignes valides qui précèdent : une
* ligne refusée n'est la première d'aucun couple.
*
* @param {string} texte le texte que rend decoder
* @param {{evenement: {tours: number}, tables: Array<{id: number, numero: number,
* objectifs?: Array<{etiquettes: string[]}>}>, participants: Array<{etiquettes?: string[]}>}} charge
* la charge de l'événement : ses tables, leurs numéros et leurs objectifs,
* les étiquettes de ses personnes, son nombre de tours
* @param {Object} [options]
* @param {Object<string, number>} [options.choix] { champ: rang } qui
* tranche une ambiguïté de l'en-tête (voir associer)
* @param {'mettreAJour'|'remplacer'} [options.mode] mettreAJour par défaut
* @returns {ApercuObjectifs}
* @throws {TypeError} quand texte n'est pas une chaîne, pour un mode inconnu,
* et pour un nombre de tours qui n'est pas un entier positif
*/
export function apercevoirObjectifs(texte, charge, { choix = {}, mode = 'mettreAJour' } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (!MODES_IMPORT_OBJECTIFS.includes(mode)) {
throw new TypeError(`mode : ${MODES_IMPORT_OBJECTIFS.join(', ')} attendu, reçu ${String(mode)}`);
}
const { tours } = charge.evenement;
if (!Number.isSafeInteger(tours) || tours < 1) throw new TypeError(`tours : entier positif attendu, reçu ${String(tours)}`);
const refuser = (...refus) => refuserEn(mode, ...refus);
if (contientNul(texte)) return refuser('CARACTERE_NUL');
let separateur;
try {
({ separateur } = choisirSeparateur(texte, reconnaitreObjectif));
} catch (erreur) {
// Sur une chaîne, choisirSeparateur ne lève que SEPARATEUR_INTROUVABLE ;
// toute autre erreur est un défaut, qu'un refus de l'aperçu masquerait.
if (!(erreur instanceof ErreurCsv) || erreur.code !== 'SEPARATEUR_INTROUVABLE') throw erreur;
if (texte.trim() === '') return refuser('AUCUN_ENTETE');
const { cause = null, ligne = null } = erreur.details;
if (cause !== 'GUILLEMET_OUVERT') return refuser('SEPARATEUR_INTROUVABLE', {}, ligne);
({ separateur } = erreur.details);
}
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
const indiceEntete = enregistrements.findIndex((enregistrement) => !enregistrementVide(enregistrement));
const entetes = enregistrements[indiceEntete];
const association = associerObjectifs(entetes, choix);
const lu = { separateur, entetes, association };
if (guillemetOuvert) return refuser('GUILLEMET_OUVERT', lu, enregistrements.length);
if (association.nonReconnues.length === entetes.length) return refuser('AUCUN_ENTETE', lu);
if (association.colonnes.table === undefined) return refuser('TABLE_NON_ASSOCIEE', lu);
if (association.colonnes.objectif === undefined) return refuser('OBJECTIF_NON_ASSOCIE', lu);
if (charge.tables.length === 0) return refuser('OBJECTIFS_SANS_TABLE', lu);
const valides = [];
const refusees = [];
const premieres = new Map();
for (let indice = indiceEntete + 1; indice < enregistrements.length; indice += 1) {
const brut = enregistrements[indice];
if (enregistrementVide(brut)) continue;
const ligne = indice + 1;
const { champs, ...refus } = lireLigne(brut, entetes.length, association.colonnes, { tables: charge.tables, tours });
if (champs === undefined) {
refusees.push({ ligne, ...refus, brut });
continue;
}
const cle = cleDuCouple(champs.table, champs.tour);
if (premieres.has(cle)) {
refusees.push({
ligne, code: 'OBJECTIF_EN_DOUBLE', valeur: null, premiereLigne: premieres.get(cle), numero: champs.numero,
tour: champs.tour, brut,
});
continue;
}
premieres.set(cle, ligne);
valides.push({ ligne, champs, brut });
}
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const { listes, fusions: fusionsEtiquettes } = reconcilier(
etiquettesExistantes(charge, mode),
valides.map(({ champs }) => champs.etiquettes),
);
const lignes = valides.map(({ ligne, champs, brut }, rang) => ({ ligne, champs: { ...champs, etiquettes: listes[rang] }, brut }));
return {
mode,
...lu,
lignes,
refusees,
fusionsEtiquettes,
multilignes: multilignesDe(lignes, separateur),
refusGlobal: null,
refusGlobalLigne: null,
};
}
/**
* Ce que remplacer toucherait, à annoncer avant d'agir (§ 10.4) : les
* objectifs de l'événement, que remplacer retire tous, et les tables qui en
* portent. La charge n'est pas modifiée.
*
* @param {{tables: Array<{objectifs?: Array<Object>}>}} charge
* @returns {{objectifs: number, tables: number}}
*/
export function bilanRemplacementObjectifs({ tables }) {
const portantes = tables.filter(({ objectifs = [] }) => objectifs.length > 0);
return { objectifs: portantes.reduce((somme, { objectifs }) => somme + objectifs.length, 0), tables: portantes.length };
}
// Deux objectifs de même règle et de mêmes étiquettes, rangées.
const memesObjectifs = (a, b) => a.regle === b.regle && JSON.stringify(a.etiquettes) === JSON.stringify(b.etiquettes);
/**
* @typedef {Object} ResumeImportObjectifs
* @property {number} ajoutes objectifs d'un couple qui n'en portait pas
* @property {number} remplaces objectifs qui remplacent un objectif
* différent du même couple
* @property {number} inchanges lignes qui redonnent l'objectif de leur couple
* @property {number} retires sous remplacer, les objectifs d'un couple
* que le fichier ne nomme pas ; 0 sous mettreAJour
* @property {number} tables les tables dont les objectifs changent
* @property {LigneObjectifRefusee[]} refusees ceux de l'aperçu, copiés
* @property {boolean} derive vrai en état retenu : l'import y avertit
* que le classement peut changer, sans mettre aucune proposition
* en dérive
*/
/**
* Applique un aperçu des objectifs à une charge (§ 10.4), selon le mode :
* - 'mettreAJour' : chaque couple (table, tour) du fichier remplace
* l'objectif de ce couple ; les autres restent ;
* - 'remplacer' : chaque objectif de l'événement part, puis ceux du fichier
* s'écrivent.
* Un objectif écrit porte la portée « tous » pour la soirée, « tour » pour un
* tour, sa règle — null quand il suit le défaut — et ses étiquettes rangées
* par unités de code ; les objectifs d'une table se rangent la soirée
* d'abord, puis par tour croissant, et une table qui n'en garde aucun perd
* son champ objectifs (§ 8.9). Personnes, réservations, titres,
* propositions et retenu restent. La charge et l'aperçu reçus ne sont pas
* modifiés ; la charge rendue n'en partage aucun objet.
*
* @param {import('../stockage/types.js').Charge} charge
* @param {ApercuObjectifs} apercu calculé contre cette charge, pour ce mode
* @param {'mettreAJour'|'remplacer'} mode
* @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImportObjectifs}}
* @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état
* bloqué ; le refus global de l'aperçu, son code, détails { ligne } ;
* AMBIGUITE, détails { champs }, tant que le choix ne tranche pas
* @throws {TypeError} pour un mode inconnu, un aperçu calculé pour un autre
* mode, et une ligne dont la table manque à la charge : l'aperçu montré à
* l'opérateur est celui que l'import applique
*/
export function appliquerImportObjectifs(charge, apercu, mode) {
if (!MODES_IMPORT_OBJECTIFS.includes(mode)) {
throw new TypeError(`mode : ${MODES_IMPORT_OBJECTIFS.join(', ')} attendu, reçu ${String(mode)}`);
}
if (apercu.mode !== mode) throw new TypeError(`aperçu calculé pour ${String(apercu.mode)}, import en ${mode}`);
if (charge.evenement.etat === 'bloque') throw new ErreurCsv('PLAN_BLOQUE');
if (apercu.refusGlobal !== null) throw new ErreurCsv(apercu.refusGlobal, { ligne: apercu.refusGlobalLigne });
const { ambigus } = apercu.association;
if (ambigus.length > 0) throw new ErreurCsv('AMBIGUITE', { champs: ambigus.map(({ champ: nom }) => nom) });
const resultat = structuredClone(charge);
const parId = new Map(resultat.tables.map((table) => [table.id, table]));
const avant = new Map(resultat.tables.map((table) => [table.id, JSON.stringify(table.objectifs ?? [])]));
const ecrits = new Set();
let ajoutes = 0;
let remplaces = 0;
let inchanges = 0;
for (const { champs } of apercu.lignes) {
const table = parId.get(champs.table);
if (table === undefined) throw new TypeError(`aperçu : la table ${champs.table} manque à la charge`);
const objectif = {
portee: champs.tour === null ? 'tous' : 'tour',
tour: champs.tour,
regle: champs.regle,
etiquettes: rangees(champs.etiquettes),
};
const objectifs = table.objectifs ?? [];
const rang = objectifs.findIndex(({ tour }) => tour === champs.tour);
if (rang === -1) ajoutes += 1;
else if (memesObjectifs(objectifs[rang], objectif)) inchanges += 1;
else remplaces += 1;
table.objectifs = [...objectifs.filter(({ tour }) => tour !== champs.tour), objectif].sort(ordreDesObjectifs);
ecrits.add(cleDuCouple(champs.table, champs.tour));
}
let retires = 0;
if (mode === 'remplacer') {
for (const table of resultat.tables) {
const gardes = (table.objectifs ?? []).filter(({ tour }) => ecrits.has(cleDuCouple(table.id, tour)));
retires += (table.objectifs ?? []).length - gardes.length;
table.objectifs = gardes;
}
}
for (const table of resultat.tables) if (table.objectifs?.length === 0) delete table.objectifs;
const tables = resultat.tables.filter((table) => JSON.stringify(table.objectifs ?? []) !== avant.get(table.id)).length;
return {
charge: resultat,
resume: {
ajoutes,
remplaces,
inchanges,
retires,
tables,
refusees: structuredClone(apercu.refusees),
derive: charge.evenement.etat === 'retenu',
},
};
}
// La cellule regle d'un objectif : 'ou', 'et', ou vide quand il suit le
// défaut ; toute autre valeur lève TypeError, qui nomme la table.
function celluleRegle(regle, numero) {
if (regle === null) return '';
if (regle === 'ou' || regle === 'et') return regle;
throw new TypeError(`table ${numero}, regle : 'ou', 'et' ou null attendu, reçu ${JSON.stringify(regle) ?? String(regle)}`);
}
// La cellule objectif : les étiquettes jointes par JOINTURE_ETIQUETTES, dans
// l'ordre de leur liste ; une liste qui n'est pas de textes lève TypeError.
function celluleObjectif(etiquettes, numero) {
if (!Array.isArray(etiquettes) || !etiquettes.every((motCle) => typeof motCle === 'string')) {
throw new TypeError(`table ${numero}, etiquettes : liste de textes attendue, reçu ${JSON.stringify(etiquettes) ?? String(etiquettes)}`);
}
return etiquettes.join(JOINTURE_ETIQUETTES);
}
/**
* Les objectifs des tables en CSV (§ 10.4), dans la forme exacte que
* l'import lit : UTF-8 avec marque, « ; », CRLF ; l'en-tête
* table;tour;objectif;regle ; puis une ligne par objectif, les tables par
* numéro affiché puis identifiant croissant, et pour chacune la soirée puis
* les tours croissants. Le tour s'écrit vide pour la soirée ; les étiquettes
* jointes par « , », dans l'ordre de leur liste ; la règle ou, et, ou vide
* quand l'objectif suit le défaut. Une valeur s'écrit telle quelle, même
* quand un tableur la lirait comme une formule (export.js). Sans objectif,
* l'en-tête seul. Les tables reçues ne sont pas modifiées.
*
* @param {Array<{id: number, numero: number, objectifs?: Array<{tour: number|null, regle: 'ou'|'et'|null, etiquettes: string[]}>}>} tables
* @returns {Uint8Array}
* @throws {TypeError} pour une règle hors de ou, et, null, ou des étiquettes
* qui ne sont pas une liste de textes, la table nommée par son numéro
*/
export function exporterObjectifs(tables) {
const lignes = [...tables]
.sort((a, b) => a.numero - b.numero || a.id - b.id)
.flatMap(({ numero, objectifs = [] }) =>
[...objectifs]
.sort(ordreDesObjectifs)
.map(({ tour, regle, etiquettes }) => [
String(numero),
tour === null ? '' : String(tour),
celluleObjectif(etiquettes, numero),
celluleRegle(regle, numero),
]),
);
return ecrireCsv([[...CHAMPS_OBJECTIFS], ...lignes], SEPARATEUR);
}