gestion_table_tournante_libre/src/interface/accueil/modele.js

273 lines
10 KiB
JavaScript
Raw Normal View History

// © 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;
}