315 lines
15 KiB
JavaScript
315 lines
15 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.
|
||
|
|
// 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';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 {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,
|
||
|
|
* 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
|
||
|
|
* @property {null|'CARACTERE_NUL'|'AUCUN_ENTETE'|'SEPARATEUR_INTROUVABLE'|'GUILLEMET_OUVERT'|'NOM_NON_ASSOCIE'|'AUCUNE_LIGNE_VALIDE'} refusGlobal
|
||
|
|
* sous un refus global, lignes, fusions et doublons 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 : ce que la lecture a établi avant le
|
||
|
|
// refus, aucune ligne.
|
||
|
|
function refuser(
|
||
|
|
code,
|
||
|
|
{ separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {},
|
||
|
|
ligne = null,
|
||
|
|
) {
|
||
|
|
return {
|
||
|
|
separateur,
|
||
|
|
entetes,
|
||
|
|
association,
|
||
|
|
lignes: [],
|
||
|
|
refusees: [],
|
||
|
|
fusions: [],
|
||
|
|
doublons: [],
|
||
|
|
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]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Aperçu d'un texte décodé, import ou collage, contre les participants
|
||
|
|
* existants : rien n'est importé.
|
||
|
|
*
|
||
|
|
* @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)
|
||
|
|
* @returns {Apercu}
|
||
|
|
* @throws {TypeError} quand texte n'est pas une chaîne : des octets passent
|
||
|
|
* d'abord par decoder
|
||
|
|
*/
|
||
|
|
export function apercevoir(texte, { participants = [], choix = {} } = {}) {
|
||
|
|
if (typeof texte !== 'string') throw new TypeError('texte : chaîne attendue');
|
||
|
|
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 = [...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 {
|
||
|
|
...lu,
|
||
|
|
lignes,
|
||
|
|
refusees,
|
||
|
|
fusions,
|
||
|
|
doublons: doublonsDe(existants, lignes),
|
||
|
|
refusGlobal: null,
|
||
|
|
refusGlobalLigne: null,
|
||
|
|
};
|
||
|
|
}
|