From c72215a2da3b8aa59c6dc645b64e5209f7840f04 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 7 Oct 2026 01:20:50 -0400 Subject: [PATCH] [FIX] csv: update matching, stray quotes, preview by import mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An update file without a prénom or appartenance column matched nobody who had one, and added everyone again. Prénom and appartenance now narrow the match only when both the line and the participant carry them. A quote that opened a field and closed anywhere later swallowed records and could merge two people; a closing quote not followed by a separator or a record end now refuses the file with GUILLEMET_OUVERT. The preview takes the import mode, so in replace mode no line is shown as a duplicate of someone deleted. Checked: 1519 node tests, the new cases red on the previous code. --- FR --- [FIX] csv : rapprochement, guillemet égaré, aperçu selon le mode Un fichier de mise à jour sans colonne prénom ou appartenance ne retrouvait personne qui en avait une, et ajoutait tout le monde de nouveau. Prénom et appartenance ne restreignent désormais le rapprochement que s'ils sont des deux côtés. Un guillemet qui ouvrait un champ et se fermait plus loin avalait des lignes et pouvait fondre deux personnes ; un guillemet fermant que ne suit ni séparateur ni fin d'enregistrement refuse le fichier (GUILLEMET_OUVERT). L'aperçu prend le mode : en remplacement, aucune ligne n'est un doublon d'une personne supprimée. Vérifié : 1519 épreuves node ; les nouveaux cas, rouges sur l'ancien code. Assisted-by: Claude Opus 5.5 --- src/csv/apercu.js | 162 ++++++++++++++++++++++++++++++++++++---- src/csv/apercu.test.js | 155 ++++++++++++++++++++++++++++++++++++++ src/csv/import.js | 92 ++++++++++------------- src/csv/import.test.js | 162 +++++++++++++++++++++++++++++++++------- src/csv/lecture.js | 39 +++++++--- src/csv/lecture.test.js | 56 +++++++++++++- 6 files changed, 561 insertions(+), 105 deletions(-) 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));