diff --git a/src/csv/apercu.js b/src/csv/apercu.js index 754c197..bb82f1f 100644 --- a/src/csv/apercu.js +++ b/src/csv/apercu.js @@ -22,7 +22,11 @@ // 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. +// 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). @@ -32,6 +36,13 @@ import { ErreurCsv } from './erreurs.js'; import { choisirSeparateur, decouper, enregistrementVide } from './lecture.js'; import { cleNormalisee } from './normalisation.js'; +/** + * Modes de l'import (§ 10.1), que l'aperçu et l'import reçoivent tous deux. + * + * @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 @@ -69,6 +80,9 @@ export const VALEURS_EXCLU = new Map([ * 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 @@ -84,8 +98,9 @@ export const VALEURS_EXCLU = new Map([ * 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 ; + * 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 @@ -93,10 +108,30 @@ export const VALEURS_EXCLU = new Map([ * 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 + * 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 et doublons sont vides, et - * refusees aussi, sauf sous AUCUNE_LIGNE_VALIDE, dont les lignes + * 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 @@ -105,14 +140,16 @@ export const VALEURS_EXCLU = new Map([ * null sinon */ -// Aperçu d'un texte refusé en entier : ce que la lecture a établi avant le -// refus, aucune ligne. -function refuser( +// 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, @@ -120,6 +157,8 @@ function refuser( refusees: [], fusions: [], doublons: [], + homonymes: [], + multilignes: [], refusGlobal: code, refusGlobalLigne: ligne, }; @@ -237,9 +276,98 @@ function doublonsDe(existants, valides) { .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>} + */ +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} 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 : rien n'est importé. + * 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] @@ -248,12 +376,17 @@ function doublonsDe(existants, valides) { * ordonne leurs orthographes * @param {Object} [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 + * d'abord par decoder ; pour un mode inconnu */ -export function apercevoir(texte, { participants = [], choix = {} } = {}) { +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; @@ -295,7 +428,7 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) { } if (valides.length === 0) return { ...refuser('AUCUNE_LIGNE_VALIDE', lu), refusees }; - const existants = [...participants].sort((a, b) => a.id - b.id); + 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, @@ -303,11 +436,14 @@ export function apercevoir(texte, { participants = [], choix = {} } = {}) { 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, }; diff --git a/src/csv/apercu.test.js b/src/csv/apercu.test.js index ac71754..daa7f98 100644 --- a/src/csv/apercu.test.js +++ b/src/csv/apercu.test.js @@ -53,6 +53,7 @@ const refusGlobal = ( { separateur = null, entetes = [], association = { colonnes: {}, ambigus: [], nonReconnues: [] } } = {}, ligne = null, ) => ({ + mode: 'ajouter', separateur, entetes, association, @@ -60,6 +61,8 @@ const refusGlobal = ( refusees: [], fusions: [], doublons: [], + homonymes: [], + multilignes: [], refusGlobal: code, refusGlobalLigne: ligne, }); @@ -94,6 +97,7 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => { 'Pervenche;Théo;;;;oui;', ); assert.deepEqual(apercevoir(texte), { + mode: 'ajouter', separateur: ';', entetes: ['Nom', 'Prénom', 'Équipe', 'Courriel', 'Rôle', 'Exclu', 'Notes'], association: { @@ -119,6 +123,8 @@ describe("apercevoir : les lignes d'un texte sans défaut (§ 10.1)", () => { refusees: [], fusions: [], doublons: [], + homonymes: [], + multilignes: [], refusGlobal: null, refusGlobalLigne: null, }); @@ -565,6 +571,89 @@ describe('apercevoir : les doublons, signalés et jamais fusionnés (§ 10.1)', }); }); +describe("apercevoir : le mode de l'import (§ 10.1)", () => { + // Deux participants existants, dont l'appartenance de l'un s'écrit + // autrement dans le fichier. + const participants = [existant(1, 'Ombrelle', 'Iris', 'Club des Merles'), existant(2, 'Lacasse', 'Ondine', null)]; + const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles', 'Lacasse;Ondine;', 'Lacasse;Ondine;'); + + test("ajouter, par défaut, et mettre à jour lisent le fichier contre les existants : doublons et fusions les comptent", () => { + for (const mode of [undefined, 'ajouter', 'mettreAJour']) { + const apercu = apercevoir(texte, { participants, mode }); + assert.equal(apercu.mode, mode ?? 'ajouter'); + assert.deepEqual(apercu.doublons, [ + { cle: '["ombrelle","iris","club des merles"]', lignes: [2], existants: [1] }, + { cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [2] }, + ], String(mode)); + assert.deepEqual(apercu.fusions, [ + { affichee: 'Club des Merles', orthographes: [{ texte: 'Club des Merles', lignes: [] }, { texte: 'club des merles', lignes: [2] }] }, + ], String(mode)); + assert.equal(apercu.lignes[0].champs.appartenance, 'Club des Merles', String(mode)); + } + }); + + test("remplacer retire chaque existant : aucune ligne n'en est le doublon, aucune orthographe existante ne s'affiche ni ne fond", () => { + const apercu = apercevoir(texte, { participants, mode: 'remplacer' }); + assert.equal(apercu.mode, 'remplacer'); + assert.deepEqual(apercu.doublons, [{ cle: '["lacasse","ondine",""]', lignes: [3, 4], existants: [] }]); + assert.deepEqual(apercu.fusions, []); + assert.equal(apercu.lignes[0].champs.appartenance, 'club des merles'); + assert.deepEqual(apercu, apercevoir(texte, { participants: [], mode: 'remplacer' })); + }); + + test("mettre à jour nomme chaque ligne qu'il ajoute à côté d'un existant de même nom, que le prénom ou l'appartenance écarte des deux côtés", () => { + // Iris passe du Club des Merles à la Société Alpha : la ligne ne désigne + // personne, et l'import ajouterait une seconde Iris Ombrelle. + const existants = [ + existant(1, 'Ombrelle', 'Iris', 'Club des Merles'), + existant(2, 'Ombrelle', 'Théo', null), + existant(3, 'Lacasse', 'Ondine', null), + ]; + const changeDeClub = csv( + 'nom;prenom;appartenance', + // Iris écarte 2, la Société Alpha écarte 1 : un homonyme de 1 et 2. + 'OMBRELLE;Iris;Société Alpha', + // Désigne 3, qui n'a pas d'appartenance : aucun homonyme. + 'Lacasse;Ondine;Club des Merles', + // Aucun existant de ce nom : un ajout, pas un homonyme. + 'Pervenche;Théo;', + // Sans prénom ni appartenance : les deux Ombrelle, un refus de + // l'import, pas un ajout. + 'Ombrelle;;', + // Désigne 2, sans prénom ni appartenance de part et d'autre. + 'Ombrelle;Théo;Chorale du Givre', + ); + assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode: 'mettreAJour' }).homonymes, [ + { ligne: 2, participants: [1, 2] }, + ]); + // Ajouter et remplacer n'en nomment aucun : ajouter crée chaque ligne, + // remplacer retire chaque existant. + for (const mode of ['ajouter', 'remplacer']) { + assert.deepEqual(apercevoir(changeDeClub, { participants: existants, mode }).homonymes, [], mode); + } + // Les lignes qui désignent un existant ne retirent rien aux candidats + // des suivantes ; les identifiants sont croissants, quel que soit + // l'ordre des existants. + const inverse = csv('nom;prenom', 'Ombrelle;Théo', 'Ombrelle;Iris', 'Ombrelle;Anouk'); + assert.deepEqual( + apercevoir(inverse, { participants: [existants[1], existants[0]], mode: 'mettreAJour' }).homonymes, + [{ ligne: 4, participants: [1, 2] }], + ); + }); + + test('un aperçu refusé porte son mode', () => { + assert.equal(apercevoir('', { mode: 'remplacer' }).mode, 'remplacer'); + assert.equal(apercevoir(csv('prenom', 'Iris'), { mode: 'mettreAJour' }).mode, 'mettreAJour'); + }); + + test('un mode inconnu : TypeError, qui nomme les trois modes', () => { + assert.throws(() => apercevoir(texte, { mode: 'fusionner' }), { + name: 'TypeError', + message: 'mode : ajouter, mettreAJour, remplacer attendu, reçu fusionner', + }); + }); +}); + describe('apercevoir : la réconciliation et la clé de doublon, une seule forme normalisée (§ 10.1)', () => { test('deux personnes du même nom et du même prénom forment un doublon si et seulement si leurs appartenances se fondent', () => { // Chaque orthographe éprouve une dimension de la clé : casse, accent @@ -764,6 +853,72 @@ describe('apercevoir : les refus globaux (§ 10.1)', () => { } }); + test("un guillemet égaré au-delà de la fenêtre, refermé plus loin sur une lettre : GUILLEMET_OUVERT au rang où il s'ouvre, aucune personne fondue dans une autre", () => { + // Vingt-cinq enregistrements : la note du rang 23 ouvre un guillemet que + // rien ne referme à sa place ; celui de « "Bob" », au rang 25, le + // referme, suivi d'une lettre. Les rangs 24 et 25 tiendraient sinon dans + // la note du rang 23, et l'aperçu compterait 22 lignes sans un refus. + const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`); + const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;surnommé "Bob" par tous'); + assert.deepEqual( + apercevoir(texte), + refusGlobal( + 'GUILLEMET_OUVERT', + { + separateur: ';', + entetes: ['nom', 'prenom', 'notes'], + association: { colonnes: { nom: 0, prenom: 1, notes: 2 }, ambigus: [], nonReconnues: [] }, + }, + 23, + ), + ); + // Le même texte dont la note se referme à sa place s'importe en entier. + const referme = texte.replace('"VIP au fond', '"VIP au fond"'); + assert.equal(apercevoir(referme).refusGlobal, null); + assert.equal(apercevoir(referme).lignes.length, 24); + }); + + test("un guillemet égaré refermé plus loin par un pouce, au-delà de la fenêtre : la cellule fondue est nommée, à son rang", () => { + // Le guillemet de « écran 27" » referme la note ouverte au rang 23 : + // les rangs 24 et 25 tiennent dans cette note, que rien ne refuse. + // L'aperçu nomme la cellule, qui s'étend sur plusieurs lignes du texte + // et porte le séparateur. + const lignes = Array.from({ length: 21 }, (_, i) => `Nom${i + 1};Pre${i + 1};`); + const texte = csv('nom;prenom;notes', ...lignes, 'Nom22;Pre22;"VIP au fond', 'Nom23;Pre23;', 'Nom24;Pre24;écran 27"', 'Nom25;Pre25;'); + const apercu = apercevoir(texte); + assert.equal(apercu.refusGlobal, null); + assert.equal(apercu.lignes.length, 23); + assert.equal(apercu.lignes[21].champs.notes, 'VIP au fond\r\nNom23;Pre23;\r\nNom24;Pre24;écran 27'); + assert.deepEqual(apercu.multilignes, [{ ligne: 23, rangs: [2] }]); + }); + + test("une cellule nommée : sur plusieurs lignes du texte et portant le séparateur, dans la fenêtre comme au-delà ; un nom sur plusieurs lignes d'un fichier à une colonne", () => { + // Dans la fenêtre, le guillemet égaré du rang 3 se referme au rang 4 : + // l'enregistrement fondu a la largeur de l'en-tête, et le séparateur + // est retenu. + const fondu = csv('nom;prenom;notes', 'Ombrelle;Iris;', 'Lacasse;Ondine;"à l\'entrée', 'Pervenche;Théo;écran 27"', 'Givre;Silas;'); + assert.deepEqual(apercevoir(fondu).multilignes, [{ ligne: 3, rangs: [2] }]); + // Une note sur plusieurs lignes sans le séparateur, ou le séparateur + // sur une seule ligne, n'est pas nommée ; une cellule qui porte les + // deux l'est, une fois par cellule, quel que soit le séparateur. + const notes = csv( + 'nom,prenom,appartenance,notes', + 'Ombrelle,Iris,"Club, nord","arrive tôt\nrepart tard"', + 'Lacasse,Ondine,"Chorale\ndu Givre","noix, arachides\r\nsans gluten"', + 'Pervenche,Théo,,"au fond\rprès de la scène, côté jardin"', + ); + assert.deepEqual(apercevoir(notes).multilignes, [{ ligne: 3, rangs: [3] }, { ligne: 4, rangs: [3] }]); + // Fichier à une colonne : un nom ne s'étend jamais sur deux lignes, et + // aucun séparateur n'est à chercher. + assert.deepEqual(apercevoir(csv('nom', 'Ombrelle', '"Lacasse', 'Pervenche"', 'Givre')).multilignes, [ + { ligne: 3, rangs: [0] }, + ]); + // Une ligne refusée revient dans le CSV des refus : elle n'est pas + // nommée ici. + const refusee = csv('nom;notes;exclu', 'Ombrelle;"a\nb;c";peut-être', 'Lacasse;;'); + assert.deepEqual(apercevoir(refusee).multilignes, []); + }); + test("sous GUILLEMET_OUVERT, l'association suit le choix, dans la fenêtre comme au-delà", () => { for (const rang of [2, 21]) { const texte = csv('nom;organisation;entreprise', ...fenetre((patronyme) => `${patronyme};x;y`, rang - 2), 'Ombrelle;"x'); diff --git a/src/csv/import.js b/src/csv/import.js index aca6bef..753c337 100644 --- a/src/csv/import.js +++ b/src/csv/import.js @@ -21,12 +21,10 @@ // champs en trop parmi les vingt premiers enregistrements, qui fait refuser // le texte entier, à son rang, par le choix du séparateur. +import { MODES, designer, indexerParNom } from './apercu.js'; import { ErreurCsv } from './erreurs.js'; import { SEPARATEUR, ecrireCsv } from './export.js'; import { enregistrementVide } from './lecture.js'; -import { cleNormalisee } from './normalisation.js'; - -const MODES = Object.freeze(['ajouter', 'mettreAJour', 'remplacer']); // Champs d'une ligne de l'aperçu, dans l'ordre du participant : ceux que // porte une personne créée, et que pose une mise à jour quand la cellule @@ -67,14 +65,8 @@ const ENTETE_MOTIF = 'motif'; * @property {boolean} derive vrai en état retenu */ -const croissant = (a, b) => a - b; const parLigne = (a, b) => a.ligne - b.ligne; -// Clé qui apparie une ligne aux participants existants : le nom et le -// prénom, chacun par sa clé normalisée, un prénom absent comptant comme -// vide, écrits en JSON pour qu'aucun texte ne se confonde avec une frontière. -const cleDeNom = (nom, prenom) => JSON.stringify([cleNormalisee(nom), cleNormalisee(prenom ?? '')]); - // Titres pourvus (§ 4.1, § 4.4). Un titre n'est pourvu que par la réservation // d'une personne non exclue, sur sa place, à un tour au moins : la // réservation d'une personne exclue est suspendue. La place se lit selon le @@ -164,56 +156,44 @@ function poser(participant, champs) { } // Met à jour, en place, les participants que désignent les lignes, et rend -// les lignes à ajouter, les refus et les comptes. Une ligne désigne les -// participants d'avant l'import dont le nom et le prénom ont la même clé -// normalisée que les siens, et l'appartenance aussi quand la ligne en porte -// une : un participant ajouté par une ligne n'est désigné par aucune autre. -// Un prénom absent compte comme vide, sur la ligne comme chez le -// participant : seule une ligne sans prénom désigne un participant sans -// prénom, et elle ne désigne que ceux-là. L'appartenance que porte une ligne -// filtre même un homonyme unique, et un participant sans appartenance ne -// passe pas ce filtre. Une ligne qui ne désigne personne est à ajouter, -// jamais fondue dans un homonyme. Une ligne qui en désigne plusieurs est -// refusée PLUSIEURS_CORRESPONDENT. Le participant qu'une ligne désigne seule -// est mis à jour par la première de ces lignes, dans l'ordre du fichier ; -// chaque suivante est refusée DEJA_DESIGNE : deux lignes ne se fondent pas -// en silence dans une même personne. +// les lignes à ajouter, les refus et les comptes. Chaque ligne désigne par +// designer, contre l'index des participants d'avant l'import : un +// participant ajouté par une ligne n'est désigné par aucune autre, et le +// prénom ou l'appartenance qu'une ligne pose n'écarte pas ce participant des +// candidats d'une suivante. Une ligne qui ne désigne personne est à ajouter. +// Une ligne qui en désigne plusieurs est refusée PLUSIEURS_CORRESPONDENT, +// jamais ajoutée. Le participant qu'une ligne désigne seule est mis à jour +// par la première de ces lignes, dans l'ordre du fichier ; chaque suivante +// est refusée DEJA_DESIGNE : deux lignes ne se fondent pas en silence dans +// une même personne. function mettreAJour(participants, lignes) { - const parNom = new Map(); - for (const participant of participants) { - const cle = cleDeNom(participant.nom, participant.prenom); - if (!parNom.has(cle)) parNom.set(cle, []); - parNom.get(cle).push(participant); - } + const index = indexerParNom(participants); + const parId = new Map(participants.map((participant) => [participant.id, participant])); const designes = new Map(); const nouvelles = []; const refusees = []; let misAJour = 0; let inchanges = 0; for (const { ligne, champs, brut } of lignes) { - const appartenance = champs.appartenance === null ? null : cleNormalisee(champs.appartenance); - const candidats = (parNom.get(cleDeNom(champs.nom, champs.prenom)) ?? []).filter( - (participant) => appartenance === null || cleNormalisee(participant.appartenance ?? '') === appartenance, - ); + const { candidats } = designer(champs, index); if (candidats.length === 0) { nouvelles.push({ champs }); continue; } if (candidats.length > 1) { - const ids = candidats.map(({ id }) => id).sort(croissant); - refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: ids, brut: [...brut] }); + refusees.push({ ligne, code: 'PLUSIEURS_CORRESPONDENT', valeur: null, participants: candidats, brut: [...brut] }); continue; } - const [designe] = candidats; - if (designes.has(designe.id)) { + const [id] = candidats; + if (designes.has(id)) { refusees.push({ - ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [designe.id], premiereLigne: designes.get(designe.id), + ligne, code: 'DEJA_DESIGNE', valeur: null, participants: [id], premiereLigne: designes.get(id), brut: [...brut], }); continue; } - designes.set(designe.id, ligne); - if (poser(designe, champs)) misAJour += 1; + designes.set(id, ligne); + if (poser(parId.get(id), champs)) misAJour += 1; else inchanges += 1; } return { nouvelles, refusees, misAJour, inchanges }; @@ -223,16 +203,17 @@ function mettreAJour(participants, lignes) { * Applique un aperçu à une charge (§ 10.1), selon le mode : * - 'ajouter' : chaque ligne valide devient un participant ; un doublon ne se * fusionne jamais ; - * - 'mettreAJour' : une ligne désigne les participants d'avant l'import de - * même nom et même prénom normalisés, et de même appartenance quand la - * ligne en porte une : un prénom absent compte comme vide, sur la ligne - * comme chez le participant, et un participant sans appartenance n'est - * désigné que par une ligne qui n'en porte pas. Désigné seul, le - * participant prend chaque champ non vide de la ligne, nom et prénom - * compris, et garde la valeur d'un champ vide ; exclu vide le garde. Une - * ligne qui ne désigne personne est ajoutée ; qui en désigne plusieurs, - * refusée PLUSIEURS_CORRESPONDENT ; qui désigne un participant qu'une - * ligne précédente désigne déjà, refusée DEJA_DESIGNE ; + * - 'mettreAJour' : une ligne désigne, par designer, les participants + * d'avant l'import de même nom normalisé ; le prénom et l'appartenance + * n'en écartent un que lorsque la ligne et lui en portent tous deux un, + * de clés normalisées différentes, selon leurs valeurs d'avant l'import. + * Une ligne ajoutée à côté d'un participant de même nom est celle que + * l'aperçu nomme dans homonymes. Désigné seul, le participant prend chaque champ non vide de + * la ligne, nom, prénom et appartenance compris, et garde la valeur d'un + * champ vide ; exclu vide le garde. Une ligne qui ne désigne personne est + * ajoutée ; qui en désigne plusieurs, refusée PLUSIEURS_CORRESPONDENT ; qui + * désigne un participant qu'une ligne précédente désigne déjà, refusée + * DEJA_DESIGNE ; * - 'remplacer' : les participants et leurs réservations sont retirés, * puis chaque ligne valide ajoutée. Tables, titres, propositions et retenu * restent (bilanRemplacement). @@ -251,19 +232,22 @@ function mettreAJour(participants, lignes) { * * @param {import('../stockage/types.js').Charge} charge * @param {import('./apercu.js').Apercu} apercu calculé contre les - * participants de la charge : l'appartenance d'une ligne y prend - * l'orthographe d'un existant de même clé, que remplacer garde même quand - * il retire cet existant + * participants de la charge, pour ce mode : hors de remplacer, + * l'appartenance d'une ligne y prend l'orthographe d'un existant de même + * clé ; en remplacer, celle du fichier * @param {'ajouter'|'mettreAJour'|'remplacer'} mode * @returns {{charge: import('../stockage/types.js').Charge, resume: ResumeImport}} * @throws {ErreurCsv} dans cet ordre : PLAN_BLOQUE, détails {}, en état * bloqué ; le refus global de l'aperçu, son code, détails { ligne }, le rang * que nomme l'aperçu ou null ; AMBIGUITE, détails { champs }, les champs * que l'en-tête laisse ambigus, tant que le choix ne les tranche pas - * @throws {TypeError} pour un mode inconnu + * @throws {TypeError} pour un mode inconnu, et pour un aperçu calculé pour un + * autre mode : l'aperçu montré à l'opérateur est celui que l'import + * applique */ export function appliquerImport(charge, apercu, mode) { if (!MODES.includes(mode)) throw new TypeError(`mode : ${MODES.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; diff --git a/src/csv/import.test.js b/src/csv/import.test.js index c5c717e..6736247 100644 --- a/src/csv/import.test.js +++ b/src/csv/import.test.js @@ -130,9 +130,10 @@ function retenuDe({ id, siegesAttribues, tables, capacites, tours, participants, return { proposition: id, siegesAttribues, tables, capacites, tours, participants, placement }; } -// Aperçu d'un texte contre les participants de la charge, puis import. +// Aperçu d'un texte contre les participants de la charge, dans le mode +// donné, puis import dans ce même mode. function importer(charge, texte, mode, choix = {}) { - return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix }), mode); + return appliquerImport(charge, apercevoir(texte, { participants: charge.participants, choix, mode }), mode); } // Motif d'épreuve : le code et ses détails en JSON. Il porte guillemets et @@ -304,11 +305,12 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => { test("l'appartenance que porte la ligne compte même devant un seul homonyme : d'une autre appartenance, la ligne est ajoutée, et l'homonyme garde ses valeurs", () => { const existants = [personne(1, 'Ombrelle', 'Iris', 'Club des Merles', { courriel: 'iris@exemple.test' })]; - const { charge: apres, resume } = importer( - chargeDe({ participants: existants }), - csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'), - 'mettreAJour', - ); + const texte = csv('nom;prenom;appartenance;courriel', 'Ombrelle;Iris;Chorale du Givre;givre@exemple.test'); + // L'aperçu nomme la ligne qui s'ajoute à côté de l'homonyme. + assert.deepEqual(apercevoir(texte, { participants: existants, mode: 'mettreAJour' }).homonymes, [ + { ligne: 2, participants: [1] }, + ]); + const { charge: apres, resume } = importer(chargeDe({ participants: existants }), texte, 'mettreAJour'); assert.deepEqual(apres.participants, [ existants[0], personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre', { courriel: 'givre@exemple.test' }), @@ -316,32 +318,91 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => { assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); }); - test('une ligne sans prénom ne désigne que des participants sans prénom : devant le seul participant de ce nom, qui en porte un, elle est ajoutée, et lui reste tel quel', () => { - // La clé est (nom, prénom), un prénom absent y comptant comme vide : une - // liste de noms sans prénoms ajoute des personnes au lieu de mettre à - // jour les inscrits de même nom. - const existants = [personne(1, 'Ombrelle', 'Iris'), personne(2, 'Pervenche', 'Théo')]; - const { charge: apres, resume } = importer(chargeDe({ participants: existants }), csv('nom;exclu', 'Ombrelle;oui'), 'mettreAJour'); - assert.deepEqual(apres.participants, [...existants, personne(3, 'Ombrelle', null, null, { exclu: true })]); - assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + test('une ligne sans prénom, ou un fichier sans colonne prénom, désigne le seul participant de ce nom, qui en porte un : il est mis à jour, rien ajouté', () => { + // Un fichier qui ne porte que les noms et les courriels complète les + // inscrits ; il ne les double pas. + const existants = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Lacasse', 'Ondine', 'Société Alpha'), + personne(3, 'Pervenche', 'Théo'), + ]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), + csv('nom;courriel', 'Ombrelle;iris@exemple.test', 'Lacasse;ondine@exemple.test'), + 'mettreAJour', + ); + assert.deepEqual(apres.participants, [ + { ...existants[0], courriel: 'iris@exemple.test' }, + { ...existants[1], courriel: 'ondine@exemple.test' }, + existants[2], + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 2 })); + const vide = importer(chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Ombrelle;;oui'), 'mettreAJour'); + assert.deepEqual(vide.charge.participants, [{ ...existants[0], exclu: true }, existants[1], existants[2]]); + assert.deepEqual(vide.resume, resumeDe({ misAJour: 1 })); }); - test('une ligne qui porte un prénom ne désigne pas un participant sans prénom : elle est ajoutée, et lui reste sans prénom', () => { + test('une ligne qui porte un prénom désigne le seul participant de ce nom sans prénom : il prend le prénom', () => { const existants = [personne(1, 'Grisaille', null, 'Club des Merles')]; const { charge: apres, resume } = importer( chargeDe({ participants: existants }), csv('nom;prenom;exclu', 'Grisaille;Silas;oui'), 'mettreAJour', ); - assert.deepEqual(apres.participants, [...existants, personne(2, 'Grisaille', 'Silas', null, { exclu: true })]); - assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + assert.deepEqual(apres.participants, [personne(1, 'Grisaille', 'Silas', 'Club des Merles', { exclu: true })]); + assert.deepEqual(resume, resumeDe({ misAJour: 1 })); }); - test("un participant sans appartenance n'est désigné que par une ligne qui n'en porte pas : celle qui en porte une est ajoutée, et lui reste sans appartenance", () => { - const existants = [personne(1, 'Pervenche', 'Théo', null, { courriel: 'theo@exemple.test' })]; + test("une ligne qui porte une appartenance désigne le participant de ce nom et de ce prénom qui n'en a pas : il la prend, rien n'est ajouté", () => { + const existants = [ + personne(1, 'Ombrelle', 'Iris'), + personne(2, 'Lacasse', 'Ondine', null, { courriel: 'ondine@exemple.test' }), + ]; const { charge: apres, resume } = importer( - chargeDe({ participants: existants }), csv('nom;prenom;appartenance', 'Pervenche;Théo;Club des Merles'), 'mettreAJour', + chargeDe({ participants: existants }), + csv('nom;prenom;appartenance', 'Ombrelle;Iris;Club des Merles', 'Lacasse;Ondine;Société Alpha'), + 'mettreAJour', ); - assert.deepEqual(apres.participants, [...existants, personne(2, 'Pervenche', 'Théo', 'Club des Merles')]); - assert.deepEqual(resume, resumeDe({ ajoutes: 1 })); + assert.deepEqual(apres.participants, [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Lacasse', 'Ondine', 'Société Alpha', { courriel: 'ondine@exemple.test' }), + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 2 })); + }); + + test("le prénom et l'appartenance ne départagent que lorsque la ligne et le participant en portent tous deux un : plusieurs homonymes restants sont refusés, jamais un ajout", () => { + const existants = [ + personne(1, 'Ombrelle', 'Iris', 'Club des Merles'), + personne(2, 'Ombrelle', 'Ondine', null), + personne(3, 'Ombrelle', null, 'Chorale du Givre'), + personne(4, 'Givre', 'Silas', 'Club des Merles'), + personne(5, 'Givre', 'Anouk', 'Club des Merles'), + ]; + const { charge: apres, resume } = importer( + chargeDe({ participants: existants }), + csv( + 'nom;prenom;appartenance;courriel', + // Iris : 1 par le prénom, 3 sans prénom ; Chorale du Givre écarte 1. + 'Ombrelle;Iris;Chorale du Givre;a@exemple.test', + // Sans prénom ni appartenance : les trois Ombrelle. + 'OMBRELLE;;;b@exemple.test', + // Club des Merles écarte 3 ; Iris écarte 2 : reste 1. + 'Ombrelle;Iris;Club des Merles;c@exemple.test', + // Sans prénom : deux Givre du même club. + 'Givre;;club des merles;d@exemple.test', + ), + 'mettreAJour', + ); + assert.deepEqual(resume.refusees.map(({ ligne, code, participants }) => [ligne, code, participants]), [ + [3, 'PLUSIEURS_CORRESPONDENT', [1, 2, 3]], + [5, 'PLUSIEURS_CORRESPONDENT', [4, 5]], + ]); + assert.deepEqual(resume, resumeDe({ misAJour: 2, refusees: resume.refusees })); + assert.deepEqual(apres.participants, [ + { ...existants[0], courriel: 'c@exemple.test' }, + existants[1], + { ...existants[2], prenom: 'Iris', courriel: 'a@exemple.test' }, + existants[3], + existants[4], + ]); }); test("l'appartenance se compare par sa clé normalisée, des deux côtés : la ligne désigne l'homonyme qui l'écrit sans accents ou en capitales, et lui pose l'orthographe affichée", () => { @@ -385,6 +446,33 @@ describe('appliquerImport : mettre à jour (§ 10.1)', () => { })); }); + test("une ligne désigne parmi les valeurs d'avant l'import : ce qu'une ligne précédente pose ne l'écarte pas, et elle est refusée DEJA_DESIGNE, dans les deux ordres", () => { + // Ombrelle n'a ni prénom ni appartenance : chaque ligne la désigne, quel + // que soit le prénom ou l'appartenance que la précédente lui a donné. + const existants = [personne(1, 'Ombrelle')]; + const cas = [ + ['nom;prenom', ['Ombrelle;Iris', 'Ombrelle;Théo']], + ['nom;prenom', ['Ombrelle;Théo', 'Ombrelle;Iris']], + ['nom;appartenance', ['Ombrelle;Club des Merles', 'Ombrelle;Chorale du Givre']], + ]; + for (const [entete, [premiere, seconde]] of cas) { + const texte = csv(entete, premiere, seconde); + const apercu = apercevoir(texte, { participants: existants, mode: 'mettreAJour' }); + const { charge: apres, resume } = appliquerImport(chargeDe({ participants: existants }), apercu, 'mettreAJour'); + const [champ, valeur] = [entete.split(';')[1], premiere.split(';')[1]]; + assert.deepEqual(apres.participants, [{ ...existants[0], [champ]: valeur }], premiere); + assert.deepEqual(resume, resumeDe({ + misAJour: 1, + refusees: [{ + ligne: 3, code: 'DEJA_DESIGNE', valeur: null, participants: [1], premiereLigne: 2, brut: seconde.split(';'), + }], + }), premiere); + // L'aperçu, qui lit lui aussi les valeurs d'avant l'import, ne nomme + // aucun homonyme. + assert.deepEqual(apercu.homonymes, [], premiere); + } + }); + test('une mise à jour dont toutes les lignes sont refusées rend une charge égale à celle reçue ; les identifiants refusés, croissants', () => { const charge = chargeDe({ participants: [personne(2, 'Ombrelle', 'Iris', 'Chorale du Givre'), personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], @@ -498,6 +586,7 @@ describe('appliquerImport : la charge rendue', () => { const charge = chargeGarnie(); const apercu = apercevoir(csv('nom;prenom;courriel;exclu', 'Ombrelle;Iris;neuf@exemple.test;', 'Pervenche;;;peut-être', 'Bruyère;Anouk;;'), { participants: charge.participants, + mode, }); const avant = structuredClone({ charge, apercu }); const resultat = appliquerImport(charge, apercu, mode); @@ -583,6 +672,29 @@ describe('appliquerImport : les refus globaux (§ 10.1)', () => { ); }); + test("un aperçu calculé pour un autre mode lève TypeError : l'aperçu montré est celui que l'import applique", () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')] }); + const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles'); + for (const vu of MODES) { + const apercu = apercevoir(texte, { participants: charge.participants, mode: vu }); + for (const mode of MODES.filter((autre) => autre !== vu)) { + assert.throws(() => appliquerImport(charge, apercu, mode), { + name: 'TypeError', + message: `aperçu calculé pour ${vu}, import en ${mode}`, + }); + } + } + }); + + test("remplacer pose l'orthographe du fichier, non celle d'un participant retiré : l'aperçu et l'import s'accordent", () => { + const charge = chargeDe({ participants: [personne(1, 'Ombrelle', 'Iris', 'Club des Merles')], prochain: 2 }); + const texte = csv('nom;prenom;appartenance', 'Ombrelle;Iris;club des merles'); + const apercu = apercevoir(texte, { participants: charge.participants, mode: 'remplacer' }); + assert.deepEqual(apercu.doublons, []); + const { charge: apres } = appliquerImport(charge, apercu, 'remplacer'); + assert.deepEqual(apres.participants, [personne(2, 'Ombrelle', 'Iris', 'club des merles')]); + }); + test('un mode inconnu lève TypeError', () => { const apercu = apercevoir(csv('nom', 'Ombrelle')); for (const mode of ['fusionner', 'Ajouter', undefined, null]) { @@ -687,7 +799,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => { personne(3, 'Pervenche'), ], }); - const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants }); + const apercu = apercevoir(csv('nom', 'Ombrelle', 'Pervenche'), { participants: charge.participants, mode: 'mettreAJour' }); assert.equal(apercu.separateur, null); const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); const { texte } = decoder(exporterRefus(apercu, MOTIF, resume.refusees)); @@ -790,7 +902,7 @@ describe('exporterRefus : la forme du CSV des refus (§ 10.1)', () => { 'pervenche;theo;autre@exemple.test;', 'Sarcelle;Ondine;;peut-être', ), - { participants: charge.participants }, + { participants: charge.participants, mode: 'mettreAJour' }, ); const { resume } = appliquerImport(charge, apercu, 'mettreAJour'); assert.deepEqual(resume.refusees.map(({ ligne, code }) => [ligne, code]), [ diff --git a/src/csv/lecture.js b/src/csv/lecture.js index 80d08b5..f5afd1a 100644 --- a/src/csv/lecture.js +++ b/src/csv/lecture.js @@ -18,6 +18,11 @@ const CANDIDATS = [';', ',', '\t']; // Nombre d'enregistrements que le choix du séparateur lit. const FENETRE = 20; +// Vrai quand le caractère qui suit un guillemet fermant le laisse refermer +// le champ cité : le séparateur, une fin de ligne, ou la fin du texte. +const fermeLeChamp = (suivant, separateur) => + suivant === undefined || suivant === separateur || suivant === '\r' || suivant === '\n'; + // Parcourt texte en un seul passage et rend ses enregistrements, avec l'état // des guillemets à la fin de la lecture. La lecture s'arrête après limite // enregistrements terminés ; guillemetOuvert est alors faux, la lecture @@ -27,8 +32,14 @@ const FENETRE = 20; // - un guillemet ouvre un champ cité quand il en est le premier caractère ; // ailleurs, c'est un caractère comme un autre ; // - dans un champ cité, le séparateur et les fins de ligne font partie du -// texte, deux guillemets font un guillemet, un seul referme le champ ; ce qui -// suit le guillemet fermant, jusqu'au séparateur, reste dans le champ ; +// texte, deux guillemets font un guillemet, un seul referme le champ quand +// le séparateur, une fin de ligne ou la fin du texte le suit ; +// - un guillemet seul suivi de tout autre caractère ne referme pas le champ : +// le guillemet qui l'a ouvert est égaré, et tout ce qu'il a lu depuis +// tiendrait dans un seul champ, jusqu'à fondre plusieurs enregistrements +// en un. La lecture s'arrête là, guillemetOuvert vrai : le dernier +// enregistrement rendu est celui où le champ s'ouvre, et porte le texte lu +// jusqu'à ce guillemet ; // - CRLF, LF ou CR terminent un enregistrement, CRLF comptant pour une seule // fin ; // - une ligne vide est un enregistrement d'un seul champ vide ; la ligne vide @@ -50,8 +61,12 @@ function analyser(texte, separateur, limite) { } else if (texte[i + 1] === GUILLEMET) { champ += GUILLEMET; i += 1; - } else { + } else if (fermeLeChamp(texte[i + 1], separateur)) { cite = false; + } else { + enregistrement.push(champ); + enregistrements.push(enregistrement); + return { enregistrements, guillemetOuvert: true }; } continue; } @@ -88,9 +103,10 @@ function analyser(texte, separateur, limite) { /** * Découpe un texte en enregistrements de champs, selon RFC 4180 : champs cités, * guillemets doublés, séparateur et fins de ligne dans un champ cité, fins de - * ligne CRLF, LF ou CR. Une ligne vide est un enregistrement d'un seul champ - * vide ; la ligne vide qui suit la dernière fin de ligne ne fait pas - * d'enregistrement. Aucun blanc n'est retiré des champs. + * ligne CRLF, LF ou CR. Un champ cité se referme sur un guillemet que suivent + * le séparateur, une fin de ligne ou la fin du texte. Une ligne vide est un + * enregistrement d'un seul champ vide ; la ligne vide qui suit la dernière fin + * de ligne ne fait pas d'enregistrement. Aucun blanc n'est retiré des champs. * * Un enregistrement se désigne par son rang à partir de 1, l'en-tête compris : * le numéro de ligne qu'un tableur lui donne. Un champ cité sur plusieurs @@ -101,8 +117,10 @@ function analyser(texte, separateur, limite) { * @param {string|null} separateur un caractère, ou null pour un fichier à une * colonne, dont chaque ligne est un seul champ * @returns {{ enregistrements: string[][], guillemetOuvert: boolean }} - * guillemetOuvert est vrai quand le texte s'achève dans un champ cité : le - * dernier enregistrement porte alors le texte lu jusque-là. + * guillemetOuvert est vrai quand le texte s'achève dans un champ cité, ou + * qu'un guillemet seul y est suivi d'un autre caractère que le séparateur + * ou une fin de ligne : la découpe s'arrête alors, et le dernier + * enregistrement, celui où le champ s'ouvre, porte le texte lu jusque-là. */ export function decouper(texte, separateur) { return analyser(texte, separateur, Infinity); @@ -122,8 +140,9 @@ export function enregistrementVide(enregistrement) { // Lecture d'un texte par un candidat dans la fenêtre du choix. L'en-tête est // le premier enregistrement non vide parmi ceux que la fenêtre termine : un -// guillemet resté ouvert court jusqu'à la fin du texte, et l'enregistrement -// où il s'ouvre, le dernier lu, reste inachevé. Rend null quand l'en-tête +// guillemet resté ouvert, ou refermé sur un autre caractère que le +// séparateur ou une fin de ligne, laisse inachevé l'enregistrement où il +// s'ouvre, le dernier lu. Rend null quand l'en-tête // manque ou n'a qu'un champ : la constance seule ne suffit pas, et le candidat // n'a pas de défaut à nommer. Sinon, rend le nombre d'en-têtes reconnus et le // premier défaut qui écarte le candidat, ou null : un enregistrement non vide diff --git a/src/csv/lecture.test.js b/src/csv/lecture.test.js index 4aa3049..96726cf 100644 --- a/src/csv/lecture.test.js +++ b/src/csv/lecture.test.js @@ -183,9 +183,46 @@ describe('decouper : enregistrements et champs (§ 10.1)', () => { assert.deepEqual(enregistrementsDe('a"b;c'), [['a"b', 'c']]); }); - test("ce qui suit le guillemet fermant, jusqu'au séparateur, reste dans le champ", () => { - assert.deepEqual(enregistrementsDe('"Benoît" ;x'), [['Benoît ', 'x']]); - assert.deepEqual(enregistrementsDe('"a"b;c'), [['ab', 'c']]); + test("un guillemet fermant suivi d'autre chose qu'un séparateur, une fin d'enregistrement ou un second guillemet : la lecture s'arrête, guillemetOuvert", () => { + // Le champ cité est refusé là où son guillemet fermant est suivi d'un + // autre caractère ; le dernier enregistrement rendu est celui où le champ + // s'ouvre, et porte le texte lu jusqu'à ce guillemet. + assert.deepEqual(decouper('"Benoît" ;x', ';'), { enregistrements: [['Benoît']], guillemetOuvert: true }); + assert.deepEqual(decouper('"a"b;c', ';'), { enregistrements: [['a']], guillemetOuvert: true }); + assert.deepEqual(decouper('nom;notes\nA;"a ""b"" c"x\nB;y\n', ';'), { + enregistrements: [['nom', 'notes'], ['A', 'a "b" c']], + guillemetOuvert: true, + }); + // Sous un séparateur null, seule une fin d'enregistrement suit le + // guillemet fermant. + assert.deepEqual(decouper('nom\n"Roy, Camille";x\n', null), { + enregistrements: [['nom'], ['Roy, Camille']], + guillemetOuvert: true, + }); + }); + + test("un guillemet égaré qui avale des enregistrements : le champ se referme plus loin sur un autre caractère, la lecture s'arrête au rang où il s'ouvre", () => { + // Le guillemet qui ouvre la note de A n'est jamais refermé à sa place : + // celui de « "Bob" » le referme, suivi d'une lettre. Les enregistrements + // de B et de C ne se fondent pas dans la note de A. + const texte = 'nom;prenom;notes\r\nA;Pa;"VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé "Bob" par tous\r\nD;Pd;\r\n'; + assert.deepEqual(decouper(texte, ';'), { + enregistrements: [ + ['nom', 'prenom', 'notes'], + ['A', 'Pa', 'VIP au fond\r\nB;Pb;\r\nC;Pc;surnommé '], + ], + guillemetOuvert: true, + }); + }); + + test('un guillemet fermant suivi du séparateur, de CR, de LF, de CRLF ou de la fin du texte referme le champ', () => { + assert.deepEqual(enregistrementsDe('"a";b'), [['a', 'b']]); + assert.deepEqual(enregistrementsDe('"a"\rb'), [['a'], ['b']]); + assert.deepEqual(enregistrementsDe('"a"\nb'), [['a'], ['b']]); + assert.deepEqual(enregistrementsDe('"a"\r\nb'), [['a'], ['b']]); + assert.deepEqual(enregistrementsDe('x;"a"'), [['x', 'a']]); + assert.deepEqual(enregistrementsDe('x\t"a"\ty', '\t'), [['x', 'a', 'y']]); + assert.deepEqual(enregistrementsDe('"a"\nb', null), [['a'], ['b']]); }); test('les blancs autour des valeurs sont rendus tels quels, cités ou non', () => { @@ -397,6 +434,19 @@ describe('choisirSeparateur : le séparateur introuvable (§ 10.1)', () => { assertSeparateurIntrouvable(() => choisirSeparateur(texte, reconnaitre), ouvert(';', 2)); }); + test("un guillemet fermant suivi d'un autre caractère dans la fenêtre écarte le candidat, au rang où le champ s'ouvre", () => { + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\nBenoît;Exemple\nOdile;"Fictive"x\nRémi;Témoin\n', reconnaitre), + ouvert(';', 3), + ); + // Le champ s'ouvre au rang 2 et avale les rangs 3 et 4 avant de se + // refermer sur une lettre : le rang nommé est celui où il s'ouvre. + assertSeparateurIntrouvable( + () => choisirSeparateur('nom;prenom\nBenoît;"Exemple\nOdile;Fictive\nRémi;dit "R" ici\n', reconnaitre), + ouvert(';', 2), + ); + }); + test('un texte vide, ou fait de lignes vides', () => { assertSeparateurIntrouvable(() => choisirSeparateur('', reconnaitre)); assertSeparateurIntrouvable(() => choisirSeparateur('\r\n\r\n', reconnaitre));