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
661 lines
33 KiB
JavaScript
661 lines
33 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Les opérations sur les événements d'un dossier de travail (§ 8.6, § 8.7,
|
||
// § 15) : copier, renommer, importer un fichier d'état venu d'ailleurs ou les
|
||
// paires de l'autre emplacement, supprimer vers la corbeille, la lister, et
|
||
// charger une démonstration livrée. Chaque fonction reçoit le système de
|
||
// fichiers (§ 13.4) ; aucune ne lit l'horloge ni un aléa, ni ne porte de
|
||
// texte : identifiant, horodatage, version et libellés entrent en paramètre
|
||
// (§ 14.6).
|
||
//
|
||
// Ce qui s'écrit d'un événement passe par le dépôt, qui seul apparie un état
|
||
// à son journal et les aligne l'un sur l'autre : copier et charger créent
|
||
// par creer ; renommer écrit par enregistrer, en place, ou par
|
||
// enregistrerAilleurs, sous le nouveau nom ; le journal d'un événement est
|
||
// celui qu'ouvrir lui apparie, ou, faute d'état lisible, celui que lister
|
||
// lui apparie. Ce module ne fait par lui-même que recopier les octets d'un
|
||
// fichier, le déplacer ou le supprimer en entier.
|
||
//
|
||
// Un fichier d'un format plus récent que FORMAT ne se réécrit sous aucun nom
|
||
// (§ 8.8) : la charge que l'analyse en lit tait les clés qu'elle ne connaît
|
||
// pas. Copier, renommer et charger le refusent avant toute écriture ;
|
||
// importer en recopie les octets, et l'événement importé s'ouvre en lecture
|
||
// seule. Un état dont les octets ne sont pas de l'UTF-8 ne s'efface jamais :
|
||
// renommer l'Ouvert de son secours le met d'abord dans un dossier daté de la
|
||
// corbeille, comme le premier geste (mettreDeCoteEtatMalEncode, du dépôt) ;
|
||
// copier en fait un événement neuf, sans le toucher.
|
||
//
|
||
// Une base se dérive du nom de l'événement (deriverBase), libre parmi les
|
||
// bases que portent les fichiers d'événement à la racine du dossier
|
||
// (basesPrises), comme le dépôt la dérive pour creer.
|
||
import { canoniser } from './canonique.js';
|
||
import {
|
||
basesPrises,
|
||
creer,
|
||
enregistrer,
|
||
enregistrerAilleurs,
|
||
lister,
|
||
mettreDeCoteEtatMalEncode,
|
||
ouvrir,
|
||
ouvrirSecours,
|
||
} from './depot.js';
|
||
import { analyser, etatDeduit } from './document.js';
|
||
import { ErreurStockage } from './erreurs.js';
|
||
import { decoderJournal } from './journal.js';
|
||
import { DOSSIER_CORBEILLE, SUFFIXES, SUFFIXE_ECRITURE, cleSansCasse, deriverBase, dossierCorbeille } from './noms.js';
|
||
|
||
// Deux chaînes, comparées unité UTF-16 par unité.
|
||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||
|
||
|
||
// Celui des noms qui désigne le même fichier que nom : nom lui-même quand il
|
||
// y est ; sinon, quand le système ignore la casse, le premier dont la clé
|
||
// (cleSansCasse) est la sienne ; null sinon. Sur un système sensible à la
|
||
// casse, seul le nom exact désigne le fichier.
|
||
function nomSurDisque(noms, nom, insensibleCasse) {
|
||
if (noms.includes(nom)) return nom;
|
||
if (!insensibleCasse) return null;
|
||
const cle = cleSansCasse(nom);
|
||
return noms.find((autre) => cleSansCasse(autre) === cle) ?? null;
|
||
}
|
||
|
||
// Entrées du dossier de racine, triées par nom ; aucune quand il manque.
|
||
async function entreesDe(fs, racine, dossier) {
|
||
try {
|
||
return [...(await fs.lister(racine, dossier))].sort((a, b) => comparerTextes(a.nom, b.nom));
|
||
} catch (erreur) {
|
||
if (erreur.code === 'ABSENT') return [];
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
// Texte du fichier chemin, ou null quand il manque ou que ses octets ne sont
|
||
// pas de l'UTF-8 (LECTURE, cause UTF8_INVALIDE) : un texte qui ne se lit pas
|
||
// ne se recopie pas. Toute autre erreur remonte telle quelle.
|
||
async function texteSiPresent(fs, racine, chemin) {
|
||
try {
|
||
return await fs.lireTexte(racine, chemin);
|
||
} catch (erreur) {
|
||
if (erreur.code === 'ABSENT') return null;
|
||
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') return null;
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
// Texte du journal chemin tel que l'ouverture le lit : le fichier entier ;
|
||
// quand ses octets ne sont pas tous de l'UTF-8, les lignes qui précèdent la
|
||
// première qui ne se décode pas (decoderJournal), celles que la lecture
|
||
// écarterait n'étant pas recopiées ; null quand il manque, ou que sa ligne
|
||
// d'ouverture ne se décode pas. Toute autre erreur remonte telle quelle.
|
||
async function journalSiPresent(fs, racine, chemin) {
|
||
try {
|
||
return await fs.lireTexte(racine, chemin);
|
||
} catch (erreur) {
|
||
if (erreur.code === 'ABSENT') return null;
|
||
if (erreur.code === 'LECTURE' && erreur.details.cause === 'UTF8_INVALIDE') {
|
||
return decoderJournal(erreur.details.octets).texte;
|
||
}
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
// Un fichier d'un format plus récent ne se réécrit sous aucun nom (§ 8.8).
|
||
function exigerFormatCourant(formatPlusRecent) {
|
||
if (formatPlusRecent) throw new ErreurStockage('LECTURE_SEULE', { raison: 'FORMAT_PLUS_RECENT' });
|
||
}
|
||
|
||
// Une séance est une chaîne non vide : celle sous laquelle supprimer sonde
|
||
// le verrou.
|
||
function exigerSeance(seance) {
|
||
if (typeof seance !== 'string' || seance === '') {
|
||
throw new TypeError("supprimer : la séance n'est pas une chaîne non vide");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Libellé de l'instant que restitue l'Ouvert : celui de l'entrée de sa
|
||
* révision au journal ; sans elle — un état sans journal, ou que le journal
|
||
* ne rejoint pas —, libelleReprise, le texte qu'une écriture donnerait à
|
||
* l'entrée de reprise de cet état. La filiation qu'écrit copier le porte, et
|
||
* l'appelant qui compose le libellé de l'entrée de copie le lit ici, avant
|
||
* l'appel.
|
||
*
|
||
* @param {{journal: Object|null, revision: number}} ouvert
|
||
* @param {string} [libelleReprise]
|
||
* @returns {string}
|
||
* @throws {TypeError} quand il le faut et qu'il manque
|
||
*/
|
||
export function libelleDeLInstant({ journal, revision }, libelleReprise) {
|
||
const libelle = journal?.entrees.find((entree) => entree.revision === revision)?.libelle ?? libelleReprise;
|
||
if (typeof libelle !== 'string' || libelle === '') {
|
||
throw new TypeError(`copier : la révision ${revision} n'a pas d'entrée au journal, libelleReprise requis`);
|
||
}
|
||
return libelle;
|
||
}
|
||
|
||
/**
|
||
* Copie l'événement ouvert dans son dossier (§ 8.7) : un événement neuf,
|
||
* d'identifiant id et de nom nom, que le logiciel demande avant de créer.
|
||
* Sa charge est celle de l'Ouvert ; son état se déduit de son contenu
|
||
* (etatDeduit), jamais bloqué ; sa filiation inscrit la source, son
|
||
* identifiant et son nom, et l'instant copié, la révision de l'Ouvert et le
|
||
* libellé de son entrée au journal. L'historique ne se copie pas : creer
|
||
* écrit un journal d'une entrée, en instantané, au libellé libelle, et son
|
||
* jalon nomJalon, puis l'état ; sa base se dérive du nom. L'original n'est
|
||
* ni lu ni écrit : la copie part de l'Ouvert, en lecture comme en écriture.
|
||
*
|
||
* @param {import('./depot.js').Ouvert} ouvert
|
||
* @param {Object} options
|
||
* @param {string} options.id identifiant de la copie
|
||
* @param {string} options.nom nom de la copie
|
||
* @param {string} options.libelle texte figé de son entrée
|
||
* @param {string} options.nomJalon nom du jalon de sa création
|
||
* @param {string} options.horodatage AAAA-MM-JJTHH:MM:SS±HH:MM
|
||
* @param {string} options.produitVersion la construction qui écrit
|
||
* @param {string} [options.libelleReprise] libellé de l'instant copié quand
|
||
* sa révision n'a pas d'entrée au journal ; requis alors
|
||
* @returns {Promise<import('./depot.js').Ouvert>} l'Ouvert de la copie
|
||
* @throws {ErreurStockage} LECTURE_SEULE {raison: 'FORMAT_PLUS_RECENT'}, sans
|
||
* rien écrire ; ce que lève creer
|
||
* @throws {TypeError} une valeur que la lecture refuserait, libelleReprise
|
||
* manquant quand il est requis, avant toute écriture
|
||
*/
|
||
export async function copier(ouvert, { id, nom, libelle, nomJalon, horodatage, produitVersion, libelleReprise }) {
|
||
exigerFormatCourant(ouvert.formatPlusRecent);
|
||
const source = ouvert.charge.evenement;
|
||
const instant = { revision: ouvert.revision, libelle: libelleDeLInstant(ouvert, libelleReprise) };
|
||
const charge = canoniser(ouvert.charge);
|
||
charge.evenement = {
|
||
...charge.evenement,
|
||
id,
|
||
nom,
|
||
etat: etatDeduit(charge),
|
||
filiation: { source: { id: source.id, nom: source.nom }, instant },
|
||
};
|
||
return creer(ouvert.fs, ouvert.racine, charge, { libelle, nomJalon, horodatage, produitVersion });
|
||
}
|
||
|
||
// Vrai quand la base que deriver tire du nom, parmi les autres bases, tombe
|
||
// sur base à la casse près : rendre base aux bases prises fait alors passer
|
||
// la dérivation au rang suivant, ou lever CHEMIN_TROP_LONG quand ce rang ne
|
||
// tient pas sous la borne. Sinon les mêmes rangs restent pris, et la
|
||
// dérivation ne change pas. Seule deriverBase compare ainsi deux bases.
|
||
function tombeSur(deriver, autres, base) {
|
||
const libre = deriver(autres);
|
||
try {
|
||
return deriver([...autres, base]) !== libre;
|
||
} catch (erreur) {
|
||
if (erreur.code === 'CHEMIN_TROP_LONG') return true;
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
// Supprime le fichier chemin de racine. Rend vrai quand il n'y est plus, et
|
||
// faux quand le système de fichiers refuse de l'effacer, par une
|
||
// ErreurStockage, qui ne lève pas. Toute autre erreur remonte.
|
||
async function effacerSiPossible(fs, racine, chemin) {
|
||
try {
|
||
await fs.supprimer(racine, chemin);
|
||
return true;
|
||
} catch (erreur) {
|
||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Efface ce que la recopie a écrit sous base avant un refus : le journal
|
||
// quand journal le dit, puis le .precedent, et lui seulement une fois le
|
||
// journal parti. Un journal resté garde ainsi son .precedent, qui le tient à
|
||
// la nouvelle base : sans lui, l'ancien état pourrait s'apparier ce journal
|
||
// (apparier, dans le dépôt).
|
||
async function effacerRecopie(fs, racine, base, { journal }) {
|
||
if (journal && !(await effacerSiPossible(fs, racine, base + SUFFIXES.journal))) return;
|
||
await effacerSiPossible(fs, racine, base + SUFFIXES.precedent);
|
||
}
|
||
|
||
// La nouvelle paire de l'Ouvert, que le dépôt écrit dans son dossier sous la
|
||
// base que dérive le nom de la charge écrite (enregistrerAilleurs, avec le
|
||
// geste d'options) : l'état d'avant au .precedent, puis le journal entier
|
||
// suivi de l'entrée du geste, puis l'état. Le dossier ne se crée pas : un
|
||
// dossier de travail disparu ne renaît pas (§ 8.6). Le .precedent, écrit
|
||
// avant le journal, fait de la nouvelle base celle de l'événement : le
|
||
// journal neuf ne s'apparie qu'à elle, jamais à l'ancien état, que le journal
|
||
// de l'ancienne base soit le sien ou non, et lister le tait tant que l'ancien
|
||
// état se lit.
|
||
//
|
||
// Un refus avant l'état laisse l'ancienne paire seule à porter l'événement,
|
||
// et ce que la recopie a écrit avant lui s'efface (effacerRecopie) : sur un
|
||
// journal refusé, que nomme le chemin de l'ECRITURE, le .precedent ; sur un
|
||
// état refusé, le journal, puis le .precedent. L'ECRITURE d'un journal
|
||
// remonte telle quelle ; le refus de l'état remonte en ECRITURE {chemin,
|
||
// dossier, cause}, sans Ouvert : celui de l'appelant décrit encore le disque.
|
||
// Toute autre erreur remonte telle quelle.
|
||
async function ecrireNouvellePaire(ouvert, options) {
|
||
const { fs, racine } = ouvert;
|
||
try {
|
||
return await enregistrerAilleurs(ouvert, racine, { ...options, existantes: [], creerRacine: false });
|
||
} catch (erreur) {
|
||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||
if (erreur.code === 'ETAT_NON_ECRIT') {
|
||
const { chemin, dossier, cause, ouvert: neuve } = erreur.details;
|
||
await effacerRecopie(fs, racine, neuve.base, { journal: true });
|
||
throw new ErreurStockage('ECRITURE', { chemin, dossier, cause });
|
||
}
|
||
const { chemin } = erreur.details;
|
||
if (erreur.code === 'ECRITURE' && chemin.endsWith(SUFFIXES.journal)) {
|
||
await effacerRecopie(fs, racine, chemin.slice(0, -SUFFIXES.journal.length), { journal: false });
|
||
}
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
// Supprime l'ancienne paire de l'Ouvert, la nouvelle étant complète : son
|
||
// état d'abord, sans lequel lister ne la montre plus, la nouvelle portant
|
||
// son identifiant ; puis son .precedent et le journal de l'événement
|
||
// (baseJournal), jamais celui d'un autre ; un fichier absent n'est pas une
|
||
// faute. Une ErreurStockage reçoit dans ses détails ouvert, l'Ouvert de la
|
||
// nouvelle paire, d'où continuer, puis remonte : celui que tient l'appelant
|
||
// décrit l'ancienne, et un geste parti de lui ferait diverger deux paires du
|
||
// même événement.
|
||
async function supprimerAncienne(ouvert, nouvelle) {
|
||
const { fs, racine, base, baseJournal } = ouvert;
|
||
const chemins = [base + SUFFIXES.etat, base + SUFFIXES.precedent];
|
||
if (baseJournal !== null) chemins.push(baseJournal + SUFFIXES.journal);
|
||
try {
|
||
for (const chemin of chemins) await fs.supprimer(racine, chemin);
|
||
} catch (erreur) {
|
||
if (erreur instanceof ErreurStockage) erreur.details.ouvert = nouvelle;
|
||
throw erreur;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Renomme l'événement ouvert (§ 8.7) : nom devient le nom de sa charge, par
|
||
* un geste au libellé libelle. La base du nouveau nom se dérive, libre dans
|
||
* le dossier. Quand elle tomberait sur la base de l'événement, ses fichiers
|
||
* mis à part — un nom qui ne diffère que par un caractère que Windows
|
||
* refuse, ou par la casse —, le renommage est ce seul geste, en place
|
||
* (enregistrer). Sinon, le logiciel écrit d'abord la paire complète sous la
|
||
* nouvelle base — l'état d'avant au .precedent, le journal recopié suivi de
|
||
* l'entrée du renommage, puis l'état —, puis supprime l'ancienne : son état,
|
||
* son .precedent et le journal de l'événement, jamais l'inverse. Rien de
|
||
* l'ancienne paire ne s'écrit, et le .precedent neuf, posé avant le journal
|
||
* neuf, tient ce journal à la nouvelle base : quel que soit le journal de
|
||
* l'ancienne base — le sien, aucun pour un état importé, un illisible, celui
|
||
* d'un autre événement, le sien rangé sous une autre base —, l'ancienne
|
||
* paire, tant qu'elle existe, s'ouvre sous l'ancien nom, à sa révision, ses
|
||
* octets intacts. Une coupure laisse l'ancienne paire, les deux, ou la
|
||
* nouvelle, jamais aucune. Le journal d'un autre événement, que porterait
|
||
* l'ancienne base, ne se touche pas ; le verrou non plus, que la séance
|
||
* déplace.
|
||
*
|
||
* L'Ouvert d'un secours dont l'état sur le disque n'est pas de l'UTF-8 met
|
||
* d'abord cet état dans un dossier daté de la corbeille, à l'horodatage du
|
||
* renommage (mettreDeCoteEtatMalEncode) : ni la recopie ni la suppression de
|
||
* l'ancienne paire ne le remplacent ni ne l'effacent, et ses octets restent.
|
||
*
|
||
* Un nom inchangé, les blancs de ses deux bouts mis à part — deriverBase
|
||
* les retire aussi —, ne touche à rien : aucune entrée, aucune recopie,
|
||
* aucune suppression, et l'Ouvert reçu revient, même quand le nom dériverait
|
||
* aujourd'hui une autre base que la sienne. Une entrée est exactement ce qui
|
||
* change un fichier d'événement (§ 8.2), et garder son nom n'est pas un geste.
|
||
*
|
||
* Un refus avant que la nouvelle paire soit complète laisse l'ancienne
|
||
* intacte, et l'Ouvert reçu d'où continuer ; ce que la recopie a écrit
|
||
* s'efface quand le support le permet (ecrireNouvellePaire). Un refus
|
||
* pendant la suppression de l'ancienne porte dans ses détails ouvert,
|
||
* l'Ouvert de la nouvelle paire, d'où continuer : l'Ouvert reçu ne décrit
|
||
* plus que des fichiers en partance.
|
||
*
|
||
* @param {import('./depot.js').Ouvert} ouvert en écriture
|
||
* @param {Object} options
|
||
* @param {string} options.nom
|
||
* @param {string} options.libelle texte figé de l'entrée
|
||
* @param {string} options.horodatage
|
||
* @param {string} options.produitVersion
|
||
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
||
* requis quand le journal ne finit pas sur la charge ouverte
|
||
* @returns {Promise<import('./depot.js').Ouvert>} l'Ouvert renommé, sous la
|
||
* base que le nom dérive ; l'Ouvert reçu quand le nom ne change pas
|
||
* @throws {ErreurStockage} sans rien écrire : LECTURE_SEULE {raison:
|
||
* 'FORMAT_PLUS_RECENT'}, CHEMIN_TROP_LONG, ABSENT {chemin: ''} quand
|
||
* le dossier n'existe plus ; en place, ce que lève enregistrer ;
|
||
* ce que lève la mise de côté d'un état aux octets hors UTF-8
|
||
* (mettreDeCoteEtatMalEncode), l'ancienne paire sinon intacte ;
|
||
* ECRITURE {chemin, dossier, cause} du .precedent, du journal ou de
|
||
* l'état de la nouvelle paire, l'ancienne intacte ; ECRITURE {chemin,
|
||
* dossier, cause, ouvert} de la suppression de l'ancienne
|
||
* @throws {TypeError} une valeur que la lecture refuserait, avant toute
|
||
* écriture
|
||
*/
|
||
export async function renommer(ouvert, { nom, libelle, horodatage, produitVersion, libelleReprise }) {
|
||
exigerFormatCourant(ouvert.formatPlusRecent);
|
||
if (nom.trim() === ouvert.charge.evenement.nom.trim()) return ouvert;
|
||
const { fs, racine, base } = ouvert;
|
||
const autres = (await basesPrises(fs, racine)).filter((autre) => autre !== base);
|
||
const { separateur } = await fs.emplacements();
|
||
const deriver = (existantes) => deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||
const apres = { ...ouvert.charge, evenement: { ...ouvert.charge.evenement, nom } };
|
||
if (tombeSur(deriver, autres, base)) {
|
||
return enregistrer(ouvert, apres, { libelle, horodatage, produitVersion, libelleReprise });
|
||
}
|
||
const geste = { apres, libelle, produitVersion };
|
||
const depart = await mettreDeCoteEtatMalEncode(ouvert, horodatage);
|
||
const nouvelle = await ecrireNouvellePaire(depart, { geste, libelleReprise, horodatage });
|
||
await supprimerAncienne(depart, nouvelle);
|
||
return nouvelle;
|
||
}
|
||
|
||
// Décodage strict : une suite d'octets qui n'est pas de l'UTF-8 lève, et la
|
||
// marque d'ordre d'octets reste dans le texte. Le texte décodé se réencode
|
||
// ainsi en UTF-8 sur exactement les octets reçus.
|
||
const UTF8_STRICT = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
||
|
||
// Texte des octets d'un fichier d'état. Des octets qui ne sont pas de l'UTF-8
|
||
// ne sont pas un texte : ETAT_ILLISIBLE, raison ENCODAGE, comme le dépôt le
|
||
// lève d'un état du dossier aux octets hors UTF-8.
|
||
function decoder(octets) {
|
||
if (!(octets instanceof Uint8Array)) throw new TypeError('importer : les octets du fichier sont un Uint8Array');
|
||
try {
|
||
return UTF8_STRICT.decode(octets);
|
||
} catch {
|
||
throw new ErreurStockage('ETAT_ILLISIBLE', { raison: 'ENCODAGE', chemin: null });
|
||
}
|
||
}
|
||
|
||
// Écrit dans racine l'événement id, nom : ses fichiers, tels quels, chacun
|
||
// quand il est donné, sous une base libre que nom dérive — le .precedent
|
||
// d'abord, qui tient la base à l'événement (apparier, dans le dépôt), puis
|
||
// le journal, puis l'état. Rend cette base. IDENTIFIANT_PRESENT quand lister
|
||
// nomme id dans le dossier, que son état se lise ou non : l'événement y est
|
||
// déjà, et une seconde copie de son identifiant dériverait (§ 8.6).
|
||
async function deposer(fs, racine, { id, nom, texteEtat, textePrecedent = null, texteJournal }) {
|
||
const present = (await lister(fs, racine)).find((resume) => resume.id === id);
|
||
if (present !== undefined) throw new ErreurStockage('IDENTIFIANT_PRESENT', { id, base: present.base });
|
||
const { separateur } = await fs.emplacements();
|
||
const existantes = await basesPrises(fs, racine);
|
||
const base = deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||
if (textePrecedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, textePrecedent);
|
||
if (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
||
if (texteEtat !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.etat, texteEtat);
|
||
return base;
|
||
}
|
||
|
||
/**
|
||
* Importe un fichier d'état venu d'ailleurs (§ 8.7) : ses octets, lus en
|
||
* UTF-8, se copient tels quels — marque d'ordre d'octets, fins de ligne et
|
||
* mise en page comprises — sous une base libre que dérive le nom qu'il
|
||
* porte. Aucun journal ne s'écrit, et rien ne se convertit : un fichier d'un
|
||
* format plus récent, que l'analyse lit réduit à ce qu'elle connaît, s'ouvre
|
||
* ensuite en lecture seule.
|
||
*
|
||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||
* @param {Uint8Array} octets
|
||
* @returns {Promise<{base: string}>}
|
||
* @throws {ErreurStockage} ETAT_ILLISIBLE {raison, chemin, [format]} de
|
||
* l'analyse, raison ENCODAGE pour des octets hors UTF-8 ; ni base ni
|
||
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
|
||
* base} quand lister nomme son identifiant dans le dossier, que
|
||
* l'état qui le porte se lise ou non ;
|
||
* CHEMIN_TROP_LONG ; ECRITURE. Rien ne s'écrit avant l'écriture de
|
||
* l'état
|
||
* @throws {TypeError} des octets qui ne sont pas un Uint8Array
|
||
*/
|
||
export async function importer(fs, racine, octets) {
|
||
const texte = decoder(octets);
|
||
const { id, nom } = analyser(texte).charge.evenement;
|
||
return { base: await deposer(fs, racine, { id, nom, texteEtat: texte, texteJournal: null }) };
|
||
}
|
||
|
||
// Les fichiers de l'événement de base dans depuis, dont l'état ne se lit
|
||
// pas, recopiés tels quels dans vers (deposer) : le .precedent et l'état,
|
||
// chacun quand il existe et que ses octets sont de l'UTF-8, et le journal
|
||
// que le secours source apparie, par ses lignes lisibles (journalSiPresent). Le secours source — 'precedent' ou 'journal'
|
||
// (ouvrirSecours) — donne l'identifiant et le nom. Rend la base dans vers.
|
||
async function importerSecours(fs, depuis, vers, base, source) {
|
||
const { charge, baseJournal } = await ouvrirSecours(fs, depuis, base, source);
|
||
const { id, nom } = charge.evenement;
|
||
const lire = (chemin) => texteSiPresent(fs, depuis, chemin);
|
||
const journal = baseJournal === null ? null : baseJournal + SUFFIXES.journal;
|
||
return deposer(fs, vers, {
|
||
id,
|
||
nom,
|
||
textePrecedent: await lire(base + SUFFIXES.precedent),
|
||
texteJournal: journal === null ? null : await journalSiPresent(fs, depuis, journal),
|
||
texteEtat: await lire(base + SUFFIXES.etat),
|
||
});
|
||
}
|
||
|
||
// Importe l'événement de base de depuis dans vers (importerDepuis) et rend
|
||
// sa base là-bas. Un état illisible qui a un secours lisible s'importe par
|
||
// ses fichiers (importerSecours), le .precedent pour source quand il se lit ;
|
||
// sans secours, son refus remonte.
|
||
async function importerUn(fs, depuis, vers, base) {
|
||
let ouvert;
|
||
try {
|
||
ouvert = await ouvrir(fs, depuis, base);
|
||
} catch (erreur) {
|
||
if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur;
|
||
const { precedent, journal } = erreur.details.secours;
|
||
if (precedent === null && journal === null) throw erreur;
|
||
return importerSecours(fs, depuis, vers, base, precedent === null ? 'journal' : 'precedent');
|
||
}
|
||
const { texteEtat, baseJournal } = ouvert;
|
||
const { id, nom } = analyser(texteEtat).charge.evenement;
|
||
const texteJournal = baseJournal === null ? null : await journalSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
||
return deposer(fs, vers, { id, nom, texteEtat, texteJournal });
|
||
}
|
||
|
||
/**
|
||
* Importe des événements de l'autre emplacement (§ 8.6) : pour chaque base
|
||
* de depuis, dans l'ordre reçu, l'état et le journal qu'ouvrir lui apparie
|
||
* se copient tels quels dans vers, le journal d'abord, sous une base libre
|
||
* que dérive le nom que porte l'état. Le .precedent ne se copie pas. Un
|
||
* événement dont l'état ne se lit pas, mais dont le .precedent ou le journal
|
||
* se lisent (§ 8.8), se copie par ses fichiers, tels quels : le .precedent,
|
||
* le journal que son secours apparie, et l'état, chacun quand ses octets
|
||
* sont de l'UTF-8 ; il s'ouvre là-bas en secours. Un journal dont une ligne
|
||
* n'est pas de l'UTF-8 se copie par les lignes qui la précèdent, celles que
|
||
* l'ouverture lit (journalSiPresent). Les originaux restent où
|
||
* ils sont, sans qu'aucune écriture ne touche depuis. Une base qui ne
|
||
* s'importe pas se refuse par son code — ETAT_ILLISIBLE sans secours
|
||
* lisible, ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les
|
||
* suivantes ; l'écriture refusée d'un fichier laisse dans vers ceux déjà
|
||
* écrits.
|
||
*
|
||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||
* @param {import('./systeme_fichiers.js').Racine} depuis
|
||
* @param {import('./systeme_fichiers.js').Racine} vers
|
||
* @param {Iterable<string>} bases bases de depuis
|
||
* @returns {Promise<{importes: Array<{base: string, nouvelle: string}>, refuses: Array<{base: string, code: string}>}>}
|
||
* importes : chaque base importée et sa base dans vers ; refuses :
|
||
* chaque base refusée et le code de son refus ; l'un et l'autre
|
||
* dans l'ordre des bases reçues. Une erreur qui n'est pas une
|
||
* ErreurStockage remonte telle quelle
|
||
*/
|
||
export async function importerDepuis(fs, depuis, vers, bases) {
|
||
const importes = [];
|
||
const refuses = [];
|
||
for (const base of bases) {
|
||
try {
|
||
importes.push({ base, nouvelle: await importerUn(fs, depuis, vers, base) });
|
||
} catch (erreur) {
|
||
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||
refuses.push({ base, code: erreur.code });
|
||
}
|
||
}
|
||
return { importes, refuses };
|
||
}
|
||
|
||
// Base du journal de l'événement de base : celle qu'ouvrir lui apparie quand
|
||
// son état se lit ; sinon celle que lister lui apparie par l'identifiant de
|
||
// son .precedent, ou du journal sans état, le résumé de base se trouvant
|
||
// comme un nom de fichier (nomSurDisque) ; null quand aucun ne lui revient.
|
||
// Le journal d'un autre événement n'est jamais le sien. Sur un système qui
|
||
// ignore la casse, la base rendue peut différer par la casse du nom du
|
||
// journal sur le disque.
|
||
async function baseDuJournal(fs, racine, base, insensibleCasse) {
|
||
try {
|
||
return (await ouvrir(fs, racine, base)).baseJournal;
|
||
} catch (erreur) {
|
||
if (erreur.code === 'ABSENT') return null;
|
||
if (erreur.code !== 'ETAT_ILLISIBLE') throw erreur;
|
||
}
|
||
const resumes = await lister(fs, racine);
|
||
const trouvee = nomSurDisque(resumes.map((resume) => resume.base), base, insensibleCasse);
|
||
return resumes.find((resume) => resume.base === trouvee)?.journal ?? null;
|
||
}
|
||
|
||
/**
|
||
* Supprime l'événement de base (§ 8.7) : ses fichiers passent dans un
|
||
* dossier neuf de la corbeille, daté de l'horodatage (dossierCorbeille),
|
||
* créé d'abord — _2, _3… quand la seconde a déjà le sien. Partent l'état, le
|
||
* .precedent, le journal de l'événement (baseDuJournal), jamais celui d'un
|
||
* autre, le verrou, et chacun de leurs fichiers d'écriture atomique restés ;
|
||
* ceux-ci d'abord, puis le verrou, le journal, le .precedent, et l'état en
|
||
* dernier. Sur un système de fichiers qui ignore la casse, un fichier se
|
||
* reconnaît à la clé de son nom, la majuscule de sa NFC, comme deriverBase
|
||
* compare les bases, et part sous son nom sur le disque : un journal, un
|
||
* .precedent ou un verrou d'une autre casse est le même fichier. Sur un
|
||
* système sensible à la casse, seul le nom exact compte. Chaque déplacement
|
||
* porte un fichier entier : une coupure laisse
|
||
* chacun à sa place ou dans la corbeille, jamais perdu, et l'événement se
|
||
* liste encore tant que son état n'est pas parti. Rien ne vide la corbeille.
|
||
*
|
||
* Une autre séance qui tient l'événement en écriture y écrit encore : avant
|
||
* tout déplacement, supprimer sonde le verrou en le prenant sous seance, la
|
||
* séance qui supprime (§ 8.8). Un verrou d'une séance vivante, d'un autre
|
||
* poste ou illisible — vivant n'y est pas faux — lève VERROU_PRIS, sans rien
|
||
* déplacer. Le verrou d'une séance morte, celui de seance, ou celui que la
|
||
* sonde vient de prendre part avec le reste ; un refus qui suit la sonde
|
||
* rend le verrou qu'elle a pris. Là où le système n'a pas de verrou
|
||
* (verrouDisponible faux), la sonde ne pose rien.
|
||
*
|
||
* Le dossier daté se crée sous la racine, que creerDossier ne crée pas : un
|
||
* dossier de travail qui disparaît pendant la suppression ne renaît pas
|
||
* (§ 8.6). Disparu avant la création du dossier daté, il fait lever
|
||
* ECRITURE, cause ENOENT ; après, le déplacement du premier fichier qui n'est
|
||
* plus là lève ABSENT.
|
||
*
|
||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||
* @param {string} base
|
||
* @param {{horodatage: string, seance: string}} options horodatage
|
||
* AAAA-MM-JJTHH:MM:SS±HH:MM ; seance, la séance qui supprime
|
||
* @returns {Promise<{dossier: string}>} chemin relatif du dossier daté
|
||
* @throws {ErreurStockage} ABSENT {chemin} quand l'événement n'a aucun
|
||
* fichier, CORBEILLE_SATUREE, l'un et l'autre sans rien écrire ;
|
||
* VERROU_PRIS {seance, depuis, vivant}, sans rien déplacer ;
|
||
* ECRITURE de la création du dossier daté ou d'un déplacement
|
||
* refusé ; EXISTE ; ABSENT {chemin} d'un fichier qui disparaît
|
||
* pendant les déplacements
|
||
* @throws {TypeError} un horodatage hors de sa forme, une séance qui n'est
|
||
* pas une chaîne non vide, sans rien écrire
|
||
*/
|
||
export async function supprimer(fs, racine, base, { horodatage, seance }) {
|
||
exigerSeance(seance);
|
||
const { insensibleCasse } = await fs.emplacements();
|
||
const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
|
||
const verrou = base + SUFFIXES.verrou;
|
||
const fichiers = [
|
||
verrou,
|
||
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
||
base + SUFFIXES.precedent,
|
||
base + SUFFIXES.etat,
|
||
];
|
||
// Les noms des fichiers du dossier, triés ; ceux de l'événement parmi eux,
|
||
// sous leur nom sur le disque, dans l'ordre de leur départ.
|
||
const nomsSurDisque = async () =>
|
||
(await fs.lister(racine, ''))
|
||
.filter(({ type }) => type === 'fichier')
|
||
.map(({ nom }) => nom)
|
||
.sort(comparerTextes);
|
||
const partantsParmi = (presents) =>
|
||
[...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
|
||
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
|
||
.filter((nom) => nom !== null);
|
||
let presents = await nomsSurDisque();
|
||
if (partantsParmi(presents).length === 0) throw new ErreurStockage('ABSENT', { chemin: base + SUFFIXES.etat });
|
||
const existants = (await entreesDe(fs, racine, DOSSIER_CORBEILLE)).map(({ nom }) => nom);
|
||
const dossier = dossierCorbeille(horodatage, existants);
|
||
// La sonde vise le verrou sous son nom sur le disque, celui qui part.
|
||
const nomDuVerrou = nomSurDisque(presents, verrou, insensibleCasse) ?? verrou;
|
||
const sonde = await fs.verrouiller(racine, nomDuVerrou, seance);
|
||
if (!sonde.pris && sonde.seance !== seance && sonde.vivant !== false) {
|
||
throw new ErreurStockage('VERROU_PRIS', { seance: sonde.seance, depuis: sonde.depuis, vivant: sonde.vivant });
|
||
}
|
||
try {
|
||
if (sonde.pris) presents = await nomsSurDisque();
|
||
await fs.creerDossier(racine, dossier);
|
||
for (const nom of partantsParmi(presents)) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
|
||
} catch (erreur) {
|
||
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
|
||
if (sonde.pris) await fs.deverrouiller(racine, nomDuVerrou, seance).catch(() => {});
|
||
throw erreur;
|
||
}
|
||
return { dossier };
|
||
}
|
||
|
||
/**
|
||
* Le contenu de la corbeille de racine (§ 8.7), sans rien écrire : un
|
||
* élément par dossier daté, son chemin relatif et les noms de ses fichiers,
|
||
* les dossiers et les fichiers triés par nom. Une corbeille absente est
|
||
* vide ; ce qui n'est pas un dossier daté ne s'y liste pas.
|
||
*
|
||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||
* @returns {Promise<Array<{dossier: string, fichiers: string[]}>>}
|
||
*/
|
||
export async function listerCorbeille(fs, racine) {
|
||
const contenu = [];
|
||
for (const { nom, type } of await entreesDe(fs, racine, DOSSIER_CORBEILLE)) {
|
||
if (type !== 'dossier') continue;
|
||
const dossier = `${DOSSIER_CORBEILLE}/${nom}`;
|
||
const entrees = await entreesDe(fs, racine, dossier);
|
||
const fichiers = entrees.filter((entree) => entree.type === 'fichier').map((entree) => entree.nom);
|
||
contenu.push({ dossier, fichiers });
|
||
}
|
||
return contenu;
|
||
}
|
||
|
||
/**
|
||
* Charge une démonstration livrée (§ 15) : un événement neuf, d'identifiant
|
||
* id, nommé d'après la démonstration suivie d'un rang, « ‹nom› N », N le
|
||
* premier rang libre à partir de 1 parmi les noms des événements lisibles du
|
||
* dossier. Les noms se comparent en NFC et, quand le système de fichiers
|
||
* ignore la casse, par leur majuscule, comme deriverBase compare les bases.
|
||
* Son état se déduit de son contenu ; creer l'écrit, journal d'une entrée au
|
||
* libellé libelle et son jalon nomJalon, sous une base que le nom dérive. Le
|
||
* texte livré ne fait que se lire ; ni lui ni l'événement courant ne
|
||
* changent. Le mode de l'événement chargé, la lecture, appartient à la
|
||
* séance.
|
||
*
|
||
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||
* @param {import('./systeme_fichiers.js').Racine} racine
|
||
* @param {string} texteLivre le fichier d'état livré
|
||
* @param {{id: string, libelle: string, nomJalon: string, horodatage: string, produitVersion: string}} options
|
||
* @returns {Promise<import('./depot.js').Ouvert>}
|
||
* @throws {ErreurStockage} ETAT_ILLISIBLE de l'analyse ; LECTURE_SEULE
|
||
* {raison: 'FORMAT_PLUS_RECENT'} ; l'un et l'autre sans rien écrire ;
|
||
* ce que lève creer
|
||
*/
|
||
export async function chargerDemonstration(
|
||
fs,
|
||
racine,
|
||
texteLivre,
|
||
{ id, libelle, nomJalon, horodatage, produitVersion },
|
||
) {
|
||
const { charge, formatPlusRecent } = analyser(texteLivre);
|
||
exigerFormatCourant(formatPlusRecent);
|
||
const { insensibleCasse } = await fs.emplacements();
|
||
const cle = (nom) => (insensibleCasse ? cleSansCasse(nom) : nom.normalize('NFC'));
|
||
const pris = new Set(
|
||
(await lister(fs, racine)).filter((resume) => resume.nom !== null).map((resume) => cle(resume.nom)),
|
||
);
|
||
let rang = 1;
|
||
while (pris.has(cle(`${charge.evenement.nom} ${rang}`))) rang += 1;
|
||
const evenement = { ...charge.evenement, id, nom: `${charge.evenement.nom} ${rang}`, etat: etatDeduit(charge) };
|
||
return creer(fs, racine, { ...charge, evenement }, { libelle, nomJalon, horodatage, produitVersion });
|
||
}
|