[ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to
format and name, or each component grows its own. horloge.js is the only
code that reads time and arms timers, injected so tests drive it.
traduction.js keys texts by domain, in two families of tables, raises on a
missing key and never shows a raw key; libelles/ splits the label table by
domain. format.js formats numbers and dates in French, and personnes.js
composes the displayed name the frozen labels use. Nothing persists
between sessions.
Checked: 1773 node and 43 browser tests.
--- FR ---
[ADD] application : horloge, traduction, mise en forme, nom affiché
Les écrans exigent une horloge, un mécanisme de traduction et une façon de
mettre en forme et de nommer, sans quoi chaque composant ferait les siens.
horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté
pour que les épreuves le pilotent. traduction.js range les textes par
domaine, en deux familles de tables, lève sur une clé absente et ne montre
jamais une clé brute ; libelles/ découpe la table des libellés par domaine.
format.js met en forme nombres et dates en français, et personnes.js
compose le nom affiché qu'emploient les libellés figés. Rien ne persiste
d'une séance à l'autre.
Vérifié : 1773 node et 43 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 02:33:39 -04:00
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Les textes des codes de src/stockage (§ 14.6) : le système de fichiers et le
// dépôt, les raisons d'un fichier illisible, les fautes et les raisons de
// dérive d'un placement, ce que l'ouverture annonce, le dossier de travail et
// les réglages locaux. Une raison qu'un texte ne traite pas à part ne lève
// jamais : une phrase générique la nomme, par son texte quand les tables en
// portent 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.
import {
capitale ,
causeEntreParentheses ,
cheminDe ,
cite ,
compte ,
depuisQuand ,
element ,
enumeration ,
numeros ,
ouEcrire ,
phraseDeRaison ,
} from './aides.js' ;
// La raison d'un fichier d'état illisible, en une phrase.
function raisonIllisible ( { raison , ... details } , texte ) {
if ( raison === 'ABSENT' ) return "Le fichier d'état manque, à côté de son historique." ;
return phraseDeRaison ( raison , details , texte ) ;
}
// 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 } ;
}
export const TABLE = 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 , texte ) => ` L'écriture de ${ ouEcrire ( details , texte ) } a échoué ; le fichier d'avant reste intact. ` ] ,
[ 'LECTURE' , ( details , texte ) => ` La lecture de ${ ouEcrire ( details , texte ) } 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 , texte ) => {
const sujet = typeof details . base === 'string' ? ` Le fichier d'état de ${ cite ( details . base ) } ` : 'Le fichier' ;
return ` ${ sujet } ne s'ouvre pas. ${ raisonIllisible ( details , texte ) } ${ 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 } , texte ) => {
if ( vivant === true ) {
return ` Une autre séance modifie cet événement ${ depuisQuand ( depuis , texte ) } : il reste en lecture ici. ` ;
}
if ( vivant === false ) {
return (
` Une séance interrompue a laissé son verrou ${ depuisQuand ( depuis , texte ) } , 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 , texte ) } : ` +
'il reste en lecture ici, et rien ne le reprend seul.'
) ;
} ,
] ,
[
'LECTURE_SEULE' ,
( { raison } , texte ) => {
if ( raison === 'BLOQUE' ) {
return ` ${ texte ( 'BLOQUE' ) } Rien ne peut changer ; « Débloquer » le rend de nouveau modifiable. ` ;
}
if ( raison === 'FORMAT' || raison === 'FORMAT_PLUS_RECENT' ) {
return ` ${ texte ( 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. ${ texte ( '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 , { } , texte)} L'événement reste en lecture seule, et aucun geste ne le modifie ici. ` ;
} ,
] ,
[
'ETAT_NON_ECRIT' ,
( details , texte ) => ` Le geste est inscrit à l'historique, mais l'état ${ ouEcrire ( details , texte ) } 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 } , texte ) => ` Cette commande n'est pas offerte ici ${ causeEntreParentheses ( cause , texte ) } . ` ] ,
// --- 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 } , texte ) =>
` L'historique à côté de ce fichier ne se lit pas. ${ phraseDeRaison ( raison , { } , texte)} ` +
"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 } , texte ) =>
` Le dossier data à côté de l'exécutable refuse l'écriture ${ causeEntreParentheses ( cause , texte ) } : ` +
'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" ] ,
[FIX] web storage: refuse a name Chromium did not convert, UTF-8 launch
Under a non-UTF-8 locale, Chromium returns the parent folder for an
accented folder name without raising, so a write meant for that folder
replaced a file of the same name in its parent. Each step of a path walk
now compares the handle with its parent and refuses NOM_NON_CONVERTI —
LECTURE on reads, ECRITURE on writes — and nothing lands elsewhere. The
test launcher gives Chromium LANG=C.UTF-8 when the locale is not UTF-8, as
the containers do, and the matrix installs ps where an image lacks it.
Checked: 1776 node and 45 browser tests; the guard holds under LC_ALL=C.
--- FR ---
[FIX] stockage web : refuser un nom non converti, lancement en UTF-8
Hors locale UTF-8, Chromium rend le dossier parent pour un nom de dossier
accentué, sans lever : une écriture destinée à ce dossier remplaçait un
fichier homonyme de son parent. Chaque pas d'un chemin compare désormais le
dossier obtenu à son parent et refuse NOM_NON_CONVERTI — LECTURE en lecture,
ECRITURE en écriture — : rien n'atterrit ailleurs. Le lanceur des épreuves
donne LANG=C.UTF-8 à Chromium quand la locale n'est pas UTF-8, comme les
conteneurs, et la matrice installe ps là où une image en manque.
Vérifié : 1776 node, 45 navigateur ; la garde tient sous LC_ALL=C.
Assisted-by: Claude Opus 5.5
2026-10-07 02:35:12 -04:00
// Une cause de l'OPFS : sous des paramètres régionaux qui ne sont pas
// UTF-8, le navigateur ne nomme pas un dossier ou un fichier hors ASCII.
[
'NOM_NON_CONVERTI' ,
( ) =>
"le navigateur ne sait pas nommer un dossier ou un fichier aux caractères accentués sous les réglages de langue du système" ,
] ,
[ADD] application: clock, translation, formatting, displayed name
The screens need one clock, one translation mechanism and one way to
format and name, or each component grows its own. horloge.js is the only
code that reads time and arms timers, injected so tests drive it.
traduction.js keys texts by domain, in two families of tables, raises on a
missing key and never shows a raw key; libelles/ splits the label table by
domain. format.js formats numbers and dates in French, and personnes.js
composes the displayed name the frozen labels use. Nothing persists
between sessions.
Checked: 1773 node and 43 browser tests.
--- FR ---
[ADD] application : horloge, traduction, mise en forme, nom affiché
Les écrans exigent une horloge, un mécanisme de traduction et une façon de
mettre en forme et de nommer, sans quoi chaque composant ferait les siens.
horloge.js est le seul code qui lit l'heure et arme des minuteries, injecté
pour que les épreuves le pilotent. traduction.js range les textes par
domaine, en deux familles de tables, lève sur une clé absente et ne montre
jamais une clé brute ; libelles/ découpe la table des libellés par domaine.
format.js met en forme nombres et dates en français, et personnes.js
compose le nom affiché qu'emploient les libellés figés. Rien ne persiste
d'une séance à l'autre.
Vérifié : 1773 node et 43 navigateur.
Assisted-by: Claude Opus 5.5
2026-10-07 02:33:39 -04:00
[
'PORTABLE_NON_INSCRIPTIBLE' ,
( { racine , evenements , cause } , texte ) => {
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 , texte ) } . ` ;
} ,
] ,
[
'DOCUMENTS_NON_INSCRIPTIBLE' ,
( { racine , cause } , texte ) =>
` Le dossier de travail ${ cite ( cheminDe ( racine ) ) } refuse l'écriture ${ causeEntreParentheses ( cause , texte ) } : ` +
"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 } , texte ) => {
const pourquoi =
raison === 'JSON' || raison === 'FORME' ? texte ( 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. ` ;
} ,
] ,
] ) ;