gestion_table_tournante_libre/src/application/evenements.js

492 lines
20 KiB
JavaScript
Raw Normal View History

2026-10-07 04:14:24 -04:00
// © 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,
};
}