The working screens of an evening. The home screen lists the events of the working folder, asks its question, loads demos, copies, renames, imports and deletes, and shows what the trash holds. An open event frames its mode, offers Modifier, Débloquer and a confirmed lock takeover, and shows its warnings. Participants get a grid, a form and a file; an import goes through a preview and a report; tables, the default and tours have their screens; the plan draws tables, chairs, empty seats and names. Checked: 2342 node, 356 browser, 60 node-long; the Electron start check. --- FR --- [ADD] interface : liste, événement, participants, import, tables, plan Les écrans de travail d'une soirée. L'accueil liste les événements du dossier de travail, pose sa question, charge les démonstrations, copie, renomme, importe et supprime, et montre ce que tient la corbeille. Un événement ouvert encadre son mode, offre Modifier, Débloquer et une reprise de verrou confirmée, et montre ses avertissements. Les participants ont une grille, un formulaire et une fiche ; un import passe par un aperçu et un rapport ; tables, défaut et tours ont leurs écrans ; le plan dessine tables, chaises, sièges vides et noms. Vérifié : 2342 node, 356 navigateur, 60 node-long ; démarrage d'Electron. Assisted-by: Claude Opus 5.5
272 lines
10 KiB
JavaScript
272 lines
10 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// Le modèle de l'accueil, pur (§ 8.1, § 8.6, § 8.8) : ce que les composants
|
|
// de la liste des événements affichent, sans arithmétique ni texte. Une
|
|
// ligne de la liste (LigneListe de la façade, A3) devient ses six
|
|
// informations mises en forme et ses signalements ; une question du dossier
|
|
// de travail, ses réponses ; un état illisible, ses secours ; un refus de la
|
|
// façade, le refus affiché sur la liste. Chaque clé rendue est écrite ici en
|
|
// entier : aucun composant ne compose une clé.
|
|
|
|
// Les états du plan (§ 9) et la clé de leur nom, dans l'ordre du § 9.
|
|
const CLES_ETATS = new Map([
|
|
['brouillon', 'accueil.etat.brouillon'],
|
|
['propose', 'accueil.etat.propose'],
|
|
['retenu', 'accueil.etat.retenu'],
|
|
['bloque', 'accueil.etat.bloque'],
|
|
]);
|
|
|
|
/** Les états du plan, dans l'ordre du § 9. */
|
|
export const ETATS = Object.freeze([...CLES_ETATS.keys()]);
|
|
|
|
// La clé de la valeur absente, celle de la mise en forme.
|
|
const CLE_VIDE = 'format.vide';
|
|
|
|
// Les réponses de chaque question du dossier de travail, dans l'ordre où
|
|
// l'accueil les offre, et la clé de leur libellé : celles qu'admet
|
|
// facade.repondre (A3).
|
|
const REPONSES = new Map([
|
|
[
|
|
'PORTABLE_NON_INSCRIPTIBLE',
|
|
[
|
|
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
|
{ choix: 'documents', cle: 'accueil.question.documents' },
|
|
],
|
|
],
|
|
[
|
|
'DOCUMENTS_NON_INSCRIPTIBLE',
|
|
[
|
|
{ choix: 'reessayer', cle: 'accueil.question.reessayer' },
|
|
{ choix: 'continuer', cle: 'accueil.question.continuer' },
|
|
],
|
|
],
|
|
]);
|
|
|
|
// Les secours d'un état illisible, dans l'ordre du § 8.8 — la version
|
|
// précédente, puis le dernier instant du journal —, et la clé de la commande
|
|
// qui l'ouvre.
|
|
const SECOURS = [
|
|
{ source: 'precedent', cle: 'communs.commande.ouvrirPrecedent' },
|
|
{ source: 'journal', cle: 'communs.commande.ouvrirJournal' },
|
|
];
|
|
|
|
/**
|
|
* La clé du nom d'un état du plan ; null, celui d'un état illisible, rend
|
|
* la clé de la valeur absente.
|
|
*
|
|
* @param {string|null} etat
|
|
* @returns {string}
|
|
* @throws {RangeError} un état hors de ETATS
|
|
*/
|
|
export function cleDEtat(etat) {
|
|
if (etat === null) return CLE_VIDE;
|
|
const cle = CLES_ETATS.get(etat);
|
|
if (cle === undefined) throw new RangeError(`état du plan inconnu : ${JSON.stringify(etat)}`);
|
|
return cle;
|
|
}
|
|
|
|
/**
|
|
* Les signalements d'une ligne (§ 8.1, § 8.8), chacun { cle, details, noms }
|
|
* : la traduction compose cle et details, et noms sont les données qu'il
|
|
* nomme hors de son texte, que le composant rend chacune dans son propre
|
|
* nœud (§ 14.6). ETAT_ILLISIBLE pour un état qui ne s'ouvre pas — un journal
|
|
* sans état compris, de raison ABSENT —, avec la base, la raison et les
|
|
* secours lus par la façade, et aucun nom à part ;
|
|
* accueil.signalement.doublon pour un état dont d'autres bases portent
|
|
* l'identifiant, avec leur nombre, et ces bases pour noms, dans l'ordre de la
|
|
* façade.
|
|
* Un verrou présent ne se signale pas : seule la sonde d'une opération dit si
|
|
* sa séance vit. Les détails et les noms sont des copies.
|
|
*
|
|
* @param {Object} ligne LigneListe (A3)
|
|
* @returns {Array<{cle: string, details: Object, noms: string[]}>}
|
|
*/
|
|
export function signalementsDe(ligne) {
|
|
const signalements = [];
|
|
if (ligne.illisible !== null) {
|
|
signalements.push({ cle: 'ETAT_ILLISIBLE', details: { base: ligne.base, ...ligne.illisible }, noms: [] });
|
|
}
|
|
if (ligne.doublonAvec.length > 0) {
|
|
const noms = [...ligne.doublonAvec];
|
|
signalements.push({ cle: 'accueil.signalement.doublon', details: { autres: noms.length }, noms });
|
|
}
|
|
return signalements;
|
|
}
|
|
|
|
/**
|
|
* Les six informations d'une ligne (§ 8.1), mises en forme, et ses
|
|
* signalements. nom : celui inscrit dans le fichier, ou la base quand l'état
|
|
* ne se lit pas ; date, participants et dernière modification par format,
|
|
* la valeur absente quand ils manquent — la dernière modification est
|
|
* l'horodatage de la dernière entrée du journal, sur l'horloge de
|
|
* l'application, jamais la date du système de fichiers (§ 19.4) ; etat : la
|
|
* clé de son nom, que le composant traduit ; chemin : tel que la façade
|
|
* l'affiche, en entier.
|
|
*
|
|
* @param {Object} ligne LigneListe (A3)
|
|
* @param {ReturnType<import('../../application/format.js').creerFormat>} format
|
|
* @returns {{nom: string, date: string, etat: string, participants: string,
|
|
* modification: string, chemin: string,
|
|
* signalements: Array<{cle: string, details: Object, noms: string[]}>}}
|
|
*/
|
|
export function ligneAffichee(ligne, format) {
|
|
return {
|
|
nom: ligne.nom ?? ligne.base,
|
|
date: ligne.date === null ? format.nombre(null) : format.date(ligne.date),
|
|
etat: cleDEtat(ligne.etat),
|
|
participants: format.nombre(ligne.participants),
|
|
modification: ligne.derniereModification === null ? format.nombre(null) : format.horodatage(ligne.derniereModification),
|
|
chemin: ligne.chemin,
|
|
signalements: signalementsDe(ligne),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Les réponses qu'offre une question du dossier de travail (§ 8.6), dans
|
|
* l'ordre : [{ choix, cle }], choix tel que facade.repondre le reçoit.
|
|
*
|
|
* @param {{code: string}} question
|
|
* @returns {Array<{choix: string, cle: string}>}
|
|
* @throws {RangeError} une question inconnue
|
|
*/
|
|
export function reponsesDe(question) {
|
|
const reponses = REPONSES.get(question.code);
|
|
if (reponses === undefined) throw new RangeError(`question inconnue : ${JSON.stringify(question.code)}`);
|
|
return reponses.map((reponse) => ({ ...reponse }));
|
|
}
|
|
|
|
/**
|
|
* Les secours lisibles d'un état illisible (§ 8.8), dans l'ordre : la
|
|
* version précédente, puis le dernier instant du journal ; chacun { source,
|
|
* cle }, source telle que seance.ouvrirSecours la reçoit. Aucun secours, ou
|
|
* des secours non lus, rendent [].
|
|
*
|
|
* @param {{precedent: number|null, journal: number|null}|null|undefined} secours
|
|
* @returns {Array<{source: 'precedent'|'journal', cle: string}>}
|
|
*/
|
|
export function secoursOfferts(secours) {
|
|
if (secours === null || secours === undefined) return [];
|
|
return SECOURS.filter(({ source }) => secours[source] !== null && secours[source] !== undefined).map((offert) => ({
|
|
...offert,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Le refus d'un geste de la liste, tel que l'accueil l'affiche : le code et
|
|
* les détails de l'erreur, son remède retiré. Sur la liste, aucun événement
|
|
* n'est ouvert : un remède de la séance — débloquer, modifier, ouvrir un
|
|
* secours — n'y aurait rien sur quoi agir, et le texte du refus dit déjà ce
|
|
* qui le lève. Une erreur sans code n'est pas un refus : null.
|
|
*
|
|
* @param {unknown} erreur
|
|
* @returns {{code: string, details: Object}|null}
|
|
*/
|
|
export function refusDeLaListe(erreur) {
|
|
if (typeof erreur?.code !== 'string') return null;
|
|
return { code: erreur.code, details: { ...(erreur.details ?? {}), remede: null } };
|
|
}
|
|
|
|
/**
|
|
* Exécute une opération de la liste et rend ce qu'elle rend. Un refus
|
|
* remonte tel que la liste l'affiche (refusDeLaListe) : { code, details },
|
|
* sans remède. Toute autre erreur est une faute ou une panne, et remonte
|
|
* telle quelle.
|
|
*
|
|
* @template T
|
|
* @param {() => T|Promise<T>} operation
|
|
* @returns {Promise<T>}
|
|
*/
|
|
export async function surLaListe(operation) {
|
|
try {
|
|
return await operation();
|
|
} catch (erreur) {
|
|
const refus = refusDeLaListe(erreur);
|
|
if (refus === null) throw erreur;
|
|
throw refus;
|
|
}
|
|
}
|
|
|
|
// Le champ que désigne chaque refus d'un nom ou d'une date saisis.
|
|
const CHAMPS_DES_REFUS = new Map([
|
|
['NOM_REQUIS', 'nom'],
|
|
['DATE_INVALIDE', 'date'],
|
|
]);
|
|
|
|
/**
|
|
* Le champ contre lequel un refus se pose : 'nom' pour NOM_REQUIS, 'date'
|
|
* pour DATE_INVALIDE ; null pour tout autre refus, qui se pose contre la
|
|
* commande, et sans refus.
|
|
*
|
|
* @param {{code: string}|null} refus
|
|
* @returns {'nom'|'date'|null}
|
|
*/
|
|
export function champDuRefus(refus) {
|
|
if (refus === null) return null;
|
|
return CHAMPS_DES_REFUS.get(refus.code) ?? null;
|
|
}
|
|
|
|
// La saisie d'un nom de chaque opération qui en demande un (§ 8.7) : le
|
|
// data-champ de l'entrée, la clé de son étiquette, le data-commande et la
|
|
// clé du libellé de la commande qui valide.
|
|
const CLES_SAISIES = new Map([
|
|
[
|
|
'copier',
|
|
{
|
|
champ: 'nomCopie',
|
|
etiquette: 'accueil.operation.nomCopie',
|
|
commande: 'validerCopie',
|
|
valider: 'accueil.operation.validerCopie',
|
|
},
|
|
],
|
|
[
|
|
'renommer',
|
|
{
|
|
champ: 'nouveauNom',
|
|
etiquette: 'accueil.operation.nouveauNom',
|
|
commande: 'validerRenommage',
|
|
valider: 'accueil.operation.validerRenommage',
|
|
},
|
|
],
|
|
]);
|
|
|
|
/** Les opérations de la liste qui demandent un nom avant d'écrire. */
|
|
export const SAISIES = Object.freeze([...CLES_SAISIES.keys()]);
|
|
|
|
/**
|
|
* La saisie d'un nom pour une opération (§ 8.7) : son champ, ses clés, et le
|
|
* texte de départ — vide pour copier, qui demande un nom neuf plutôt que d'en
|
|
* empiler un « (copie) » ; le nom inscrit pour renommer, vide quand l'état
|
|
* ne se lit pas.
|
|
*
|
|
* @param {'copier'|'renommer'} operation
|
|
* @param {string|null} nom le nom inscrit dans le fichier
|
|
* @returns {{champ: string, etiquette: string, commande: string, valider: string, texte: string}}
|
|
* @throws {RangeError} une opération hors de SAISIES
|
|
*/
|
|
export function saisieDe(operation, nom) {
|
|
const cles = CLES_SAISIES.get(operation);
|
|
if (cles === undefined) throw new RangeError(`opération sans saisie : ${JSON.stringify(operation)}`);
|
|
return { ...cles, texte: operation === 'renommer' ? (nom ?? '') : '' };
|
|
}
|
|
|
|
// Les séances dont le dossier de travail a déjà été annoncé.
|
|
const ANNONCEES = new WeakSet();
|
|
|
|
/**
|
|
* Vrai la première fois qu'on le demande pour une séance, faux ensuite
|
|
* (§ 8.6) : l'accueil annonce le dossier de travail — les avis de la
|
|
* détermination — une fois par séance, et non à chaque retour à la liste.
|
|
* La séance est un objet qui vit autant qu'elle, la façade d'App ; rien ne
|
|
* survit à elle.
|
|
*
|
|
* @param {object} seance
|
|
* @returns {boolean}
|
|
* @throws {TypeError} une séance qui n'est pas un objet
|
|
*/
|
|
export function premiereAnnonce(seance) {
|
|
if (seance === null || typeof seance !== 'object') throw new TypeError('premiereAnnonce : la séance est un objet');
|
|
if (ANNONCEES.has(seance)) return false;
|
|
ANNONCEES.add(seance);
|
|
return true;
|
|
}
|