gestion_table_tournante_libre/src/application/libelles.js
Mathieu Benoit 8d03d5ba41 [FIX] electron: lock liveness only when certain, retries on every write
A lock judged by its pid alone took a reused pid for a live session after a
reboot, and the event stayed locked. The lock now records the process start
time; a session is dead only when that is certain — no process with the pid,
or two known start times that differ — and alive only when they match.
Otherwise liveness is unknown and the session is not taken over, so two
sessions never write at once (§ 8.8). The pause-and-retry on EPERM, EBUSY
and EACCES that guarded only the rename now guards appends, moves and
deletes too, through one helper.
Checked: 1538 node, 26 node-long tests; the macOS start time is read in UTC.

--- FR ---

[FIX] electron : vie du verrou sûre, réessais sur chaque écriture

Un verrou jugé sur son seul pid prenait un pid réattribué pour une séance
vivante après un redémarrage, et l'événement restait verrouillé. Le verrou
inscrit désormais l'heure de démarrage du processus ; une séance n'est morte
que si c'est certain — aucun processus de ce pid, ou deux heures connues qui
diffèrent — et vivante que si elles concordent. Sinon sa vie est inconnue
et la séance n'est pas reprise : deux séances n'écrivent jamais ensemble
(§ 8.8). La pause et les réessais sur EPERM, EBUSY et EACCES, qui ne
gardaient que le renommage, gardent aussi ajouts, déplacements et
suppressions, par une seule aide.
Vérifié : 1538 node, 26 node-long ; l'heure de démarrage macOS se lit en UTC.

Assisted-by: Claude Opus 5.5
2026-10-07 02:00:36 -04:00

657 lines
30 KiB
JavaScript

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La table des textes de l'application (§ 14.6) : chaque code que lèvent ou
// rendent src/stockage, src/csv et src/application — refus, avertissements,
// raisons, causes et remèdes de leurs détails — et chaque libellé figé d'une
// entrée ou d'un jalon du journal a ici son texte français, qu'une fonction
// compose de ses détails. Un code sans texte lève : l'écran ne montre jamais
// un code brut. Une valeur codée d'un détail — état, champ, mode, échec —
// que la table ne connaît pas lève aussi, au lieu de se taire. Une raison
// qu'un texte ne traite pas à part ne lève jamais : une phrase générique la
// nomme, par son texte quand la table en porte un, telle quelle sinon. Une
// cause que nomme le système, EROFS, ENOENT ou TypeMismatchError, n'est pas
// un code de l'application : les textes la citent telle quelle, entre
// parenthèses ; une cause que la table porte s'y lit par son texte.
//
// Un libellé d'entrée se compose une fois, à l'écriture du geste, et le
// journal en garde le texte, jamais le code (§ 8.2) : changer un texte ici ne
// change aucun libellé déjà écrit.
//
// Les textes ne lisent ni l'horloge ni la langue du système : un horodatage
// se met en mots à partir de sa seule forme, AAAA-MM-JJTHH:MM:SS±HH:MM.
// Deux chaînes, comparées unité UTF-16 par unité.
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const NOMS_DES_ETATS = new Map([
['brouillon', 'Brouillon'],
['propose', 'Proposé'],
['retenu', 'Retenu'],
['bloque', 'Bloqué'],
]);
// Les champs d'un participant qu'une saisie modifie, dans l'ordre du fichier.
const NOMS_DES_CHAMPS = new Map([
['nom', 'nom'],
['prenom', 'prénom'],
['appartenance', 'appartenance'],
['courriel', 'courriel'],
['titrePressenti', 'titre pressenti'],
['notes', 'notes'],
]);
const MOIS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
// Valeur d'une table de noms, ou RangeError qui la nomme.
function nomDans(table, valeur, quoi) {
if (!table.has(valeur)) throw new RangeError(`libelle : ${quoi} inconnu ${JSON.stringify(valeur)}`);
return table.get(valeur);
}
const nomEtat = (etat) => nomDans(NOMS_DES_ETATS, etat, 'état');
const nomChamp = (champ) => nomDans(NOMS_DES_CHAMPS, champ, 'champ');
// Un texte cité entre guillemets français.
const cite = (texte) => `« ${texte} »`;
// Une cause entre parenthèses, rien quand elle manque : son texte quand la
// table en porte un, sinon la cause telle que le système la nomme.
function causeEntreParentheses(cause) {
if (cause === null || cause === undefined || cause === '') return '';
return ` (${TEXTES.has(cause) ? texteDe(cause) : cause})`;
}
// Le chemin de l'élément fautif, rien quand il manque ou désigne la racine.
const element = (chemin) => (typeof chemin === 'string' && chemin !== '' ? ` (${chemin})` : '');
// Un compte et son nom, au singulier jusqu'à un, comme le veut le français.
const compte = (n, singulier, pluriel) => `${n} ${n <= 1 ? singulier : pluriel}`;
// Une énumération : « a », « a et b », « a, b et c ».
function enumeration(termes) {
if (termes.length <= 1) return termes.join('');
return `${termes.slice(0, -1).join(', ')} et ${termes.at(-1)}`;
}
// Des numéros de proposition, un illisible nommé comme tel.
const numeros = (ids) => enumeration(ids.map((id) => (id === null ? 'sans numéro lisible' : `n° ${id}`)));
// La première lettre en capitale.
const capitale = (texte) => texte.charAt(0).toUpperCase() + texte.slice(1);
// Un horodatage AAAA-MM-JJTHH:MM:SS±HH:MM en mots, « le 17 mai 2031 à 8 h 05 »,
// à l'heure qu'il porte ; une autre forme se cite telle quelle.
const HORODATAGE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):\d{2}[+-]\d{2}:\d{2}$/;
function moment(horodatage) {
const morceaux = HORODATAGE.exec(String(horodatage));
if (morceaux === null) return String(horodatage);
const [annee, mois, jour, heure, minute] = morceaux.slice(1).map(Number);
if (mois < 1 || mois > 12) return String(horodatage);
const quantieme = jour === 1 ? '1er' : String(jour);
return `le ${quantieme} ${MOIS[mois - 1]} ${annee} à ${heure} h ${String(minute).padStart(2, '0')}`;
}
const depuisQuand = (depuis) => (depuis === null || depuis === undefined ? '' : ` depuis ${moment(depuis)}`);
// Le chemin affichable d'une racine, reçue entière ou par son seul chemin.
const cheminDe = (racine) => (typeof racine === 'string' ? racine : racine?.chemin);
// Le remède commun aux refus d'encodage (§ 10.1).
const REMEDE_UTF8 = 'Réenregistrez le fichier en UTF-8 depuis le tableur, puis importez-le de nouveau.';
const refusEncodage = (constat) => () => `${constat} ${REMEDE_UTF8}`;
// Une raison codée en une phrase : son texte quand la table en porte un et
// que les détails reçus lui suffisent ; sinon une phrase générique qui la
// cite telle quelle. Un texte qui attend ses propres détails lève ou rend
// « undefined » sans eux : la phrase générique le remplace alors.
function phraseDeRaison(raison, details = {}) {
const generique = `La raison donnée est ${cite(String(raison))}.`;
if (!TEXTES.has(raison)) return generique;
try {
const texte = texteDe(raison, details);
return typeof texte === 'string' && !texte.includes('undefined') ? texte : generique;
} catch {
return generique;
}
}
// La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible({ raison, ...details }) {
if (raison === 'ABSENT') return "Le fichier d'état manque, à côté de son historique.";
return phraseDeRaison(raison, details);
}
// Les secours d'un fichier d'état illisible, quand l'erreur les nomme.
function secoursDe(secours) {
if (secours === undefined || secours === null) return '';
const offerts = [];
if (secours.precedent !== null) offerts.push(`la version précédente du fichier (révision ${secours.precedent})`);
if (secours.journal !== null) offerts.push(`le dernier instant de l'historique (révision ${secours.journal})`);
if (offerts.length === 0) return ' Aucune version de secours ne se lit.';
return ` ${capitale(enumeration(offerts))} ${offerts.length === 1 ? 'peut' : 'peuvent'} se rouvrir.`;
}
// Ce qu'une dérive touche, du retenu aux propositions, et l'accord du verbe.
function touchees({ ids, retenu }) {
const parties = [];
if (retenu) parties.push('le placement retenu');
if (ids.length === 1) parties.push(`la proposition n° ${ids[0]}`);
if (ids.length > 1) parties.push(`les propositions n° ${enumeration(ids.map(String))}`);
return { texte: capitale(enumeration(parties)), pluriel: (retenu ? 1 : 0) + ids.length > 1 };
}
// Les titres de place qu'un geste sur une personne touche, quand il y en a.
const titresDe = (n, singulier, pluriel) =>
n > 0 ? `, et ${compte(n, 'titre de place', 'titres de place')} ${n <= 1 ? singulier : pluriel}` : '';
// Ce qu'un refus d'écriture nomme : le fichier, son dossier, la cause.
const ouEcrire = ({ chemin, dossier, cause }) =>
`${cite(chemin)} dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}`;
const TEXTES = new Map([
// --- Le système de fichiers et le dépôt (src/stockage/types.js) ---------
[
'ABSENT',
({ chemin }) =>
typeof chemin === 'string' ? `Le fichier ${cite(chemin)} est introuvable.` : "Le fichier d'état manque.",
],
['ECRITURE', (details) => `L'écriture de ${ouEcrire(details)} a échoué ; le fichier d'avant reste intact.`],
['LECTURE', (details) => `La lecture de ${ouEcrire(details)} est refusée.`],
['EXISTE', ({ chemin }) => `Un fichier ${cite(chemin)} existe déjà.`],
['CHEMIN_REFUSE', ({ chemin }) => `Le chemin ${cite(String(chemin))} est refusé.`],
[
'ETAT_ILLISIBLE',
(details) => {
const sujet = typeof details.base === 'string' ? `Le fichier d'état de ${cite(details.base)}` : 'Le fichier';
return `${sujet} ne s'ouvre pas. ${raisonIllisible(details)}${secoursDe(details.secours)}`;
},
],
['CORRECTIF', ({ rang }) => `Un correctif de l'historique ne s'applique pas (opération ${rang}).`],
[
'CHEMIN_TROP_LONG',
({ racine }) =>
`Le dossier ${cite(cheminDe(racine))} est trop profond : aucun nom de fichier n'y tient ` +
'sous la longueur de chemin que Windows accepte.',
],
[
'IDENTIFIANT_PRESENT',
({ base }) =>
`Cet événement est déjà dans ce dossier, sous ${cite(base)} : ` +
"l'y placer une seconde fois en ferait deux copies de même identifiant.",
],
[
'VERROU_PRIS',
({ depuis, vivant }) => {
if (vivant === true) {
return `Une autre séance modifie cet événement${depuisQuand(depuis)} : il reste en lecture ici.`;
}
if (vivant === false) {
return (
`Une séance interrompue a laissé son verrou${depuisQuand(depuis)}, et il n'a pas pu se reprendre : ` +
"l'événement reste en lecture."
);
}
return (
`Un verrou dont la séance ne se laisse pas vérifier — autre poste, verrou illisible, ou séance de ce poste dont la vie ne se prouve pas — tient cet événement${depuisQuand(depuis)} : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
);
},
],
[
'LECTURE_SEULE',
({ raison }) => {
if (raison === 'BLOQUE') {
return `${texteDe('BLOQUE')} Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable.`;
}
if (raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT') {
return `${texteDe(raison)} Il s'ouvre en lecture seule, et aucun geste ne le modifie ici.`;
}
if (raison === 'ENCODAGE') {
return (
`Le fichier d'état a changé sur le disque. ${texteDe('ENCODAGE')} ` +
"L'événement reste en lecture seule ; rouvert par une version de secours, son premier geste " +
"range ce fichier dans la corbeille avant d'écrire l'état."
);
}
return `${phraseDeRaison(raison)} L'événement reste en lecture seule, et aucun geste ne le modifie ici.`;
},
],
[
'ETAT_NON_ECRIT',
(details) => `Le geste est inscrit à l'historique, mais l'état ${ouEcrire(details)} n'a pas pu s'écrire.`,
],
[
'CORBEILLE_SATUREE',
({ dossier }) =>
`La corbeille ${cite(dossier)} a reçu trop de suppressions dans la même seconde : ` +
'la suppression se refait une seconde plus tard.',
],
[
'COMPTEUR_SATURE',
({ compteur }) =>
`Les numéros ${compteur === 'proposition' ? 'de proposition' : `du compteur ${cite(compteur)}`} sont épuisés : ` +
'rien de neuf ne se numérote plus dans cet événement.',
],
['NON_DISPONIBLE', ({ cause }) => `Cette commande n'est pas offerte ici${causeEntreParentheses(cause)}.`],
// --- Les raisons d'un fichier illisible ----------------------------------
['VIDE', () => 'Le fichier est vide.'],
['JSON', () => "Le texte n'est pas du JSON valide."],
['FORME', ({ chemin }) => `Une valeur sort de sa règle${element(chemin)}.`],
['COMPTES', ({ chemin }) => `Un compte de l'en-tête contredit sa liste${element(chemin)}.`],
['REFERENCE', ({ chemin }) => `Une réservation ou un titre désigne ce qui n'existe pas${element(chemin)}.`],
['FORMAT_INCONNU', () => "Le format du fichier n'est pas reconnu."],
[
'FORMAT_PLUS_RECENT',
({ format }) => {
const quel = Number.isInteger(format) ? ` (format ${format})` : '';
return `Ce fichier vient d'une version plus récente du logiciel${quel}.`;
},
],
[
'ENCODAGE',
() => "Ses octets ne sont pas de l'UTF-8 : un éditeur l'a sans doute réenregistré dans un autre encodage.",
],
// --- Les fautes d'un placement, et ses raisons de dérive (§ 8.9, § 9) ----
[
'LONGUEUR',
({ tour, table, declare, ecrit }) => {
if (tour === null) return `Le placement déclare ${compte(declare, 'tour', 'tours')} et en porte ${ecrit}.`;
if (table === null) {
return `Au tour ${tour}, le placement déclare ${compte(declare, 'table', 'tables')} et en porte ${ecrit}.`;
}
return (
`Au tour ${tour}, la table ${table} reçoit ${compte(ecrit, 'personne', 'personnes')} ` +
`pour ${compte(declare, 'place', 'places')}.`
);
},
],
['DOUBLON', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée plus d'une fois.`],
[
'INCONNU',
({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} est placée sans être déclarée.`,
],
['MANQUANT', ({ tour, participant }) => `Au tour ${tour}, la personne n° ${participant} n'est placée nulle part.`],
['IDENTIFIANT_REPETE', () => 'Une autre proposition porte déjà ce numéro.'],
['IDENTIFIANT_HORS_COMPTEUR', () => 'Ce numéro de proposition dépasse le compteur du fichier.'],
['PARTICIPANT_EXCLU', ({ participant }) => `La personne n° ${participant} est exclue depuis.`],
['PARTICIPANT_SUPPRIME', ({ participant }) => `La personne n° ${participant} a été supprimée depuis.`],
[
'PARTICIPANT_NON_PLACE',
({ participant }) => `La personne n° ${participant}, ajoutée ou réintégrée depuis, n'y est pas placée.`,
],
['TABLE_SUPPRIMEE', ({ table }) => `La table d'identifiant ${table} a été supprimée depuis.`],
['TABLE_AJOUTEE', ({ table }) => `La table d'identifiant ${table} a été ajoutée depuis.`],
[
'CAPACITE_CHANGEE',
({ table, avant, maintenant }) =>
`La table d'identifiant ${table} est passée de ${avant} à ${compte(maintenant, 'place', 'places')}.`,
],
['TOURS_CHANGES', ({ avant, maintenant }) => `Le nombre de tours est passé de ${avant} à ${maintenant}.`],
// --- Ce que l'ouverture annonce (§ 8.6, § 8.8, § 8.9, § 9) ---------------
['RETENU_FAUTIF', () => 'Le placement retenu se contredit : la version précédente du fichier peut le reprendre.'],
['JOURNAL_REPRIS', () => "Le dernier geste, inscrit à l'historique mais pas dans l'état, est repris."],
[
'JOURNAL_EN_AVANCE_DE',
({ n }) =>
`L'historique compte ${compte(n, 'geste', 'gestes')} de plus que l'état : ` +
"l'état du disque est affiché, et le prochain geste inscrit une reprise.",
],
[
'JOURNAL_INCOMPLET',
({ etat, journal }) =>
`L'historique s'arrête à la révision ${journal}, l'état est à la révision ${etat} : ` +
'le prochain geste inscrit une reprise.',
],
[
'JOURNAL_ECARTEES',
({ n }) =>
`${compte(n, 'entrée', 'entrées')} de l'historique ${n <= 1 ? 'est écartée' : 'sont écartées'}, ` +
'à partir de la première ligne illisible.',
],
[
'JOURNAL_RECREE',
() =>
"Le fichier de l'historique avait disparu du dossier : il est réécrit en entier, " +
'tel que la séance le connaissait.',
],
[
'JOURNAL_FORMAT_PLUS_RECENT',
({ format }) =>
`L'historique vient d'une version plus récente du logiciel (format ${format}) : ` +
"il ne se lit pas, et l'événement reste en lecture seule.",
],
[
'JOURNAL_ILLISIBLE',
({ raison }) =>
`L'historique à côté de ce fichier ne se lit pas. ${phraseDeRaison(raison)} ` +
"Il est laissé tel quel, et le prochain geste commence l'historique dans un nouveau fichier.",
],
[
'JOURNAL_ETRANGER',
({ evenement }) =>
`L'historique à côté de ce fichier appartient à un autre événement (${evenement}) : il est ignoré.`,
],
[
'PROPOSITIONS_FAUTIVES',
({ ids }) =>
ids.length <= 1
? `Une proposition incohérente est écartée (${numeros(ids)}).`
: `${ids.length} propositions incohérentes sont écartées (${numeros(ids)}).`,
],
[
'PROPOSITIONS_MANQUANTES',
({ n }) =>
`${compte(n, "proposition que l'en-tête annonce manque", "propositions que l'en-tête annonce manquent")} ` +
'dans le fichier : celles qu\'il porte sont gardées.',
],
[
'DERIVE',
(details) => {
const { texte, pluriel } = touchees(details);
return pluriel
? `${texte} ne décrivent plus la liste ou les tables actuelles : ils sont gardés, et rien n'est effacé.`
: `${texte} ne décrit plus la liste ou les tables actuelles : il est gardé, et rien n'est effacé.`;
},
],
// --- Le dossier de travail, les réglages locaux (§ 8.5, § 8.6, § 8.8) ----
['NON_PUBLIE', () => "Le dossier de l'exécutable n'est pas connu : les événements se rangent dans les Documents."],
[
'DONNEES_APPLICATIVES',
() => "L'exécutable est rangé sous les données applicatives : les événements se rangent dans les Documents.",
],
[
'SONDE_ECHOUEE',
({ cause }) =>
`Le dossier data à côté de l'exécutable refuse l'écriture${causeEntreParentheses(cause)} : ` +
'les événements se rangent dans les Documents.',
],
// Une cause de la sonde, que les textes citent entre parenthèses.
['RELECTURE_DIFFERENTE', () => "le fichier d'essai relu ne porte pas ce qui vient d'y être écrit"],
[
'PORTABLE_NON_INSCRIPTIBLE',
({ racine, evenements, cause }) => {
const porte =
evenements === null
? 'porte peut-être des événements'
: `porte ${compte(evenements, 'événement', 'événements')}`;
return `Le dossier ${cite(cheminDe(racine))} ${porte}, mais refuse l'écriture${causeEntreParentheses(cause)}.`;
},
],
[
'DOCUMENTS_NON_INSCRIPTIBLE',
({ racine, cause }) =>
`Le dossier de travail ${cite(cheminDe(racine))} refuse l'écriture${causeEntreParentheses(cause)} : ` +
"aucun geste ne s'y enregistrerait.",
],
[
'PLATEFORME_WEB',
() =>
"Sous la plateforme web, ni le renommage atomique ni le verrou n'existent : " +
'une panne peut laisser un fichier incomplet, et deux onglets peuvent écrire le même événement.',
],
// dossier, quand il est donné, nomme le dossier où la séance écrit
// désormais (ecrireAilleurs) ; sans lui, le dossier de travail.
[
'SUPPORT_AMOVIBLE',
({ dossier }) =>
`${typeof dossier === 'string' ? `Le dossier ${cite(dossier)}` : 'Le dossier de travail'} ` +
'est sur un support amovible : retirer le support pendant une écriture peut la laisser incomplète.',
],
[
'REGLAGES_ILLISIBLES',
({ chemin, raison }) => {
const pourquoi =
raison === 'JSON' || raison === 'FORME' ? texteDe(raison) : `La lecture est refusée (${raison}).`;
return `Les réglages locaux ${cite(chemin)} ne se lisent pas. ${pourquoi} Les valeurs par défaut s'appliquent.`;
},
],
// --- Le CSV (§ 10.1) -------------------------------------------------------
['UTF16_SANS_MARQUE', refusEncodage("Le fichier semble encodé en UTF-16, sans la marque qui l'annonce.")],
['UTF16_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-16, mais ses octets ne le sont pas.")],
['UTF8_INVALIDE', refusEncodage("Le fichier s'annonce en UTF-8, mais ses octets ne le sont pas.")],
['CARACTERE_NUL', refusEncodage("Le texte porte un caractère nul, qu'aucune liste ne contient.")],
['ENREGISTRER_EN_UTF8', () => REMEDE_UTF8],
[
'SEPARATEUR_INTROUVABLE',
({ ligne, cause }) => {
const constat = 'Aucun séparateur ne convient : ni le point-virgule, ni la virgule, ni la tabulation.';
if (ligne === null || ligne === undefined) return constat;
const ecart = texteDe(cause === 'GUILLEMET_OUVERT' ? 'GUILLEMET_OUVERT' : 'NOMBRE_DE_CHAMPS', { ligne });
return `${constat} ${ecart}`;
},
],
['NOMBRE_DE_CHAMPS', ({ ligne }) => `Avec chacun, la ligne ${ligne} n'a pas le nombre de champs de l'en-tête.`],
[
'GUILLEMET_OUVERT',
({ ligne }) =>
`Un guillemet ouvert${Number.isInteger(ligne) ? ` à la ligne ${ligne}` : ''} ne se referme pas : ` +
'tout ce qui le suit tiendrait dans un seul champ.',
],
['AUCUN_ENTETE', () => "Aucun en-tête n'est reconnu : la première ligne nomme les colonnes, « nom » au moins."],
['NOM_NON_ASSOCIE', () => "Aucune colonne ne porte le nom : l'en-tête doit compter une colonne « nom »."],
['AUCUNE_LIGNE_VALIDE', () => "Aucune ligne n'est valide : rien n'est importé."],
['CHAMPS_EN_TROP', () => "La ligne compte plus de champs que l'en-tête."],
['NOM_ABSENT', () => 'Le nom manque.'],
[
'EXCLU_INCONNU',
({ valeur }) =>
`La valeur d'« exclu » ${cite(valeur)} n'est pas reconnue : ` +
'oui, o, vrai, 1 ou x pour exclure ; non, n, faux, 0 ou vide sinon.',
],
[
'PLUSIEURS_CORRESPONDENT',
({ participants }) =>
'Plusieurs personnes de la liste correspondent à cette ligne ' +
`(${enumeration(participants.map((id) => `n° ${id}`))}) : rien n'est fusionné.`,
],
[
'DEJA_DESIGNE',
({ premiereLigne }) => `La ligne ${premiereLigne} désigne déjà cette personne : rien n'est fusionné.`,
],
['PLAN_BLOQUE', () => 'Le plan est bloqué : « Débloquer » le rend de nouveau modifiable.'],
[
'AMBIGUITE',
({ champs }) =>
`Plusieurs colonnes répondent au même champ (${enumeration(champs)}) : choisissez celle qui s'importe.`,
],
// --- La séance (§ 8.3, § 8.4, § 8.5, § 8.6, § 8.8) -------------------------
['AUCUN_EVENEMENT', () => "Aucun événement n'est ouvert."],
['MODE_LECTURE', () => "L'événement est ouvert en lecture : « Modifier » permet de le changer."],
['FORMAT', (details) => texteDe('FORMAT_PLUS_RECENT', details)],
['BLOQUE', () => 'Le plan est bloqué.'],
// echec nomme ce que le support n'a pas reçu : la ligne d'un geste
// (ECRITURE), l'état d'un geste inscrit au journal (ETAT_NON_ECRIT), le
// jalon d'un geste enregistré (JALON_NON_ECRIT), ou le verrou d'un passage
// en écriture (VERROU_NON_ECRIT), qui ne perd rien.
[
'SUPPORT_ROMPU',
({ echec, ...details }) => {
const refus = `Le support ne prend plus les écritures : ${ouEcrire(details)} n'a pas pu s'écrire`;
const ailleurs = "« Écrire ailleurs » enregistre l'événement dans un autre dossier.";
if (echec === 'ECRITURE' || echec === 'ETAT_NON_ECRIT') {
const perte =
echec === 'ETAT_NON_ECRIT'
? "le dernier geste est inscrit à l'historique, mais pas dans l'état"
: "le dernier geste n'est pas enregistré";
return `${refus}, et ${perte}. Sur ce support, l'historique et l'état ne se correspondent plus ; ${ailleurs}`;
}
if (echec === 'JALON_NON_ECRIT') {
return (
`${refus}. Le dernier geste est enregistré, mais pas le jalon qui le nomme, ` +
`et aucun geste ne s'y enregistrerait plus ; ${ailleurs}`
);
}
if (echec === 'VERROU_NON_ECRIT') {
return (
`${refus}. L'événement y reste tel qu'il s'affiche, ` +
`mais aucun geste ne s'y enregistrerait ; ${ailleurs}`
);
}
throw new RangeError(`libelle : échec du support inconnu ${JSON.stringify(echec)}`);
},
],
['SUPPORT_INTACT', () => "Le support prend encore les écritures : l'événement reste où il est."],
// Quitter l'événement d'un support rompu : la séance tient la seule copie
// que l'écriture ailleurs peut encore sauver ; ETAT_NON_ECRIT dit que son
// dernier geste n'est qu'à l'historique de ce support.
[
'SUPPORT_ROMPU_NON_SAUVE',
({ echec, dossier }) => {
const geste =
echec === 'ETAT_NON_ECRIT' ? ", et son dernier geste n'y est inscrit qu'à l'historique" : '';
return (
`Le support ${cite(dossier)} ne prend plus les écritures${geste} : ` +
"l'événement ouvert n'est sauvé dans aucun autre dossier. " +
"« Écrire ailleurs » l'enregistre d'abord ; le quitter maintenant l'abandonne à ce support."
);
},
],
[
'EVENEMENT_CHANGE',
({ revision }) =>
`Une autre séance a modifié cet événement depuis son ouverture : il s'affiche tel qu'elle l'a laissé, ` +
`à la révision ${revision}, et les gestes suivants s'y ajoutent.`,
],
[
'VERROU_REPRIS',
({ depuis }) => `Le verrou qu'une séance interrompue de ce poste avait laissé${depuisQuand(depuis)} est repris.`,
],
[
'VERROU_NON_RENDU',
({ cause }) =>
`Le verrou de l'événement n'a pas pu être rendu${causeEntreParentheses(cause)} : ` +
'une autre séance de ce poste le reprendra une fois ce logiciel fermé.',
],
['RIEN_A_DEFAIRE', () => "Rien à défaire : l'historique commence à cet instant."],
['RIEN_A_REFAIRE', () => 'Rien à refaire : seul un retour se refait, tant que nul geste ne le suit.'],
['INSTANT_NON_RESTITUABLE', ({ revision }) => `L'historique ne restitue plus l'instant ${revision}.`],
['PLAN_NON_BLOQUE', () => "Le plan n'est pas bloqué : il n'y a rien à débloquer."],
[
'JALON_NON_ECRIT',
({ dossier, cause }) =>
`Le geste est enregistré, mais le jalon qui le nomme n'a pas pu s'écrire ` +
`dans le dossier ${cite(dossier)}${causeEntreParentheses(cause)}.`,
],
['SANS_EFFET', () => "Ce geste ne change rien : rien n'est enregistré."],
// --- Les refus et les avertissements des commandes (§ 4.4, § 9, § 10.2) ---
['NOM_REQUIS', () => 'Le nom est obligatoire.'],
['DATE_INVALIDE', ({ date }) => `La date ${cite(String(date))} n'est pas un jour du calendrier, écrit AAAA-MM-JJ.`],
['SIEGES_INVALIDES', () => 'Une table compte au moins deux sièges.'],
['TOURS_INVALIDES', () => 'La soirée compte au moins un tour.'],
['PARTICIPANT_INCONNU', ({ id }) => `La personne n° ${id} n'est pas dans cet événement.`],
['PROPOSITION_INCONNUE', ({ id }) => `La proposition n° ${id} n'est pas dans cet événement.`],
['AUCUNE_PROPOSITION', () => "Aucune proposition n'existe : l'état « Proposé » suit une génération."],
['AUCUN_RETENU', () => "Aucun placement n'est retenu : l'état « Retenu » suit le choix d'une proposition."],
[
'RESERVATIONS_SUSPENDUES',
({ personne, reservations, titres }) =>
`Exclusion de ${personne} : ${compte(reservations, 'réservation suspendue', 'réservations suspendues')}, ` +
`ses places reviennent aux autres${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'RESERVATIONS_RETABLIES',
({ personne, reservations, titres }) =>
`Réintégration de ${personne} : ${compte(reservations, 'réservation rétablie', 'réservations rétablies')}` +
`${titresDe(titres, 'de nouveau pourvu', 'de nouveau pourvus')}.`,
],
[
'RESERVATIONS_RETIREES',
({ personne, reservations, titres }) =>
`Suppression de ${personne} : ${compte(reservations, 'réservation retirée', 'réservations retirées')}` +
`${titresDe(titres, "n'est plus pourvu", 'ne sont plus pourvus')}.`,
],
[
'IMPORT_EN_RETENU',
() =>
"L'import change la liste après le choix d'un placement : le placement retenu est gardé tel quel, " +
"et rien n'est effacé ; à vous de juger s'il convient encore.",
],
// --- Les libellés figés du journal (§ 8.2, § 8.3) --------------------------
['ENTREE_CREATION', ({ nom }) => `Création de l'événement ${cite(nom)}`],
['JALON_CREATION', () => 'Création'],
['JALON_ETAT', ({ etat }) => `État ${cite(nomEtat(etat))}`],
['JALON_AVANT_GENERATION', () => 'Avant la génération'],
['ENTREE_REPRISE', () => "Reprise de l'état trouvé sur le disque"],
['ENTREE_DEFAIRE', ({ libelle: instant }) => `Défait : ${cite(instant)}`],
['ENTREE_REFAIRE', ({ libelle: instant }) => `Refait : ${cite(instant)}`],
['ENTREE_REVENIR', ({ libelle: instant }) => `Retour à ${cite(instant)}`],
['ENTREE_AJOUTER_PARTICIPANT', ({ personne }) => `Ajouté ${personne}`],
[
'ENTREE_MODIFIER_PARTICIPANT',
({ personne, champs, devenu }) => {
const nouveau = devenu === null || devenu === undefined ? '' : ` (désormais ${devenu})`;
return `Modifié ${personne} : ${champs.map(nomChamp).join(', ')}${nouveau}`;
},
],
['ENTREE_EXCLURE_PARTICIPANT', ({ personne }) => `Exclu ${personne}`],
['ENTREE_REINTEGRER_PARTICIPANT', ({ personne }) => `Réintégré ${personne}`],
['ENTREE_SUPPRIMER_PARTICIPANT', ({ personne }) => `Supprimé ${personne}`],
[
'ENTREE_IMPORTER_PARTICIPANTS',
({ mode, ajoutes, misAJour, retires }) => {
const ajoutesTexte = compte(ajoutes, 'ajouté', 'ajoutés');
if (mode === 'ajouter') return `Importé ${compte(ajoutes, 'participant', 'participants')}`;
if (mode === 'mettreAJour') {
return `Mis à jour la liste par import : ${ajoutesTexte}, ${compte(misAJour, 'modifié', 'modifiés')}`;
}
if (mode === 'remplacer') {
return `Remplacé la liste par import : ${compte(retires, 'retiré', 'retirés')}, ${ajoutesTexte}`;
}
throw new RangeError(`libelle : mode d'import inconnu ${JSON.stringify(mode)}`);
},
],
['ENTREE_CHANGER_ETAT', ({ etat }) => `Plan passé à l'état ${cite(nomEtat(etat))}`],
['ENTREE_DEBLOQUER', ({ etat }) => `Plan débloqué, état ${cite(nomEtat(etat))}`],
[
'ENTREE_ENREGISTRER_GENERATION',
({ n, premier, dernier }) =>
`Généré ${compte(n, 'proposition', 'propositions')} ` +
`(n° ${premier === dernier ? premier : `${premier} à ${dernier}`})`,
],
['ENTREE_RETENIR_PROPOSITION', ({ id }) => `Retenu la proposition n° ${id}`],
[
'ENTREE_EFFACER_PROPOSITIONS',
({ n, gardee }) =>
`Effacé ${compte(n, 'proposition', 'propositions')}` +
`${gardee === null || gardee === undefined ? '' : `, gardé la retenue (n° ${gardee})`}`,
],
]);
// Le texte d'un code de la table, ou RangeError qui le nomme.
function texteDe(code, details = {}) {
const texte = TEXTES.get(code);
if (texte === undefined) throw new RangeError(`libelle : code inconnu ${JSON.stringify(code)}`);
return texte(details ?? {});
}
/** Les codes qui ont un texte, triés par unités UTF-16 croissantes. */
export const CODES = Object.freeze([...TEXTES.keys()].sort(comparerTextes));
/**
* Le texte français d'un code et de ses détails (§ 14.6) : un refus, un
* avertissement, une raison, ou le libellé figé d'une entrée ou d'un jalon.
*
* @param {string} code
* @param {Object} [details]
* @returns {string}
* @throws {RangeError} un code sans texte, ou une valeur codée de ses détails
* — état, champ, mode, échec — que la table ne connaît pas ; jamais
* une raison, qu'une phrase générique nomme
*/
export function libelle(code, details = {}) {
return texteDe(code, details);
}