gestion_table_tournante_libre/src/csv/objectifs.js
Mathieu Benoit 7dc23946c2 [ADD] priorities: search and ranking follow tiers, goals CSV, commands
The operator's priority order now drives both the search and the
ranking: each tier compiles into one score component, weights sum inside
a shared tier, and the three goal modes compare their own values. With
no stored order, no goals and the facilitator case off, plans and
rankings equal the reference fingerprints under every mode; incremental
and complete scores agree on 1 500 random configurations; a move costs
1 to 2 % more. Table goals import and export through their own CSV, and
commands set or restore the order and choose the goal mode.
Checked: node 4501, navigateur 885, node-long 124.

--- FR ---

[ADD] priorités : recherche et classement par paliers, CSV, commandes

L'ordre des priorités de l'opérateur mène désormais la recherche et le
classement : chaque palier devient une composante du score, les poids
s'additionnent dans un palier partagé, et les trois modes des objectifs
comparent leurs propres valeurs. Sans ordre écrit, sans objectif et sans
case des animateurs, plans et classements égalent les empreintes sous
chaque mode ; score incrémental et complet concordent sur 1 500
configurations tirées ; un mouvement coûte 1 à 2 % de plus. Les objectifs
des tables s'importent et s'exportent par leur CSV ; des commandes posent
ou rétablissent l'ordre et choisissent le mode des objectifs.
Vérifié : node 4501, navigateur 885, node-long 124.

Assisted-by: Claude Opus 5.5
2026-10-11 16:19:38 -04:00

571 lines
28 KiB
JavaScript

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