gestion_table_tournante_libre/src/application/evenements.js
Mathieu Benoit abae4fa9b8 [ADD] application: observable session, events facade, places and seats
The screens of iteration 3 need state they can subscribe to and commands
that keep the plan sound. The session follows the Svelte store contract,
unblocks a plan, cancels a gesture, keeps the framing, and orders the
warnings of a gesture. The facade lists events, settles the working folder
and runs copy, rename, import and delete. Places cover reservations, titles,
designation and the retained plan: each gesture reads seats as the display
shows them, reserved people keep their table, and a person removed frees
her chair while every other seat stays.
Checked: 1973 node, 127 browser, 60 node-long tests; coverage floors kept.

--- FR ---

[ADD] application : séance observable, façade des événements, places

Les écrans de l'itération 3 exigent un état auquel s'abonner et des
commandes qui gardent le plan sain. La séance suit le contrat des magasins
de Svelte, débloque un plan, annule un geste, garde le cadrage et ordonne
les avertissements d'un geste. La façade liste les événements, règle le
dossier de travail et mène copie, renommage, import et suppression. Les
places couvrent réservations, titres, désignation et plan retenu : chaque
geste lit les sièges comme l'écran les montre, une personne réservée garde
sa table, et une personne retirée libère sa chaise sans déplacer les autres.
Vérifié : 1973 node, 127 navigateur, 60 node-long ; seuils de couverture.

Assisted-by: Claude Opus 5.5
2026-10-07 04:14:24 -04:00

491 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// La façade des événements (§ 8.1, § 8.6, § 8.7, § 8.8, § 15) : la
// détermination du dossier de travail, une fois par séance, ses avis et ses
// questions ; la liste des événements ; les opérations de fichiers de la
// liste — créer, charger une démonstration, copier, renommer, supprimer,
// importer — et les dialogues de fichiers, relayés tels quels. Elle est, avec
// la séance, le seul écrivain que l'interface atteint.
//
// La façade ne connaît pas la séance : elle reçoit de quoi savoir quel
// événement est ouvert, l'ouvrir, le fermer, en créer un, lire et poser le
// dossier de travail, et appelle chacun au moment du geste. Une primitive du
// système de fichiers se lit sur fs à l'appel, jamais à la création : une
// épreuve la remplace après le montage.
//
// Lister ne fait que lire. Une opération qui écrit dérive ses refus lisibles
// sans écrire — nom vide, plan bloqué, format plus récent — avant toute
// écriture, sonde de verrou comprise. Renommer et supprimer ferment d'abord
// l'événement visé quand il est ouvert : la séance rend son verrou, et la
// façade sonde alors celui du disque sous son propre identifiant de séance,
// tiré une fois à sa création ; une séance vivante, ou dont la vie ne se
// prouve pas, qui tient l'événement le refuse. Un refus qui suit la fermeture
// rouvre l'événement, en lecture, tel qu'il était.
//
// Les libellés figés qu'elle écrit au journal — copie, renommage, chargement
// d'une démonstration, jalon de création, reprise — se composent par
// libelle, en français ; aucune autre chaîne visible n'en sort : refus, avis
// et questions sont des codes et leurs détails.
import { deverrouiller, lister as listerDepot, ouvrir as ouvrirDepot, verrouiller } from '../stockage/depot.js';
import { determinerDossierTravail } from '../stockage/dossier_travail.js';
import { ErreurStockage } from '../stockage/erreurs.js';
import { decoderJournal, lireJournal } from '../stockage/journal.js';
import { SUFFIXES } from '../stockage/noms.js';
import {
chargerDemonstration as chargerSurDisque,
copier as copierSurDisque,
importer,
importerDepuis,
libelleDeLInstant,
listerCorbeille,
renommer as renommerSurDisque,
supprimer as supprimerSurDisque,
} from '../stockage/operations.js';
import { lireReglages } from '../stockage/reglages_locaux.js';
import { ErreurCommande, refus } from './erreurs.js';
import { libelle } from './libelles.js';
/** Sièges par défaut et tours d'un événement neuf (§ 2.1, étapes 4 et 5). */
export const SIEGES_PAR_DEFAUT = 8;
export const TOURS_PAR_DEFAUT = 4;
// Les réponses qu'admet chaque question du dossier de travail (§ 8.6).
const REPONSES = new Map([
['PORTABLE_NON_INSCRIPTIBLE', ['reessayer', 'documents']],
['DOCUMENTS_NON_INSCRIPTIBLE', ['reessayer', 'continuer']],
]);
// Ce que rend la façade d'un verrou présent qui ne se lit pas.
const VERROU_ILLISIBLE = Object.freeze({ seance: null, depuis: null });
/**
* @typedef {import('../stockage/systeme_fichiers.js').Racine} Racine
* @typedef {import('../stockage/dossier_travail.js').DossierTravail} DossierTravail
* @typedef {{code: string, details: Object}} Avis
*/
// Les avis d'une détermination, dans cet ordre : la raison du repli en
// Documents, les avertissements de la règle, puis celui des réglages locaux
// illisibles, quand il y en a un.
function avisDe(dossier, avertissementReglages) {
const avis = [];
if (dossier.raison !== null) {
avis.push({ code: dossier.raison, details: dossier.raison === 'SONDE_ECHOUEE' ? { cause: dossier.cause } : {} });
}
for (const code of dossier.avertissements) avis.push({ code, details: {} });
if (avertissementReglages !== null) avis.push(avertissementReglages);
return avis;
}
// Un nom saisi : en NFC, ses blancs de bout retirés ; vide, NOM_REQUIS. Ce
// qui n'est pas une chaîne est une faute du code.
function nomSaisi(nom) {
if (typeof nom !== 'string') throw new TypeError(`nom : chaîne attendue, reçu ${JSON.stringify(nom)}`);
const texte = nom.normalize('NFC').trim();
if (texte === '') throw refus('NOM_REQUIS');
return texte;
}
// Le chemin affichable, en entier, du chemin relatif d'une racine : ses
// segments joints au séparateur du système.
function cheminAffiche(racine, separateur, relatif) {
const tete = racine.chemin.endsWith(separateur) ? racine.chemin : racine.chemin + separateur;
return tete + relatif.split('/').join(separateur);
}
// Un refus de la couche qui le lève : une erreur dont l'interface lit le
// code et les détails. Toute autre erreur est une faute ou une panne.
const estRefus = (erreur) => erreur instanceof ErreurStockage || erreur instanceof ErreurCommande;
// Le refus d'un événement qu'aucun geste de la liste ne doit réécrire : un
// format plus récent, puis un plan bloqué, que « Débloquer » rend
// modifiable.
function exigerModifiable({ formatPlusRecent, charge }) {
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT', remede: null });
if (charge.evenement.etat === 'bloque') {
throw new ErreurStockage('LECTURE_SEULE', { raison: 'BLOQUE', remede: { geste: 'debloquer' } });
}
}
// L'horodatage de la dernière entrée du journal de base, tel que l'horloge
// de l'application qui l'a écrite l'a noté (§ 19.4) ; null sans journal,
// sans entrée lisible, ou quand le fichier ne se lit pas. Un journal dont une
// ligne n'est pas de l'UTF-8 se lit jusqu'à elle, comme à l'ouverture.
async function derniereModification(fs, racine, base) {
if (base === null) return null;
let texte;
try {
texte = await fs.lireTexte(racine, base + SUFFIXES.journal);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
if (erreur.code !== 'LECTURE' || erreur.details.cause !== 'UTF8_INVALIDE') return null;
texte = decoderJournal(erreur.details.octets).texte;
}
if (texte === null) return null;
return lireJournal(texte).entrees.at(-1)?.horodatage ?? null;
}
// Pourquoi l'état d'un résumé illisible ne s'ouvre pas : les détails de
// l'ETAT_ILLISIBLE qu'ouvrir lève — raison, chemin, format quand il en nomme
// un, et les secours lisibles —, sans rien écrire. Un état redevenu lisible,
// ou disparu, depuis la liste du dépôt garde la raison du résumé, sans
// secours.
async function illisibleDe(fs, racine, { base, illisible }) {
if (illisible === null) return null;
try {
await ouvrirDepot(fs, racine, base);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
if (erreur.code === 'ETAT_ILLISIBLE') return { ...erreur.details };
}
return { raison: illisible, secours: null };
}
// Le .gtt.verrou de base, lu sans rien écrire : sa séance et l'instant de sa
// prise ; null quand il manque ; seance et depuis null quand il ne se lit
// pas. Seule une sonde, qui écrit, dit si sa séance vit.
async function verrouDe(fs, racine, base) {
let texte;
try {
texte = await fs.lireTexte(racine, base + SUFFIXES.verrou);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
return erreur.code === 'ABSENT' ? null : { ...VERROU_ILLISIBLE };
}
let contenu;
try {
contenu = JSON.parse(texte);
} catch {
contenu = null;
}
const lu = contenu !== null && typeof contenu === 'object' && typeof contenu.seance === 'string' && typeof contenu.depuis === 'string';
return lu ? { seance: contenu.seance, depuis: contenu.depuis } : { ...VERROU_ILLISIBLE };
}
/**
* La façade des événements, branchée par App sur la séance (I1).
*
* @param {Object} parametres
* @param {import('../stockage/systeme_fichiers.js').SystemeFichiers} parametres.fs
* @param {import('./horloge.js').Horloge} parametres.horloge
* @param {() => string} parametres.identifiant tiré une fois à la création,
* pour la séance sous laquelle la façade sonde les verrous, puis une
* fois par événement qu'elle copie ou charge
* @param {string} parametres.produitVersion
* @param {Array<{cle: string, nom: string, texte: string}>} parametres.demonstrations
* @param {() => string|null} parametres.ouvert base de l'événement ouvert
* @param {(base: string) => Promise<unknown>} parametres.ouvrir en lecture
* @param {() => Promise<unknown>} parametres.fermer
* @param {(champs: Object) => Promise<{base: string}>} parametres.creer
* celle de la séance, qui juge nom et date et ouvre l'événement créé
* @param {() => Racine|null} parametres.racine le dossier de travail
* @param {(racine: Racine) => Promise<unknown>} parametres.changerRacine
*/
export function creerFacade({
fs,
horloge,
identifiant,
produitVersion,
demonstrations,
ouvert,
ouvrir,
fermer,
creer,
racine,
changerRacine,
}) {
const seance = identifiant();
const libelleReprise = libelle('ENTREE_REPRISE');
const nomJalon = libelle('JALON_CREATION');
// resultat : la dernière détermination aboutie, {dossier, avis} ; enCours :
// la promesse de celle qui se fait, null sinon.
let resultat = null;
let enCours = null;
// La règle du § 8.6, puis les réglages locaux du dossier retenu, lus une
// fois ; la racine se pose quand aucune question n'attend. Un rejet ne
// retient rien.
async function determiner() {
const dossier = await determinerDossierTravail(fs);
const { avertissement } = await lireReglages(fs, dossier.racine);
const issue = { dossier, avis: avisDe(dossier, avertissement) };
if (dossier.question === null) await changerRacine(dossier.racine);
resultat = issue;
return issue;
}
function demarrer() {
if (resultat !== null) return Promise.resolve(resultat);
if (enCours === null) {
enCours = determiner().finally(() => {
enCours = null;
});
}
return enCours;
}
// Le dossier de travail d'une opération : la détermination en cours
// attendue ; QUESTION_EN_ATTENTE tant qu'une question attend sa réponse.
async function exigerDossier() {
if (enCours !== null) await enCours.catch(() => undefined);
const question = resultat?.dossier.question ?? null;
if (question !== null) throw refus('QUESTION_EN_ATTENTE', { question: question.code });
const courante = racine();
if (courante === null) throw new RangeError("façade : aucun dossier de travail, demarrer n'a pas abouti");
return courante;
}
async function repondre(choix) {
const question = resultat?.dossier.question ?? null;
if (question === null) throw new RangeError('repondre : aucune question du dossier de travail n’attend');
if (!REPONSES.get(question.code).includes(choix)) {
throw new RangeError(`repondre : ${JSON.stringify(choix)} ne répond pas à ${question.code}`);
}
if (choix === 'reessayer') {
resultat = null;
return demarrer();
}
const { dossier, avis } = resultat;
let importe = null;
let suite = { ...dossier, question: null };
if (choix === 'documents') {
// data/ illisible, compte null : rien à copier.
const bases = question.evenements === null ? [] : (await listerDepot(fs, question.racine)).map(({ base }) => base);
importe = await importerDepuis(fs, question.racine, dossier.racine, bases);
suite = { ...suite, autre: null };
}
await changerRacine(suite.racine);
resultat = { dossier: suite, avis };
return importe === null ? resultat : { ...resultat, importe };
}
async function lister() {
const courante = await exigerDossier();
const resumes = await listerDepot(fs, courante);
const { separateur } = await fs.emplacements();
const lignes = [];
for (const resume of resumes) {
const { base, id, doublon } = resume;
lignes.push({
base,
id,
nom: resume.nom,
date: resume.date,
etat: resume.etat,
participants: resume.participants,
derniereModification: await derniereModification(fs, courante, resume.journal),
chemin: cheminAffiche(courante, separateur, base + SUFFIXES.etat),
illisible: await illisibleDe(fs, courante, resume),
journalSeul: resume.illisible === 'ABSENT',
doublonAvec: doublon
? resumes.filter((autre) => autre !== resume && autre.doublon && autre.id === id).map((autre) => autre.base)
: [],
verrou: await verrouDe(fs, courante, base),
});
}
return lignes;
}
// Le verrou de l'événement cible pris sous la séance de la façade. Le sien,
// resté d'un rendu refusé, est déjà pris ; celui d'une séance morte
// (vivant faux) s'efface en son nom, puis se prend ; tout autre lève
// VERROU_PRIS, sans rien changer.
async function prendreVerrou(cible) {
let verrou = await verrouiller(cible, seance);
if (!verrou.pris && verrou.seance === seance) return;
if (!verrou.pris && verrou.vivant === false) {
await deverrouiller(cible, verrou.seance);
verrou = await verrouiller(cible, seance);
}
if (!verrou.pris) {
const { seance: tenue, depuis, vivant } = verrou;
throw new ErreurStockage('VERROU_PRIS', { seance: tenue, depuis, vivant, remede: null });
}
}
// Après le refus d'une opération qui a fermé l'événement base, le rouvre en
// lecture ; une panne, qui n'est pas un refus, laisse tout. Un échec de
// cette réouverture ne masque pas le refus.
async function rouvrirApresRefus(base, etaitOuvert, erreur) {
if (etaitOuvert && estRefus(erreur)) await ouvrir(base).catch(() => undefined);
}
async function creerEvenement({ nom, date = null }) {
await exigerDossier();
return creer({ nom, date, siegesParDefaut: SIEGES_PAR_DEFAUT, tours: TOURS_PAR_DEFAUT });
}
async function chargerDemonstration(cle) {
const demonstration = demonstrations.find((candidate) => candidate.cle === cle);
if (demonstration === undefined) throw new RangeError(`aucune démonstration de clé ${JSON.stringify(cle)}`);
const courante = await exigerDossier();
const charge = await chargerSurDisque(fs, courante, demonstration.texte, {
id: identifiant(),
libelle: libelle('ENTREE_DEMONSTRATION', { nom: demonstration.nom }),
nomJalon,
horodatage: horloge.horodatage(),
produitVersion,
});
await ouvrir(charge.base);
return { base: charge.base };
}
async function copier(base, { nom }) {
const texte = nomSaisi(nom);
const courante = await exigerDossier();
const source = await ouvrirDepot(fs, courante, base);
// L'instant copié, par la règle même qui l'inscrit à la filiation.
const instant = libelleDeLInstant(source, libelleReprise);
const copie = await copierSurDisque(source, {
id: identifiant(),
nom: texte,
libelle: libelle('ENTREE_COPIE', { source: source.charge.evenement.nom, instant }),
nomJalon,
horodatage: horloge.horodatage(),
produitVersion,
libelleReprise,
});
await ouvrir(copie.base);
return { base: copie.base };
}
async function renommer(base, { nom }) {
const texte = nomSaisi(nom);
const courante = await exigerDossier();
exigerModifiable(await ouvrirDepot(fs, courante, base));
const etaitOuvert = ouvert() === base;
if (etaitOuvert) await fermer();
const cible = { fs, racine: courante, base };
let renomme;
try {
await prendreVerrou(cible);
try {
// Relu sous le verrou : une autre séance a pu l'écrire depuis.
const source = await ouvrirDepot(fs, courante, base);
exigerModifiable(source);
renomme = await renommerSurDisque(source, {
nom: texte,
libelle: libelle('ENTREE_RENOMMER', { ancien: source.charge.evenement.nom, nom: texte }),
horodatage: horloge.horodatage(),
produitVersion,
libelleReprise,
});
} catch (erreur) {
await deverrouiller(cible, seance).catch(() => undefined);
throw erreur;
}
} catch (erreur) {
await rouvrirApresRefus(base, etaitOuvert, erreur);
throw erreur;
}
// Le renommage fait, un verrou qui ne se rend pas le dit sans le défaire.
const avertissements = [];
try {
await deverrouiller(cible, seance);
} catch (erreur) {
if (!(erreur instanceof ErreurStockage)) throw erreur;
avertissements.push({ code: 'VERROU_NON_RENDU', details: erreur.details });
}
if (etaitOuvert) await ouvrir(renomme.base);
return { base: renomme.base, avertissements };
}
async function supprimer(base) {
const courante = await exigerDossier();
const etaitOuvert = ouvert() === base;
if (etaitOuvert) await fermer();
try {
return await supprimerSurDisque(fs, courante, base, { horodatage: horloge.horodatage(), seance });
} catch (erreur) {
await rouvrirApresRefus(base, etaitOuvert, erreur);
if (erreur instanceof ErreurStockage && erreur.code === 'VERROU_PRIS') {
throw new ErreurStockage('VERROU_PRIS', { ...erreur.details, remede: null });
}
throw erreur;
}
}
async function corbeille() {
const courante = await exigerDossier();
const { separateur } = await fs.emplacements();
return (await listerCorbeille(fs, courante)).map(({ dossier, fichiers }) => ({
dossier,
chemin: cheminAffiche(courante, separateur, dossier),
fichiers,
}));
}
async function importerFichier() {
const courante = await exigerDossier();
const choisi = await fs.choisirFichierAImporter();
if (choisi === null) return null;
return importer(fs, courante, choisi.octets);
}
async function importerDepuisAutre() {
const vers = await exigerDossier();
const autre = resultat.dossier.autre;
if (autre === null) throw new RangeError("importerDepuisAutre : l'autre emplacement ne propose rien");
const bases = (await listerDepot(fs, autre.racine)).map(({ base }) => base);
const importe = await importerDepuis(fs, autre.racine, vers, bases);
resultat = { ...resultat, dossier: { ...resultat.dossier, autre: null } };
return importe;
}
function explorateur() {
return fs.nature === 'web'
? { disponible: false, raison: 'EXPLORATEUR_ABSENT' }
: { disponible: true, raison: null };
}
async function ouvrirExplorateur() {
if (fs.nature === 'web') throw refus('EXPLORATEUR_ABSENT');
return fs.ouvrirDansExplorateur(await exigerDossier());
}
return {
/**
* La détermination du dossier de travail, une fois par séance (§ 8.6) :
* { dossier, avis }, le même tant qu'aucune réponse ne la refait. Une
* détermination qui rejette ne retient rien, et l'appel suivant la refait.
* @returns {Promise<{dossier: DossierTravail, avis: Avis[]}>}
*/
demarrer,
/**
* La réponse à la question du dossier de travail : comme demarrer ;
* 'documents' rend aussi importe, ce que la copie de data/ a importé et
* refusé (importerDepuis). RangeError sans question, ou pour un choix
* qu'elle n'admet pas.
* @param {'reessayer'|'documents'|'continuer'} choix
*/
repondre,
/** Les lignes de la liste (§ 8.1), dans l'ordre du dépôt ; n'écrit rien. */
lister,
/** Crée et ouvre en lecture l'événement { nom, date } (§ 2.1, étape 1). */
creer: creerEvenement,
/** Charge la démonstration de clé donnée sous « ‹nom› N », ouverte en lecture (§ 15). */
chargerDemonstration,
/** Copie l'événement base sous le nom donné, et ouvre la copie en lecture (§ 8.7). */
copier,
/** Renomme l'événement base ; ouvert, il se rouvre en lecture sous sa nouvelle base (§ 8.7). */
renommer,
/** Déplace les fichiers de l'événement base dans la corbeille datée (§ 8.7). */
supprimer,
/** Le contenu de la corbeille : [{ dossier, chemin, fichiers }] (§ 8.7). */
corbeille,
/** Importe le fichier d'événement que l'opérateur choisit → { base }, ou null (§ 8.7). */
importerFichier,
/** Copie dans le dossier de travail les événements de l'autre emplacement (§ 8.6). */
importerDepuisAutre,
/** Le dialogue de choix d'un fichier, relayé : { nom, octets } ou null. */
choisirCsv: () => fs.choisirFichierAImporter(),
/** Le dialogue d'enregistrement, relayé : le chemin choisi, ou null. */
enregistrerSous: (nomPropose, octets) => fs.enregistrerSous(nomPropose, octets),
/** { disponible, raison } : raison EXPLORATEUR_ABSENT sous web (§ 8.6). */
explorateur,
/** Montre le dossier de travail dans l'explorateur du système. */
ouvrirExplorateur,
};
}