gestion_table_tournante_libre/src/csv/apercu.js
Mathieu Benoit abae4fa9b8 [ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : séance observable, façade des événements, places

Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.

Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00

450 lines
22 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Aperçu d'un texte CSV décodé, venu d'un fichier ou d'un collage en bloc
// (§ 10.1, § 10.2). L'aperçu n'importe rien : il dit, ligne par ligne, ce que
// l'import ferait contre les participants existants. Un fichier passe d'abord
// par decoder ; un collage, qui ne porte pas d'octets, arrive ici tel quel ;
// à partir d'ici, les deux suivent le même chemin, et un second analyseur ne
// peut pas diverger du premier.
//
// La lecture suit un ordre fixe, et le premier refus global arrête tout :
// 1. un caractère nul refuse le texte, par la règle de decoder ;
// 2. choisirSeparateur choisit le séparateur ; un texte vide ou blanc est
// refusé AUCUN_ENTETE, tout autre texte sans séparateur
// SEPARATEUR_INTROUVABLE, au rang du défaut que choisirSeparateur
// nomme, sauf quand ce défaut est un guillemet resté ouvert : le texte
// se lit alors sous le candidat qu'il écarte, jusqu'au refus du point 4 ;
// 3. decouper rend les enregistrements ; l'en-tête est le premier non vide ;
// 4. un guillemet resté ouvert refuse le texte : ce qui le suit tiendrait
// dans un seul champ, et deux personnes se fondraient en une ;
// 5. aucun en-tête reconnu, puis aucune colonne associée à nom, refusent ;
// 6. chaque enregistrement non vide qui suit l'en-tête est une ligne,
// valide ou refusée pour un seul motif ; zéro ligne valide refuse ;
// 7. les appartenances des lignes valides se réconcilient avec celles des
// existants, et les doublons se relèvent ; en mode remplacer, qui
// retire chaque existant, avec ceux du fichier seuls ;
// 8. en mode mettreAJour, les lignes ajoutées à côté d'un existant de même
// nom se nomment ; dans chaque mode, les cellules qui s'étendent sur
// plusieurs lignes du texte et portent le séparateur.
// Une ligne refusée ne refuse pas le texte : le reste s'importe, et la ligne
// revient dans le CSV des refus (§ 10.1).
import { associer, reconnaitre } from './colonnes.js';
import { contientNul } from './encodage.js';
import { ErreurCsv } from './erreurs.js';
import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js';
import { cleNormalisee } from './normalisation.js';
/**
* Modes de l'import, que l'aperçu et l'import reçoivent tous deux (§ 10.1).
*
* @type {readonly string[]}
*/
export const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']);
/**
* Valeurs admises d'« exclu », par clé normalisée (cleNormalisee) : une liste
* fermée. Une valeur absente de la liste refuse la ligne. La clé vide dit ce
* que vaut une cellule vide pour une personne créée, non ; l'aperçu rend
* pourtant null pour une cellule vide, qu'une mise à jour lit comme « garder
* la valeur » (voir ChampsLus).
*
* @type {Map<string, boolean>}
*/
export const VALEURS_EXCLU = new Map([
['oui', true],
['o', true],
['vrai', true],
['1', true],
['x', true],
['non', false],
['n', false],
['faux', false],
['0', false],
['', false],
]);
/**
* @typedef {Object} ChampsLus
* @property {string} nom
* @property {string|null} prenom
* @property {string|null} appartenance l'orthographe affichée de son groupe ;
* null pour une cellule vide, blanche ou faite de marques
* combinantes seules : sa clé normalisée est vide
* @property {string|null} courriel
* @property {string|null} titrePressenti
* @property {string|null} notes
* @property {boolean|null} exclu null pour une cellule vide ou une
* colonne absente : vide vaut non pour une personne créée, et laisse
* la valeur d'une personne existante qu'un import met à jour
*
* @typedef {Object} Apercu
* @property {'ajouter'|'mettreAJour'|'remplacer'} mode le mode pour lequel
* l'aperçu est calculé : appliquerImport n'applique l'aperçu que
* dans ce mode
* @property {string|null} separateur null pour un fichier à une colonne,
* et quand aucun séparateur n'est établi : sous CARACTERE_NUL, sous
* SEPARATEUR_INTROUVABLE, pour un texte vide ou blanc
* @property {string[]} entetes tels qu'écrits
* @property {{colonnes: Object<string, number>, ambigus: Array<{champ: string, rangs: number[]}>,
* nonReconnues: number[]}} association ce que rend associer
* @property {Array<{ligne: number, champs: ChampsLus, brut: string[]}>} lignes
* les lignes valides ; ligne : rang de l'enregistrement à partir de
* 1, l'en-tête compris ; champs : chaînes en NFC, blancs de bord
* retirés, vide → null ; brut : les champs tels que découpés
* @property {Array<{ligne: number, code: string, valeur: string|null, brut: string[]}>} refusees
* code : CHAMPS_EN_TROP, NOM_ABSENT, EXCLU_INCONNU ; valeur : la
* valeur d'« exclu » refusée, en NFC sans ses blancs de bord, null
* pour les deux autres codes
* @property {Array<{affichee: string, orthographes: Array<{texte: string, lignes: number[]}>}>} fusions
* chaque groupe d'au moins deux orthographes, existantes comprises
* hors du mode remplacer, qui porte une ligne valide, rangé par sa
* première ligne ;
* orthographes dans l'ordre de rencontre, la première affichée ;
* lignes : rangs des lignes valides de cette orthographe, vide pour
* une orthographe que seuls des participants existants portent
* @property {Array<{cle: string, lignes: number[], existants: number[]}>} doublons
* chaque triplet (nom, prénom, appartenance) normalisé que portent au
* moins deux personnes, dont une ligne valide, rangé par sa première
* ligne ; cle : le triplet normalisé, en JSON ; existants :
* identifiants croissants, toujours vide en mode remplacer
* @property {Array<{ligne: number, participants: number[]}>} homonymes
* en mode mettreAJour, chaque ligne valide qui ne désigne personne
* (designer) alors que des existants portent son nom normalisé :
* l'import l'ajoute à côté d'eux, une seconde personne de ce nom,
* parce que le prénom ou l'appartenance, présents des deux côtés,
* diffèrent ; participants : ces existants, identifiants
* croissants ; rangée par ligne. Toujours vide dans les autres
* modes : ajouter crée chaque ligne, remplacer retire chaque
* existant
* @property {Array<{ligne: number, rangs: number[]}>} multilignes
* chaque ligne valide dont une cellule au moins s'étend sur
* plusieurs lignes du texte et porte le séparateur, rangée par
* ligne ; rangs : les rangs croissants de ces cellules dans brut.
* Sans séparateur, la fin de ligne seule suffit. C'est la forme
* que laisse un guillemet égaré qu'un autre guillemet referme plus
* loin, devant le séparateur ou une fin de ligne : les
* enregistrements d'entre les deux tiennent dans la cellule, et
* deux personnes se fondent en une. Une note écrite sur plusieurs
* lignes a la même forme, et l'export l'écrit ainsi (§ 10.2) :
* l'aperçu la nomme sans refuser la ligne
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
* sous un refus global, lignes, fusions, doublons, homonymes et
* multilignes sont vides, et refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes
* refusées sont la cause
* @property {number|null} refusGlobalLigne sous GUILLEMET_OUVERT, le rang de
* l'enregistrement où le guillemet s'ouvre ; sous
* SEPARATEUR_INTROUVABLE, celui de l'enregistrement dont le nombre
* de champs écarte le séparateur, quand choisirSeparateur le nomme ;
* 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: [],
fusions: [],
doublons: [],
homonymes: [],
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;
}
// Vrai pour une appartenance qui en nomme une : une chaîne dont la clé
// normalisée n'est pas vide. Une appartenance vide, blanche ou faite de
// marques combinantes seules vaut absence (§ 10.1) : la clé de doublon la
// compte vide, comme une appartenance absente, et elle n'est l'orthographe
// d'aucun groupe. La réconciliation et la clé de doublon s'accordent ainsi
// sur chaque appartenance, lue ou existante.
const appartenanceNommee = (texte) => typeof texte === 'string' && cleNormalisee(texte) !== '';
// Lit un enregistrement de données selon les colonnes associées, largeur
// étant le nombre d'en-têtes. Rend { champs }, l'appartenance telle qu'écrite
// ou null quand elle n'en nomme aucune, ou le motif du refus { code, valeur },
// un seul, dans cet ordre : un champ non vide au-delà de l'en-tête, qui dit
// une ligne décalée dont aucune valeur n'est sûre ; un nom vide ; une valeur
// d'« exclu » hors de la liste fermée.
function lireLigne(brut, largeur, colonnes) {
if (!enregistrementVide(brut.slice(largeur))) return { code: 'CHAMPS_EN_TROP', valeur: null };
const lire = (champ) => valeurDe(brut, colonnes[champ]);
const nom = lire('nom');
if (nom === null) return { code: 'NOM_ABSENT', valeur: null };
const valeurExclu = lire('exclu');
const exclu = valeurExclu === null ? null : VALEURS_EXCLU.get(cleNormalisee(valeurExclu));
if (exclu === undefined) return { code: 'EXCLU_INCONNU', valeur: valeurExclu };
const appartenance = lire('appartenance');
return {
champs: {
nom,
prenom: lire('prenom'),
appartenance: appartenanceNommee(appartenance) ? appartenance : null,
courriel: lire('courriel'),
titrePressenti: lire('titre_pressenti'),
notes: lire('notes'),
exclu,
},
};
}
// Réconcilie les appartenances (§ 10.1). Les orthographes de même clé
// normalisée forment un groupe, que représente la première rencontrée :
// celles des participants existants d'abord, dans l'ordre de existants,
// telles qu'enregistrées, puis celles des lignes valides, dans l'ordre du
// texte. Rend l'orthographe affichée du groupe d'un texte, et les fusions :
// chaque groupe d'au moins deux orthographes qui porte une ligne valide,
// rangé par sa première ligne.
function reconcilier(existants, valides) {
const groupes = new Map();
const noter = (texte, ligne) => {
const cle = cleNormalisee(texte);
let groupe = groupes.get(cle);
if (groupe === undefined) {
groupe = { affichee: texte, orthographes: [], parTexte: new Map(), lignes: [] };
groupes.set(cle, groupe);
}
let orthographe = groupe.parTexte.get(texte);
if (orthographe === undefined) {
orthographe = { texte, lignes: [] };
groupe.orthographes.push(orthographe);
groupe.parTexte.set(texte, orthographe);
}
if (ligne !== null) {
orthographe.lignes.push(ligne);
groupe.lignes.push(ligne);
}
};
for (const { appartenance } of existants) {
if (appartenanceNommee(appartenance)) noter(appartenance, null);
}
for (const { ligne, champs } of valides) {
if (champs.appartenance !== null) noter(champs.appartenance, ligne);
}
const fusions = [...groupes.values()]
.filter((groupe) => groupe.orthographes.length > 1 && groupe.lignes.length > 0)
.sort((a, b) => a.lignes[0] - b.lignes[0])
.map(({ affichee, orthographes }) => ({ affichee, orthographes }));
return { afficheeDe: (texte) => groupes.get(cleNormalisee(texte)).affichee, fusions };
}
// Clé de doublon : le triplet (nom, prénom, appartenance), chacun par sa clé
// normalisée, un prénom ou une appartenance absents comptant comme vides,
// écrit en JSON pour qu'aucun texte ne se confonde avec une frontière.
const cleDoublon = (nom, prenom, appartenance) =>
JSON.stringify([nom, prenom ?? '', appartenance ?? ''].map((texte) => cleNormalisee(texte)));
// Doublons (§ 10.1) : chaque clé de doublon que portent au moins deux
// personnes, dont une ligne valide, avec les rangs de ses lignes et les
// identifiants de ses existants, rangée par sa première ligne. Un doublon est
// signalé, jamais fusionné : deux personnes peuvent porter le même nom.
function doublonsDe(existants, valides) {
const groupes = new Map();
const groupeDe = (cle) => {
if (!groupes.has(cle)) groupes.set(cle, { cle, lignes: [], existants: [] });
return groupes.get(cle);
};
for (const { id, nom, prenom, appartenance } of existants) {
groupeDe(cleDoublon(nom, prenom, appartenance)).existants.push(id);
}
for (const { ligne, champs } of valides) {
groupeDe(cleDoublon(champs.nom, champs.prenom, champs.appartenance)).lignes.push(ligne);
}
return [...groupes.values()]
.filter((groupe) => groupe.lignes.length > 0 && groupe.lignes.length + groupe.existants.length > 1)
.sort((a, b) => a.lignes[0] - b.lignes[0]);
}
// Vrai quand la valeur d'une ligne et celle d'un participant laissent ce
// participant parmi les candidats de la ligne : l'une ou l'autre est absente
// — null, ou de clé normalisée vide —, ou leurs clés normalisées sont égales.
function compatibles(deLaLigne, duParticipant) {
const cleLigne = cleNormalisee(deLaLigne ?? '');
const cleParticipant = cleNormalisee(duParticipant ?? '');
return cleLigne === '' || cleParticipant === '' || cleLigne === cleParticipant;
}
/**
* Index des participants par nom normalisé, que lit designer. Chaque entrée
* copie l'identifiant, le prénom et l'appartenance du participant : ce
* qu'une mise à jour pose ensuite sur lui ne change pas l'index, qui garde
* les valeurs d'avant l'import.
*
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} participants
* @returns {Map<string, Array<{id: number, prenom: string|null, appartenance: string|null}>>}
*/
export function indexerParNom(participants) {
const index = new Map();
for (const { id, nom, prenom, appartenance } of participants) {
const cle = cleNormalisee(nom);
if (!index.has(cle)) index.set(cle, []);
index.get(cle).push({ id, prenom, appartenance });
}
return index;
}
/**
* Participants qu'une ligne désigne en mise à jour (§ 10.1), seule règle de
* la désignation, que l'aperçu et l'import lisent tous deux. memeNom : les
* participants de l'index de même nom normalisé que la ligne. candidats :
* ceux d'entre eux que ni le prénom ni l'appartenance n'écartent ; l'un ou
* l'autre n'écarte un participant que lorsque la ligne et lui en portent
* tous deux un, de clés normalisées différentes : une colonne absente ou une
* cellule vide n'écarte personne, comme elle ne vide aucune valeur.
*
* @param {{nom: string, prenom: string|null, appartenance: string|null}} champs
* @param {ReturnType<typeof indexerParNom>} index
* @returns {{memeNom: number[], candidats: number[]}} identifiants croissants
*/
export function designer(champs, index) {
const memeNom = index.get(cleNormalisee(champs.nom)) ?? [];
const croissants = (entrees) => entrees.map(({ id }) => id).sort((a, b) => a - b);
return {
memeNom: croissants(memeNom),
candidats: croissants(
memeNom.filter(
({ prenom, appartenance }) =>
compatibles(champs.prenom, prenom) && compatibles(champs.appartenance, appartenance),
),
),
};
}
// Homonymes d'une mise à jour : chaque ligne valide que designer ne fait
// désigner à personne, alors que des participants portent son nom, avec
// leurs identifiants. L'index est celui des participants d'avant l'import :
// une ligne n'y change rien pour la suivante.
function homonymesDe(participants, lignes) {
const index = indexerParNom(participants);
const homonymes = [];
for (const { ligne, champs } of lignes) {
const { memeNom, candidats } = designer(champs, index);
if (memeNom.length > 0 && candidats.length === 0) homonymes.push({ ligne, participants: memeNom });
}
return homonymes;
}
// 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;
}
/**
* Aperçu d'un texte décodé, import ou collage, contre les participants
* existants, pour un mode de l'import : rien n'est importé. En mode
* remplacer, chaque existant est retiré avant que les lignes s'ajoutent :
* aucune ligne n'en est le doublon, et aucune orthographe existante ne
* s'affiche ni ne fond avec celles du fichier. Ajouter et mettre à jour
* lisent le fichier contre les existants.
*
* @param {string} texte le texte que rend decoder, ou celui d'un collage
* @param {Object} [options]
* @param {Array<{id: number, nom: string, prenom: string|null, appartenance: string|null}>} [options.participants]
* les participants existants, dans n'importe quel ordre : l'identifiant
* ordonne leurs orthographes
* @param {Object<string, number>} [options.choix] { champ: rang } qui tranche
* une ambiguïté de l'en-tête (voir associer)
* @param {'ajouter'|'mettreAJour'|'remplacer'} [options.mode] ajouter par
* défaut
* @returns {Apercu}
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
* d'abord par decoder ; pour un mode inconnu
*/
export function apercevoir(texte, { participants = [], choix = {}, mode = 'ajouter' } = {}) {
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.join(', ')} attendu, reçu ${String(mode)}`);
// Chaque refus global porte le mode de l'aperçu.
const refuser = (...refus) => refuserEn(mode, ...refus);
if (contientNul(texte)) return refuser('CARACTERE_NUL');
let separateur;
try {
({ separateur } = choisirSeparateur(texte, reconnaitre));
} 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);
// Seul le guillemet resté ouvert écarte ce candidat : le texte se lit
// sous lui, et le refus GUILLEMET_OUVERT porte le séparateur, l'en-tête,
// l'association et le rang, comme au-delà de la fenêtre.
({ separateur } = erreur.details);
}
const { enregistrements, guillemetOuvert } = decouper(texte, separateur);
// Le séparateur, retenu ou écarté par le seul guillemet ouvert, a lu un
// enregistrement non vide dans la fenêtre du choix : l'en-tête existe.
const indiceEntete = enregistrements.findIndex((enregistrement) => !enregistrementVide(enregistrement));
const entetes = enregistrements[indiceEntete];
const association = associer(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.nom === undefined) return refuser('NOM_NON_ASSOCIE', lu);
const valides = [];
const refusees = [];
for (let indice = indiceEntete + 1; indice < enregistrements.length; indice += 1) {
const brut = enregistrements[indice];
if (enregistrementVide(brut)) continue;
const ligne = indice + 1;
const lecture = lireLigne(brut, entetes.length, association.colonnes);
if (lecture.champs === undefined) refusees.push({ ligne, code: lecture.code, valeur: lecture.valeur, brut });
else valides.push({ ligne, champs: lecture.champs, brut });
}
if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees };
const existants = mode === 'remplacer' ? [] : [...participants].sort((a, b) => a.id - b.id);
const { afficheeDe, fusions } = reconcilier(existants, valides);
const lignes = valides.map(({ ligne, champs, brut }) => ({
ligne,
champs: { ...champs, appartenance: champs.appartenance === null ? null : afficheeDe(champs.appartenance) },
brut,
}));
return {
mode,
...lu,
lignes,
refusees,
fusions,
doublons: doublonsDe(existants, lignes),
homonymes: mode === 'mettreAJour' ? homonymesDe(participants, lignes) : [],
multilignes: multilignesDe(lignes, separateur),
refusGlobal: null,
refusGlobalLigne: null,
};
}