[ADD] storage: copy, rename, import, delete events; local settings
§ 8.7 operations reuse the event store's pairing and alignment instead of copying them. Renaming writes the complete pair under the new name — the store now writes a gesture with the pair it writes elsewhere — before deleting the old one, so no cut leaves zero pairs; an unchanged name writes nothing. Deleting moves state, .precedent, journal, lock and leftovers into a dated trash, matching names as the case-insensitive disk does. creerDossier creates only the root it is aimed at, so a vanished working folder never comes back empty. reglages_locaux.json keeps exactly the two § 8.5 settings; its deferred writer always keeps the last demand. Checked: 1314 node, 35 browser, 27 node-long tests; depot.js at 100 %. --- FR --- [ADD] stockage : copier, renommer, importer, supprimer ; réglages locaux Les opérations du § 8.7 reprennent l'appariement et l'alignement du dépôt au lieu de les recopier. Renommer écrit la paire complète sous le nouveau nom — le dépôt écrit désormais un geste avec la paire qu'il pose ailleurs — avant de supprimer l'ancienne : aucune coupure ne laisse zéro paire ; un nom inchangé n'écrit rien. Supprimer range état, .precedent, journal, verrou et restes dans une corbeille datée, en comparant les noms comme le disque insensible à la casse. creerDossier ne crée que la racine visée : un dossier de travail disparu ne renaît pas vide. reglages_locaux.json garde les deux réglages du § 8.5 ; son écrivain différé garde la dernière demande. Vérifié : 1314 node, 35 navigateur, 27 node-long ; depot.js à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
This commit is contained in:
parent
220814a673
commit
a3d7badf6d
16 changed files with 3241 additions and 79 deletions
|
|
@ -331,16 +331,18 @@ export function creerFichiers({
|
||||||
if (portable !== null) bases.set('portable', portable);
|
if (portable !== null) bases.set('portable', portable);
|
||||||
let choisis = 0;
|
let choisis = 0;
|
||||||
|
|
||||||
// Ce que désigne un chemin relatif à une racine : sa racine et sa cible en
|
// Ce que désigne un chemin relatif à une racine : ses segments, sa racine
|
||||||
// absolu, et le dossier que nomme un échec — celui qui porte la cible, la
|
// et sa cible en absolu, et le dossier que nomme un échec — celui qui porte
|
||||||
// racine elle-même pour ''. Seul le texte se lit : CHEMIN_REFUSE pour un
|
// la cible, la racine elle-même pour ''. Seul le texte se lit :
|
||||||
// chemin que la règle des segments refuse ou une racine inconnue.
|
// CHEMIN_REFUSE pour un chemin que la règle des segments refuse ou une
|
||||||
|
// racine inconnue.
|
||||||
function localiser(racine, chemin) {
|
function localiser(racine, chemin) {
|
||||||
const segments = segmentsAdmis(chemin);
|
const segments = segmentsAdmis(chemin);
|
||||||
const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined;
|
const base = estObjet(racine) && typeof racine.id === 'string' ? bases.get(racine.id) : undefined;
|
||||||
if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin });
|
if (base === undefined) throw new RefusFichier('CHEMIN_REFUSE', { chemin });
|
||||||
return {
|
return {
|
||||||
chemin,
|
chemin,
|
||||||
|
segments,
|
||||||
racine: chemins.join(base),
|
racine: chemins.join(base),
|
||||||
absolu: chemins.join(base, ...segments),
|
absolu: chemins.join(base, ...segments),
|
||||||
dossier: chemins.join(base, ...segments.slice(0, -1)),
|
dossier: chemins.join(base, ...segments.slice(0, -1)),
|
||||||
|
|
@ -650,11 +652,27 @@ export function creerFichiers({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// '' crée la racine, parents compris. Un autre chemin se crée dossier par
|
||||||
|
// dossier sous la racine, sans recursive : mkdir lève ENOENT quand son
|
||||||
|
// parent manque, si bien qu'une racine qui disparaît, même entre deux
|
||||||
|
// appels, ne renaît pas (§ 8.6). Un dossier présent passe ; un fichier en
|
||||||
|
// travers lève EEXIST sur le chemin même, ENOTDIR au-dessus.
|
||||||
async creerDossier(racine, chemin) {
|
async creerDossier(racine, chemin) {
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
|
const { segments } = loc;
|
||||||
try {
|
try {
|
||||||
await confiner(loc);
|
await confiner(loc);
|
||||||
await fs.mkdir(loc.absolu, { recursive: true });
|
if (segments.length === 0) await fs.mkdir(loc.absolu, { recursive: true });
|
||||||
|
for (let rang = 1; rang <= segments.length; rang += 1) {
|
||||||
|
const dossier = chemins.join(loc.racine, ...segments.slice(0, rang));
|
||||||
|
try {
|
||||||
|
await fs.mkdir(dossier);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur?.code !== 'EEXIST') throw erreur;
|
||||||
|
const cause = rang === segments.length ? 'EEXIST' : 'ENOTDIR';
|
||||||
|
if (!(await etatSuivi(dossier))?.isDirectory()) throw refus(loc, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (erreur) {
|
} catch (erreur) {
|
||||||
throw echecEcriture(loc, erreur);
|
throw echecEcriture(loc, erreur);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,13 @@
|
||||||
// geste : le journal en mémoire, en entier — suivi de l'entrée de reprise
|
// geste : le journal en mémoire, en entier — suivi de l'entrée de reprise
|
||||||
// quand il finit sur une autre révision que la charge ouverte —, puis l'état
|
// quand il finit sur une autre révision que la charge ouverte —, puis l'état
|
||||||
// que restitue sa dernière entrée, à sa révision. Rouvert là-bas,
|
// que restitue sa dernière entrée, à sa révision. Rouvert là-bas,
|
||||||
// l'événement rend la charge écrite sans rien rattraper.
|
// l'événement rend la charge écrite sans rien rattraper. Avec un geste, la
|
||||||
|
// paire porte l'entrée de ce geste à la suite du journal, et sa charge : le
|
||||||
|
// renommage (§ 8.7) écrit ainsi la paire complète sous le nouveau nom, dans
|
||||||
|
// le dossier même, sans toucher l'ancienne ni créer le dossier. Son
|
||||||
|
// .precedent, l'état d'avant le geste, s'écrit avant le journal : la
|
||||||
|
// nouvelle base appartient à l'événement dès que son journal existe, et
|
||||||
|
// l'ancienne paire ne s'apparie jamais ce journal.
|
||||||
//
|
//
|
||||||
// Un état sur le disque, ou un journal de la base, d'un format plus récent
|
// Un état sur le disque, ou un journal de la base, d'un format plus récent
|
||||||
// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours
|
// que FORMAT garde en lecture seule tout ce qui ouvre l'événement, secours
|
||||||
|
|
@ -243,12 +249,27 @@ function formatPlusRecentDe(texte) {
|
||||||
return Number.isSafeInteger(format) && format > FORMAT ? format : null;
|
return Number.isSafeInteger(format) && format > FORMAT ? format : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bases que portent les fichiers d'événement — état, .precedent, journal,
|
||||||
|
* verrou — à la racine de racine, dans l'ordre de sa liste, sans rien
|
||||||
|
* écrire : une base que l'un d'eux porte n'est pas libre (deriverBase). Une
|
||||||
|
* base revient une fois par fichier qui la porte.
|
||||||
|
*
|
||||||
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||||
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
* @throws {ErreurStockage} ABSENT quand la racine n'existe pas
|
||||||
|
*/
|
||||||
|
export async function basesPrises(fs, racine) {
|
||||||
|
return basesDe(await fs.lister(racine, ''));
|
||||||
|
}
|
||||||
|
|
||||||
// Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des
|
// Base que prend l'événement nommé nom dans racine (deriverBase) : aucune des
|
||||||
// bases d'autres, ni de celles que portent les fichiers du dossier, à la casse
|
// bases d'autres, ni de celles que portent les fichiers du dossier, à la casse
|
||||||
// près.
|
// près.
|
||||||
async function deriver(fs, racine, nom, autres) {
|
async function deriver(fs, racine, nom, autres) {
|
||||||
const { separateur } = await fs.emplacements();
|
const { separateur } = await fs.emplacements();
|
||||||
const existantes = [...autres, ...basesDe(await fs.lister(racine, ''))];
|
const existantes = [...autres, ...(await basesPrises(fs, racine))];
|
||||||
return deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
return deriverBase(nom, { racine: racine.chemin, separateur, existantes });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -905,6 +926,29 @@ function apresEcriture(ouvert, { charge, revision, journal }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La ligne de l'entrée d'un geste à la suite du journal que preparer a
|
||||||
|
// préparé, et sa révision, celle qui suit la dernière entrée de ce journal :
|
||||||
|
// un correctif depuis la charge que restitue cette entrée, ou un instantané à
|
||||||
|
// la cadence du journal ; produitVersion n'y paraît que quand la construction
|
||||||
|
// change (versionNotee). Une ligne que la lecture refuserait lève TypeError
|
||||||
|
// (ligneEntree), avant toute écriture.
|
||||||
|
function ligneDuGeste(preparation, apres, { libelle, horodatage, produitVersion, sens, retour }) {
|
||||||
|
const derniere = preparation.journal.entrees.at(-1).revision;
|
||||||
|
const revision = derniere + 1;
|
||||||
|
const ligne = ligneEntree({
|
||||||
|
revision,
|
||||||
|
libelle,
|
||||||
|
horodatage,
|
||||||
|
sens,
|
||||||
|
retour,
|
||||||
|
produitVersion: versionNotee(produitVersion, preparation.journal, derniere),
|
||||||
|
avant: preparation.charge,
|
||||||
|
apres,
|
||||||
|
instantane: estInstantane(revision, preparation.journal.entrees[0].revision),
|
||||||
|
});
|
||||||
|
return { ligne, revision };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de
|
* Crée l'événement de charge dans racine (§ 8.1, § 8.2) : sa base dérive de
|
||||||
* son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit
|
* son nom (deriverBase), libre dans le dossier. Le journal d'abord, écrit
|
||||||
|
|
@ -990,22 +1034,10 @@ export async function creer(fs, racine, charge, { libelle, nomJalon, horodatage,
|
||||||
* refuserait, avant toute écriture
|
* refuserait, avant toute écriture
|
||||||
*/
|
*/
|
||||||
export async function enregistrer(ouvert, apres, options) {
|
export async function enregistrer(ouvert, apres, options) {
|
||||||
const { libelle, horodatage, produitVersion, sens, retour, libelleReprise } = options;
|
const { horodatage, produitVersion, libelleReprise } = options;
|
||||||
exigerEcriture(ouvert);
|
exigerEcriture(ouvert);
|
||||||
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||||
const derniere = preparation.journal.entrees.at(-1).revision;
|
const { ligne, revision } = ligneDuGeste(preparation, apres, options);
|
||||||
const revision = derniere + 1;
|
|
||||||
const ligne = ligneEntree({
|
|
||||||
revision,
|
|
||||||
libelle,
|
|
||||||
horodatage,
|
|
||||||
sens,
|
|
||||||
retour,
|
|
||||||
produitVersion: versionNotee(produitVersion, preparation.journal, derniere),
|
|
||||||
avant: preparation.charge,
|
|
||||||
apres,
|
|
||||||
instantane: estInstantane(revision, preparation.journal.entrees[0].revision),
|
|
||||||
});
|
|
||||||
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
const texteEtat = relisible(serialiser(apres, { revision, produitVersion }), 'enregistrer');
|
||||||
const aligne = etatAligne(ouvert, preparation);
|
const aligne = etatAligne(ouvert, preparation);
|
||||||
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
|
const suite = aligne === null ? { depart: ouvert, preparation } : await aligner(ouvert, preparation, aligne);
|
||||||
|
|
@ -1058,43 +1090,72 @@ export async function deverrouiller({ fs, racine, base }, seance) {
|
||||||
await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance);
|
await fs.deverrouiller(racine, base + SUFFIXES.verrou, seance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L'état d'avant un geste, que le .precedent garde (§ 8.8) : le texte de
|
||||||
|
// l'état sur le disque quand il se lit ; sinon, pour un Ouvert de secours, la
|
||||||
|
// charge ouverte sous son en-tête, celle que l'entrée de reprise restitue.
|
||||||
|
// Une charge que l'analyse refuserait lève TypeError, avant toute écriture.
|
||||||
|
function etatDAvant({ texteEtat, charge, revision, produitVersion }) {
|
||||||
|
return texteEtat ?? relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
||||||
|
}
|
||||||
|
|
||||||
// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans
|
// La paire qu'enregistrerAilleurs écrit, calculée sans rien écrire. Sans
|
||||||
// journal, l'état seul : la charge ouverte, à sa révision et à sa version.
|
// journal ni geste, l'état seul : la charge ouverte, à sa révision et à sa
|
||||||
// Sinon le journal préparé (preparer), suivi de l'entrée de reprise quand il
|
// version. Sinon le journal préparé (preparer), suivi de l'entrée de reprise
|
||||||
// finit sur une autre révision que la charge ouverte, en entier et élagué
|
// quand il finit sur une autre révision que la charge ouverte, ou qu'il
|
||||||
// au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que restitue sa
|
// manque, puis de l'entrée du geste quand il y en a un (ligneDuGeste), en
|
||||||
// dernière entrée, sous l'en-tête de celle-ci (enteteDeLaDerniere), comme
|
// entier et élagué au-delà de PLAFOND_ENTREES (textePlein) ; puis l'état que
|
||||||
// l'état qu'aligne un geste : la dernière entrée du journal écrit restitue
|
// restitue sa dernière entrée : la charge du geste, à sa révision et sous sa
|
||||||
// l'état écrit (§ 8.2), et la réouverture n'a rien à rattraper. Une reprise
|
// version, comme l'écrit enregistrer ; sans geste, la charge préparée sous
|
||||||
// sans libelleReprise lève TypeError (preparer).
|
// l'en-tête de cette entrée (enteteDeLaDerniere), comme l'état qu'aligne un
|
||||||
function paireAilleurs(ouvert, options) {
|
// geste. La dernière entrée du journal écrit restitue l'état écrit (§ 8.2),
|
||||||
if (ouvert.journal === null) {
|
// et la réouverture n'a rien à rattraper. Une reprise sans libelleReprise, ou
|
||||||
|
// un geste dont la ligne ne se relirait pas, lève TypeError.
|
||||||
|
function paireAilleurs(ouvert, { libelleReprise, horodatage, geste }) {
|
||||||
|
if (ouvert.journal === null && geste === null) {
|
||||||
const { charge, revision, produitVersion } = ouvert;
|
const { charge, revision, produitVersion } = ouvert;
|
||||||
return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion };
|
return { journal: null, texteJournal: null, charge, chargeJournal: null, revision, produitVersion };
|
||||||
}
|
}
|
||||||
const preparation = preparer(ouvert, options);
|
const preparation = preparer(ouvert, { libelleReprise, horodatage });
|
||||||
const { texte, journal } = textePlein(preparation.journal);
|
if (geste === null) {
|
||||||
const { charge } = preparation;
|
const { texte, journal } = textePlein(preparation.journal);
|
||||||
return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
|
const { charge } = preparation;
|
||||||
|
return { journal, texteJournal: texte, charge, chargeJournal: charge, ...enteteDeLaDerniere(journal) };
|
||||||
|
}
|
||||||
|
const { apres, produitVersion } = geste;
|
||||||
|
const { ligne, revision } = ligneDuGeste(preparation, apres, { ...geste, horodatage });
|
||||||
|
const { texte, journal } = textePlein(avec(preparation.journal, ligne));
|
||||||
|
return { journal, texteJournal: texte, charge: apres, chargeJournal: canoniser(apres), revision, produitVersion };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Écrit l'événement ouvert dans une autre racine (§ 8.6) : la réponse à une
|
* Écrit l'événement ouvert sous une base libre d'une racine : dans une autre
|
||||||
* écriture qui échoue, l'Ouvert reçu étant celui d'avant le geste refusé
|
* racine (§ 8.6), la réponse à une écriture qui échoue, l'Ouvert reçu étant
|
||||||
* (ECRITURE) ou l'Ouvert avancé d'ETAT_NON_ECRIT. L'opérateur a choisi cette
|
* celui d'avant le geste refusé (ECRITURE) ou l'Ouvert avancé
|
||||||
* racine, que rien n'a sondée — documents en mode portable — : elle se crée
|
* d'ETAT_NON_ECRIT ; ou, avec un geste, sous le nom que ce geste donne, dans
|
||||||
* d'abord, parents compris, un dossier existant restant tel quel. Sa base
|
* le dossier même, ce qu'écrit le renommage (§ 8.7). Une racine que
|
||||||
* dérive du nom là-bas (deriverBase), libre parmi existantes et les bases du
|
* l'opérateur a choisie, que rien n'a sondée — documents en mode portable —,
|
||||||
|
* se crée d'abord, parents compris, un dossier existant restant tel quel ;
|
||||||
|
* creerRacine faux ne la crée pas : le dossier de travail qui disparaît en
|
||||||
|
* cours de séance ne renaît pas vide (§ 8.6). La base dérive du nom que porte
|
||||||
|
* la charge écrite (deriverBase), libre parmi existantes et les bases du
|
||||||
* dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le
|
* dossier. La paire s'y écrit alignée, comme après un geste (§ 8.2) : le
|
||||||
* journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé
|
* journal en mémoire, en entier — suivi d'une entrée de reprise, au libellé
|
||||||
* libelleReprise, quand il finit sur une autre révision que la charge
|
* libelleReprise, quand il finit sur une autre révision que la charge
|
||||||
* ouverte, et élagué au-delà de PLAFOND_ENTREES —, puis l'état que restitue
|
* ouverte, puis de l'entrée du geste, et élagué au-delà de PLAFOND_ENTREES —,
|
||||||
* sa dernière entrée, à la révision et à la version de celle-ci. Rouvert
|
* puis l'état que restitue sa dernière entrée, à la révision et à la version
|
||||||
* là-bas, l'événement rend cette charge sans rien rattraper. Un événement
|
* de celle-ci. Rouvert là-bas, l'événement rend cette charge sans rien
|
||||||
* sans journal n'écrit que son état : la charge ouverte, à sa révision. Le
|
* rattraper. Un événement sans journal ni geste n'écrit que son état : la
|
||||||
* .precedent ne se recopie pas. Les lignes et le texte de l'état se
|
* charge ouverte, à sa révision ; avec un geste, l'entrée de reprise commence
|
||||||
* calculent, et se relisent, avant la première écriture, celle de la racine
|
* son journal. Le .precedent de l'Ouvert ne se recopie pas. Avec un geste, la
|
||||||
* comprise.
|
* nouvelle base reçoit d'abord le sien, l'état d'avant le geste (etatDAvant),
|
||||||
|
* comme enregistrer le pose, mais avant le journal : la base appartient alors
|
||||||
|
* à l'événement (proprietaire), si bien qu'aucun autre état de son
|
||||||
|
* identifiant — l'ancienne paire d'un renommage interrompu, dont le journal
|
||||||
|
* de la base n'est pas le sien — ne s'apparie le journal neuf (apparier). La
|
||||||
|
* paire va sous une base qu'aucun fichier du dossier ne porte : elle n'y
|
||||||
|
* remplace rien, pas même la paire de l'Ouvert quand ce dossier est le sien.
|
||||||
|
* Les lignes et les textes d'état se calculent, et se relisent, avant la
|
||||||
|
* première écriture, celle de la racine comprise.
|
||||||
*
|
*
|
||||||
* @param {Ouvert} ouvert
|
* @param {Ouvert} ouvert
|
||||||
* @param {import('./systeme_fichiers.js').Racine} racine
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||||
|
|
@ -1104,25 +1165,41 @@ function paireAilleurs(ouvert, options) {
|
||||||
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
* @param {string} [options.libelleReprise] texte de l'entrée de reprise,
|
||||||
* requis quand elle a lieu
|
* requis quand elle a lieu
|
||||||
* @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de
|
* @param {string} [options.horodatage] AAAA-MM-JJTHH:MM:SS±HH:MM de
|
||||||
* l'entrée de reprise, requis avec elle
|
* l'entrée de reprise et de celle du geste, requis avec elles
|
||||||
|
* @param {{apres: import('./types.js').Charge, libelle: string, produitVersion: string}|null} [options.geste]
|
||||||
|
* un geste à la suite de la charge ouverte, comme enregistrer le
|
||||||
|
* reçoit : apres devient la charge écrite, à la révision qui suit la
|
||||||
|
* dernière entrée du journal, l'entrée au libellé libelle, l'état
|
||||||
|
* sous la version produitVersion ; null par défaut
|
||||||
|
* @param {boolean} [options.creerRacine] faux : la racine n'est pas créée,
|
||||||
|
* et doit exister ; vrai par défaut
|
||||||
* @returns {Promise<Ouvert>} l'Ouvert dans racine : le journal et la
|
* @returns {Promise<Ouvert>} l'Ouvert dans racine : le journal et la
|
||||||
* révision écrits, chargeJournal la charge de l'état écrit, et
|
* révision écrits, chargeJournal la charge de l'état écrit, et
|
||||||
* charge celle qu'en rend examiner
|
* charge celle qu'en rend examiner
|
||||||
* @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se
|
* @throws {ErreurStockage} LECTURE_SEULE ; ECRITURE quand la racine ne se
|
||||||
* crée pas, rien d'autre n'étant écrit, ou que le journal n'a pas pu
|
* crée pas, ou que le .precedent d'un geste n'a pas pu s'écrire, rien
|
||||||
* s'écrire ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire, ouvert
|
* d'autre n'étant écrit, ou que le journal n'a pas pu s'écrire, le
|
||||||
* étant l'Ouvert dans racine ; CHEMIN_TROP_LONG
|
* .precedent d'un geste restant écrit avant lui ; ABSENT {chemin: ''}
|
||||||
* @throws {TypeError} une reprise sans libelleReprise ou horodatage, un état
|
* quand creerRacine est faux et que la racine n'existe pas, rien
|
||||||
* que la lecture refuserait, avant toute écriture
|
* n'étant écrit ; ETAT_NON_ECRIT quand l'état n'a pas pu s'écrire,
|
||||||
|
* ouvert étant l'Ouvert dans racine ; CHEMIN_TROP_LONG
|
||||||
|
* @throws {TypeError} une reprise sans libelleReprise ou horodatage, un geste
|
||||||
|
* ou un état que la lecture refuserait, avant toute écriture
|
||||||
*/
|
*/
|
||||||
export async function enregistrerAilleurs(ouvert, racine, { existantes, libelleReprise, horodatage }) {
|
export async function enregistrerAilleurs(
|
||||||
|
ouvert,
|
||||||
|
racine,
|
||||||
|
{ existantes, libelleReprise, horodatage, geste = null, creerRacine = true },
|
||||||
|
) {
|
||||||
exigerEcriture(ouvert);
|
exigerEcriture(ouvert);
|
||||||
const { fs } = ouvert;
|
const { fs } = ouvert;
|
||||||
const paire = paireAilleurs(ouvert, { libelleReprise, horodatage });
|
const paire = paireAilleurs(ouvert, { libelleReprise, horodatage, geste });
|
||||||
const { journal, charge, revision, produitVersion } = paire;
|
const { journal, charge, revision, produitVersion } = paire;
|
||||||
const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
const texteEtat = relisible(serialiser(charge, { revision, produitVersion }), 'enregistrerAilleurs');
|
||||||
await fs.creerDossier(racine, '');
|
const precedent = geste === null ? null : etatDAvant(ouvert);
|
||||||
|
if (creerRacine) await fs.creerDossier(racine, '');
|
||||||
const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]);
|
const base = await deriver(fs, racine, charge.evenement.nom, [...existantes]);
|
||||||
|
if (precedent !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.precedent, precedent);
|
||||||
if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal);
|
if (journal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, paire.texteJournal);
|
||||||
const ailleurs = {
|
const ailleurs = {
|
||||||
...ouvert,
|
...ouvert,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { versionVoisine } from '../../test/version_voisine.js';
|
||||||
import { VERSION } from '../version.genere.js';
|
import { VERSION } from '../version.genere.js';
|
||||||
import { serialiser, serialiserCharge } from './canonique.js';
|
import { serialiser, serialiserCharge } from './canonique.js';
|
||||||
import {
|
import {
|
||||||
|
basesPrises,
|
||||||
creer,
|
creer,
|
||||||
deverrouiller,
|
deverrouiller,
|
||||||
enregistrer,
|
enregistrer,
|
||||||
|
|
@ -2303,6 +2304,153 @@ describe('dépôt : le verrou, et écrire ailleurs (§ 8.6, § 8.8)', () => {
|
||||||
assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4));
|
assert.equal(sansDocuments.contenu('portable', ETAT), etatTexte(chargeEssai(), 4));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("enregistrerAilleurs avec un geste, sans créer la racine : sous la base que dérive le nom du geste, l'état d'avant le geste au .precedent — le texte sur le disque, sinon la charge ouverte —, puis le journal recopié — reprise comprise quand il le faut — suivi de l'entrée du geste, puis l'état du geste ; la paire de l'Ouvert ne s'écrit pas et ne s'apparie pas le journal neuf, et une racine absente ne se crée pas", async () => {
|
||||||
|
const { disque, fs } = nouveauDisque();
|
||||||
|
let ouvert = await creer(fs, DOC, chargeEssai(), CREATION);
|
||||||
|
ouvert = await enregistrer(ouvert, modifier(ouvert.charge, 2), geste(2));
|
||||||
|
const avant = await fichiersDe(disque);
|
||||||
|
const nouvelle = 'Gala des Saules';
|
||||||
|
const renommee = (charge) => ({ ...charge, evenement: { ...charge.evenement, nom: nouvelle } });
|
||||||
|
const options = {
|
||||||
|
existantes: [],
|
||||||
|
horodatage: heure(3),
|
||||||
|
geste: { apres: renommee(ouvert.charge), libelle: 'Renommé', produitVersion: V2 },
|
||||||
|
creerRacine: false,
|
||||||
|
};
|
||||||
|
const ecritsSous = (base) => [
|
||||||
|
['ecrireAtomique', `${base}${SUFFIXES.precedent}`],
|
||||||
|
['ecrireAtomique', `${base}${SUFFIXES.journal}`],
|
||||||
|
['ecrireAtomique', `${base}${SUFFIXES.etat}`],
|
||||||
|
];
|
||||||
|
const trace = tracer(fs);
|
||||||
|
const ailleurs = await enregistrerAilleurs(ouvert, DOC, options);
|
||||||
|
assert.deepEqual(trace, ecritsSous(nouvelle));
|
||||||
|
assert.equal(fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), texteDe(avant, ETAT));
|
||||||
|
assert.deepEqual(
|
||||||
|
(await fichiersDe(disque)).filter(([nom]) => !nom.startsWith(nouvelle)),
|
||||||
|
avant,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
[ailleurs.racine, ailleurs.base, ailleurs.baseJournal, ailleurs.revision, ailleurs.produitVersion],
|
||||||
|
[DOC, nouvelle, nouvelle, 3, V2],
|
||||||
|
);
|
||||||
|
assert.equal(texteDeCharge(ailleurs.charge), texteDeCharge(renommee(modifier(chargeEssai(), 2))));
|
||||||
|
const journal = exigerEquivalence(fs, nouvelle);
|
||||||
|
assert.deepEqual(revisionsEtLibelles(journal), [
|
||||||
|
[1, CREATION.libelle],
|
||||||
|
[2, 'Geste 2'],
|
||||||
|
[3, 'Renommé'],
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
journal.entrees.map(({ produitVersion }) => produitVersion),
|
||||||
|
[V1, null, V2],
|
||||||
|
);
|
||||||
|
assert.equal(analyser(fs.contenu('documents', `${nouvelle}${SUFFIXES.etat}`)).entete.produitVersion, V2);
|
||||||
|
const rouvert = await rouvrir(disque, nouvelle);
|
||||||
|
assert.deepEqual([rouvert.revision, rouvert.charge.evenement.nom, rouvert.avertissements], [3, nouvelle, []]);
|
||||||
|
const suite = await enregistrer(ailleurs, modifier(ailleurs.charge, 4), geste(4, { produitVersion: V2 }));
|
||||||
|
assert.deepEqual([suite.base, suite.revision], [nouvelle, 4]);
|
||||||
|
exigerEquivalence(fs, nouvelle);
|
||||||
|
|
||||||
|
// Un état sans journal, à la mise en page d'un autre outil : l'entrée de
|
||||||
|
// reprise, au libellé reçu, précède celle du geste ; sans ce libellé,
|
||||||
|
// TypeError avant toute écriture. Le .precedent neuf porte ses octets.
|
||||||
|
const enQuatre = JSON.stringify(JSON.parse(etatTexte(chargeEssai(), 4)), null, 4);
|
||||||
|
const importe = `${enQuatre.replaceAll('\n', '\r\n')}\r\n`;
|
||||||
|
const seul = disqueAvec([[ETAT, importe]]);
|
||||||
|
const sansJournal = await rouvrir(seul.disque, BASE);
|
||||||
|
const gesteSeul = { ...options, geste: { ...options.geste, apres: renommee(sansJournal.charge), produitVersion: V1 } };
|
||||||
|
await assert.rejects(enregistrerAilleurs(sansJournal, DOC, gesteSeul), TypeError);
|
||||||
|
assert.equal(sansJournal.fs.compteurs.ecritures, 0);
|
||||||
|
const repris = await enregistrerAilleurs(sansJournal, DOC, { ...gesteSeul, libelleReprise: REPRISE });
|
||||||
|
assert.deepEqual([repris.base, repris.revision], [nouvelle, 5]);
|
||||||
|
assert.deepEqual(revisionsEtLibelles(exigerEquivalence(sansJournal.fs, nouvelle)), [
|
||||||
|
[4, REPRISE],
|
||||||
|
[5, 'Renommé'],
|
||||||
|
]);
|
||||||
|
assert.equal(sansJournal.fs.contenu('documents', ETAT), importe);
|
||||||
|
assert.equal(sansJournal.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), importe);
|
||||||
|
|
||||||
|
// Coupée juste après le journal neuf, l'écriture laisse la nouvelle base
|
||||||
|
// à l'événement par son .precedent : l'état de l'Ouvert, qu'aucun journal
|
||||||
|
// de sa base ne suit, ne se l'apparie pas, et s'ouvre tel qu'il était ;
|
||||||
|
// la liste ne montre que lui.
|
||||||
|
const coupe = disqueAvec([[ETAT, etatTexte(chargeEssai(), 4)]]);
|
||||||
|
const avantCoupure = await rouvrir(coupe.disque, BASE);
|
||||||
|
const ecrire = avantCoupure.fs.ecrireAtomique;
|
||||||
|
avantCoupure.fs.ecrireAtomique = async (racine, chemin, texte) => {
|
||||||
|
await ecrire(racine, chemin, texte);
|
||||||
|
if (chemin === `${nouvelle}${SUFFIXES.journal}`) avantCoupure.fs.pannes.couperApres(0);
|
||||||
|
};
|
||||||
|
await assert.rejects(
|
||||||
|
enregistrerAilleurs(avantCoupure, DOC, { ...gesteSeul, libelleReprise: REPRISE }),
|
||||||
|
PanneSimulee,
|
||||||
|
);
|
||||||
|
const apresCoupure = await rouvrir(coupe.disque, BASE);
|
||||||
|
assert.deepEqual(
|
||||||
|
[apresCoupure.revision, apresCoupure.charge.evenement.nom, apresCoupure.journal, apresCoupure.avertissements],
|
||||||
|
[4, BASE, null, []],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
(await lister(creerFichiersSimules({ disque: coupe.disque }), DOC)).map(({ base, journal }) => [base, journal]),
|
||||||
|
[[BASE, null]],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Un Ouvert de secours, l'état sur le disque illisible : le .precedent
|
||||||
|
// de la nouvelle base reçoit la charge ouverte, à sa révision.
|
||||||
|
const secours = disqueAvec([
|
||||||
|
[ETAT, '{'],
|
||||||
|
[PRECEDENT, etatTexte(chargeEssai(), 4)],
|
||||||
|
]);
|
||||||
|
const depuisPrecedent = await ouvrirSecours(secours.fs, DOC, BASE, 'precedent');
|
||||||
|
assert.equal(depuisPrecedent.texteEtat, null);
|
||||||
|
const traceSecours = tracer(secours.fs);
|
||||||
|
const gesteSecours = { ...gesteSeul.geste, apres: renommee(depuisPrecedent.charge) };
|
||||||
|
const renommeSecours = await enregistrerAilleurs(depuisPrecedent, DOC, {
|
||||||
|
...gesteSeul,
|
||||||
|
geste: gesteSecours,
|
||||||
|
libelleReprise: REPRISE,
|
||||||
|
});
|
||||||
|
assert.deepEqual(traceSecours, ecritsSous(nouvelle));
|
||||||
|
assert.equal(secours.fs.contenu('documents', `${nouvelle}${SUFFIXES.precedent}`), etatTexte(chargeEssai(), 4));
|
||||||
|
assert.deepEqual([renommeSecours.base, renommeSecours.revision], [nouvelle, 5]);
|
||||||
|
assert.deepEqual(
|
||||||
|
[ETAT, PRECEDENT].map((nom) => secours.fs.contenu('documents', nom)),
|
||||||
|
['{', etatTexte(chargeEssai(), 4)],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sur un disque où documents n'existe pas : creerRacine faux ne le crée
|
||||||
|
// pas, et sa liste lève ABSENT avant toute écriture.
|
||||||
|
const neuf = new Map();
|
||||||
|
const sansDocuments = creerFichiersSimules({ disque: neuf, executable: 'E:\\soirees' });
|
||||||
|
sansDocuments.deposer('portable', ETAT, etatTexte(chargeEssai(), 4));
|
||||||
|
const { portable, documents } = await sansDocuments.racines();
|
||||||
|
const portableSeul = await ouvrir(sansDocuments, portable, BASE);
|
||||||
|
const traceRacine = tracer(sansDocuments);
|
||||||
|
const absente = await echec(
|
||||||
|
enregistrerAilleurs(portableSeul, documents, { existantes: [], creerRacine: false }),
|
||||||
|
'ABSENT',
|
||||||
|
);
|
||||||
|
assert.deepEqual(absente.details, { chemin: '' });
|
||||||
|
assert.deepEqual(traceRacine, []);
|
||||||
|
await echec(creerFichiersSimules({ disque: neuf }).lister(DOC, ''), 'ABSENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("basesPrises rend, sans rien écrire, la base de chaque fichier d'événement du dossier — état, .precedent, journal, verrou —, dans l'ordre de sa liste ; ABSENT quand la racine manque", async () => {
|
||||||
|
const { fs } = disqueAvec([
|
||||||
|
[ETAT, '{'],
|
||||||
|
[PRECEDENT, '{'],
|
||||||
|
['Archives des Lucioles.gtt-journal.jsonl', '{'],
|
||||||
|
['GALA DES SAULES.gtt.verrou', '{}\n'],
|
||||||
|
['notes.txt', 'à garder'],
|
||||||
|
['reglages_locaux.json', '{}\n'],
|
||||||
|
]);
|
||||||
|
fs.deposerDossier('documents', 'corbeille');
|
||||||
|
assert.deepEqual(await basesPrises(fs, DOC), ['Archives des Lucioles', 'GALA DES SAULES', BASE, BASE]);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
await echec(basesPrises(creerFichiersSimules({ disque: new Map() }), DOC), 'ABSENT');
|
||||||
|
});
|
||||||
|
|
||||||
test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => {
|
test("une lecture qui échoue autrement que sur un fichier absent remonte telle quelle : un chemin refusé lève CHEMIN_REFUSE", async () => {
|
||||||
const { fs } = nouveauDisque();
|
const { fs } = nouveauDisque();
|
||||||
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
|
await echec(ouvrir(fs, DOC, '../ailleurs'), 'CHEMIN_REFUSE');
|
||||||
|
|
|
||||||
|
|
@ -135,12 +135,15 @@ async function ecrire(loc, travail) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
||||||
// creer crée ce qui manque, dossier du produit compris. Lève l'exception de
|
// creer crée ce qui manque sous lui. Le dossier du produit lui-même ne se
|
||||||
|
// crée que pour des segments vides, qui le désignent : un chemin sous un
|
||||||
|
// dossier du produit absent lève NotFoundError (§ 8.6). Lève l'exception de
|
||||||
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
||||||
// fichier à sa place.
|
// fichier à sa place.
|
||||||
async function dossierDe(segments, creer = false) {
|
async function dossierDe(segments, creer = false) {
|
||||||
const options = { create: creer };
|
const options = { create: creer };
|
||||||
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, options);
|
const produit = { create: creer && segments.length === 0 };
|
||||||
|
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
|
||||||
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
|
for (const segment of segments) dossier = await dossier.getDirectoryHandle(segment, options);
|
||||||
return dossier;
|
return dossier;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,19 +152,26 @@ describe('fichiers web : la plateforme et sa racine (§ 8.8, § 13.1)', () => {
|
||||||
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien ; creerDossier le crée", async () => {
|
test("sans le dossier du produit : une écriture lève ECRITURE sans le recréer, une lecture ABSENT, supprimer ne fait rien, creerDossier d'un chemin sous lui aussi ; creerDossier de '' le crée", async () => {
|
||||||
const fs = creerFichiersWeb();
|
const fs = creerFichiersWeb();
|
||||||
const refus = (chemin) => ({ chemin, dossier: CHEMIN_DOCUMENTS, cause: 'NotFoundError' });
|
const refus = (chemin, dossier = CHEMIN_DOCUMENTS) => ({ chemin, dossier, cause: 'NotFoundError' });
|
||||||
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json'));
|
await echoue(fs.ecrireAtomique(DOCUMENTS, 'etat.json', 'x'), 'ECRITURE', refus('etat.json'));
|
||||||
await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl'));
|
await echoue(fs.ajouterLigne(DOCUMENTS, 'journal.jsonl', 'x'), 'ECRITURE', refus('journal.jsonl'));
|
||||||
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' });
|
await echoue(fs.lireTexte(DOCUMENTS, 'etat.json'), 'ABSENT', { chemin: 'etat.json' });
|
||||||
await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
|
await echoue(fs.lister(DOCUMENTS, ''), 'ABSENT', { chemin: '' });
|
||||||
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' });
|
await echoue(fs.deplacer(DOCUMENTS, 'etat.json', 'copie.json'), 'ABSENT', { chemin: 'etat.json' });
|
||||||
await fs.supprimer(DOCUMENTS, 'etat.json');
|
await fs.supprimer(DOCUMENTS, 'etat.json');
|
||||||
|
await echoue(
|
||||||
|
fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05'),
|
||||||
|
'ECRITURE',
|
||||||
|
refus('corbeille/2026-01-02_03-04-05', `${CHEMIN_DOCUMENTS}/corbeille`),
|
||||||
|
);
|
||||||
expect(await nomsOpfs()).toStrictEqual([]);
|
expect(await nomsOpfs()).toStrictEqual([]);
|
||||||
|
|
||||||
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
|
await fs.creerDossier(DOCUMENTS, '');
|
||||||
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
|
expect(await nomsOpfs()).toStrictEqual([DOSSIER]);
|
||||||
|
expect(await fs.lister(DOCUMENTS, '')).toStrictEqual([]);
|
||||||
|
await fs.creerDossier(DOCUMENTS, 'corbeille/2026-01-02_03-04-05');
|
||||||
expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']);
|
expect(noms(await fs.lister(DOCUMENTS, 'corbeille'))).toStrictEqual(['2026-01-02_03-04-05']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,10 @@ function tenir(brut, limite) {
|
||||||
// nom, qui se lisent pareil à l'écran. Une majuscule de plusieurs lettres (ß
|
// nom, qui se lisent pareil à l'écran. Une majuscule de plusieurs lettres (ß
|
||||||
// donne SS) réunit des noms que Windows distingue : un rang de plus, jamais un
|
// donne SS) réunit des noms que Windows distingue : un rang de plus, jamais un
|
||||||
// fichier écrasé.
|
// fichier écrasé.
|
||||||
const cle = (base) => base.normalize('NFC').toUpperCase();
|
// Exportée : supprimer et le rang d'une démonstration (operations.js)
|
||||||
|
// comparent les noms de fichier par cette même clé.
|
||||||
|
export const cleSansCasse = (base) => base.normalize('NFC').toUpperCase();
|
||||||
|
const cle = cleSansCasse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base de nom de fichier d'un nom d'événement dans un dossier de travail
|
* Base de nom de fichier d'un nom d'événement dans un dossier de travail
|
||||||
|
|
|
||||||
524
src/stockage/operations.js
Normal file
524
src/stockage/operations.js
Normal file
|
|
@ -0,0 +1,524 @@
|
||||||
|
// © 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.
|
||||||
|
//
|
||||||
|
// 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, ouvrir } from './depot.js';
|
||||||
|
import { analyser, etatDeduit } from './document.js';
|
||||||
|
import { ErreurStockage } from './erreurs.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.
|
||||||
|
async function texteSiPresent(fs, racine, chemin) {
|
||||||
|
try {
|
||||||
|
return await fs.lireTexte(racine, chemin);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (erreur.code === 'ABSENT') return null;
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. TypeError quand il le faut et qu'il manque.
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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 ;
|
||||||
|
* 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 nouvelle = await ecrireNouvellePaire(ouvert, { geste, libelleReprise, horodatage });
|
||||||
|
await supprimerAncienne(ouvert, 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 JSON : ETAT_ILLISIBLE, raison JSON, comme l'analyse le
|
||||||
|
// lèverait d'un texte qui ne se lit pas.
|
||||||
|
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: 'JSON', chemin: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écrit dans racine l'événement id, nom : texteJournal, quand il est donné,
|
||||||
|
// puis texteEtat, tels quels, sous une base libre que nom dérive. Rend cette
|
||||||
|
// base. IDENTIFIANT_PRESENT quand un état lisible du dossier porte déjà id.
|
||||||
|
async function deposer(fs, racine, { id, nom, texteEtat, texteJournal }) {
|
||||||
|
const present = (await lister(fs, racine)).find((resume) => resume.illisible === null && 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 (texteJournal !== null) await fs.ecrireAtomique(racine, base + SUFFIXES.journal, texteJournal);
|
||||||
|
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 JSON pour des octets hors UTF-8 ; ni base ni
|
||||||
|
* secours : le fichier n'est pas du dossier. IDENTIFIANT_PRESENT {id,
|
||||||
|
* base} quand un état lisible du dossier porte son identifiant ;
|
||||||
|
* 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 }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. 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,
|
||||||
|
* ABSENT, IDENTIFIANT_PRESENT, ECRITURE… —, sans arrêter les suivantes ;
|
||||||
|
* l'écriture refusée de son état laisse dans vers le journal déjà écrit.
|
||||||
|
*
|
||||||
|
* @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 {
|
||||||
|
const { texteEtat, baseJournal } = await ouvrir(fs, depuis, base);
|
||||||
|
const { id, nom } = analyser(texteEtat).charge.evenement;
|
||||||
|
const texteJournal =
|
||||||
|
baseJournal === null ? null : await texteSiPresent(fs, depuis, baseJournal + SUFFIXES.journal);
|
||||||
|
importes.push({ base, nouvelle: await deposer(fs, vers, { id, nom, texteEtat, texteJournal }) });
|
||||||
|
} 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.
|
||||||
|
*
|
||||||
|
* 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}} options AAAA-MM-JJTHH:MM:SS±HH:MM
|
||||||
|
* @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 ;
|
||||||
|
* 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, sans rien écrire
|
||||||
|
*/
|
||||||
|
export async function supprimer(fs, racine, base, { horodatage }) {
|
||||||
|
const { insensibleCasse } = await fs.emplacements();
|
||||||
|
const journal = await baseDuJournal(fs, racine, base, insensibleCasse);
|
||||||
|
const fichiers = [
|
||||||
|
base + SUFFIXES.verrou,
|
||||||
|
...(journal === null ? [] : [journal + SUFFIXES.journal]),
|
||||||
|
base + SUFFIXES.precedent,
|
||||||
|
base + SUFFIXES.etat,
|
||||||
|
];
|
||||||
|
const presents = (await fs.lister(racine, ''))
|
||||||
|
.filter(({ type }) => type === 'fichier')
|
||||||
|
.map(({ nom }) => nom)
|
||||||
|
.sort(comparerTextes);
|
||||||
|
const partants = [...fichiers.map((nom) => nom + SUFFIXE_ECRITURE), ...fichiers]
|
||||||
|
.map((nom) => nomSurDisque(presents, nom, insensibleCasse))
|
||||||
|
.filter((nom) => nom !== null);
|
||||||
|
if (partants.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);
|
||||||
|
await fs.creerDossier(racine, dossier);
|
||||||
|
for (const nom of partants) await fs.deplacer(racine, nom, `${dossier}/${nom}`);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
1554
src/stockage/operations.test.js
Normal file
1554
src/stockage/operations.test.js
Normal file
File diff suppressed because it is too large
Load diff
321
src/stockage/reglages_locaux.js
Normal file
321
src/stockage/reglages_locaux.js
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Les réglages locaux (§ 8.5) : le fichier reglages_locaux.json, à la racine
|
||||||
|
// du dossier de travail, à côté des fichiers d'événement et hors d'eux. Il
|
||||||
|
// porte deux choses, et deux seulement : le cadrage de chaque événement,
|
||||||
|
// indexé par son identifiant — le zoom k en pixels par centimètre, la
|
||||||
|
// translation tx, ty en pixels —, et le format de papier des planches,
|
||||||
|
// global à la machine (§ 11.2), null tant que l'opérateur ne l'a pas choisi.
|
||||||
|
// Ni l'un ni l'autre ne voyage avec un événement ni ne revient par un retour
|
||||||
|
// arrière.
|
||||||
|
//
|
||||||
|
// La lecture ne bloque jamais : un fichier absent donne les défauts ; un
|
||||||
|
// fichier qui ne se lit pas, ou dont une valeur connue sort de sa règle,
|
||||||
|
// donne les défauts et l'avertissement REGLAGES_ILLISIBLES. Un fichier
|
||||||
|
// lisible garde ses clés inconnues, à tout niveau, et son format, un entier
|
||||||
|
// d'au moins 1 : le réglage qu'une construction plus récente y ajoute
|
||||||
|
// survit à la réécriture par une plus ancienne.
|
||||||
|
//
|
||||||
|
// Le texte est canonique : JSON sur une ligne, les clés de chaque objet
|
||||||
|
// rangées par unités UTF-16, les cadrages par identifiant d'événement, une
|
||||||
|
// fin de ligne finale. L'écriture en est différée (§ 8.2, § 8.5) : un
|
||||||
|
// écrivain regroupe les demandes d'un délai borné, si bien qu'un zoom
|
||||||
|
// continu écrit une fois par délai, jamais à chaque image.
|
||||||
|
import { clesRangees } from './document.js';
|
||||||
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
|
||||||
|
/** Le fichier des réglages locaux, à la racine du dossier de travail (§ 8.5). */
|
||||||
|
export const FICHIER_REGLAGES = 'reglages_locaux.json';
|
||||||
|
|
||||||
|
// Format d'un fichier que ce code crée.
|
||||||
|
const FORMAT_REGLAGES = 1;
|
||||||
|
|
||||||
|
// Les papiers d'une planche (§ 11.2).
|
||||||
|
const PAPIERS = ['A4', 'Lettre'];
|
||||||
|
|
||||||
|
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les réglages locaux, tels que reglages_locaux.json les porte.
|
||||||
|
*
|
||||||
|
* @typedef {Object} ReglagesLocaux
|
||||||
|
* @property {number} format entier ≥ 1, gardé tel que lu
|
||||||
|
* @property {null|'A4'|'Lettre'} papier
|
||||||
|
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
||||||
|
* un par événement, triés par identifiant ; evenement non vide, k
|
||||||
|
* nombre fini > 0, tx et ty nombres finis. Toute autre clé, à tout
|
||||||
|
* niveau, se garde telle quelle
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Deux chaînes, comparées unité UTF-16 par unité.
|
||||||
|
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||||
|
const parEvenement = (a, b) => comparerTextes(a.evenement, b.evenement);
|
||||||
|
|
||||||
|
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||||
|
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||||
|
|
||||||
|
// Les réglages d'un dossier qui n'en a pas, neufs à chaque appel.
|
||||||
|
const defauts = () => ({ format: FORMAT_REGLAGES, papier: null, cadrages: [] });
|
||||||
|
|
||||||
|
// Chemin de la première valeur connue d'un cadrage qui sort de sa règle, ou
|
||||||
|
// null ; chemin désigne le cadrage lui-même.
|
||||||
|
function fauteDeCadrage(cadrage, chemin) {
|
||||||
|
if (!estObjet(cadrage)) return chemin;
|
||||||
|
const { evenement, k, tx, ty } = cadrage;
|
||||||
|
if (typeof evenement !== 'string' || evenement === '') return `${chemin}.evenement`;
|
||||||
|
if (!Number.isFinite(k) || k <= 0) return `${chemin}.k`;
|
||||||
|
if (!Number.isFinite(tx)) return `${chemin}.tx`;
|
||||||
|
if (!Number.isFinite(ty)) return `${chemin}.ty`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chemin de la première valeur connue des réglages qui sort de sa règle, ou
|
||||||
|
// null ; '' quand les réglages ne sont pas un objet. Le second cadrage d'un
|
||||||
|
// même événement est fautif.
|
||||||
|
function fauteDeReglages(reglages) {
|
||||||
|
if (!estObjet(reglages)) return '';
|
||||||
|
const { format, papier, cadrages } = reglages;
|
||||||
|
if (!Number.isSafeInteger(format) || format < 1) return 'format';
|
||||||
|
if (papier !== null && !PAPIERS.includes(papier)) return 'papier';
|
||||||
|
if (!Array.isArray(cadrages)) return 'cadrages';
|
||||||
|
const vus = new Set();
|
||||||
|
for (let rang = 0; rang < cadrages.length; rang += 1) {
|
||||||
|
const chemin = `cadrages[${rang}]`;
|
||||||
|
const faute = fauteDeCadrage(cadrages[rang], chemin);
|
||||||
|
if (faute !== null) return faute;
|
||||||
|
if (vus.has(cadrages[rang].evenement)) return `${chemin}.evenement`;
|
||||||
|
vus.add(cadrages[rang].evenement);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copie de valeur dont chaque objet reçoit ses clés rangées (clesRangees),
|
||||||
|
// chaque liste gardant son ordre. JSON.stringify écrit les clés de la copie
|
||||||
|
// dans cet ordre, les clés d'index entier en tête, croissantes, comme
|
||||||
|
// JavaScript les énumère : le texte ne dépend que de l'ensemble des clés.
|
||||||
|
// Object.fromEntries pose chaque clé en propre, « __proto__ » comprise.
|
||||||
|
function rangee(valeur) {
|
||||||
|
if (Array.isArray(valeur)) return valeur.map(rangee);
|
||||||
|
if (!estObjet(valeur)) return valeur;
|
||||||
|
return Object.fromEntries(clesRangees(valeur).map((cle) => [cle, rangee(valeur[cle])]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ce que rend lireReglages d'un fichier illisible : les défauts, et
|
||||||
|
// l'avertissement qui nomme le fichier et la raison.
|
||||||
|
const illisibles = (raison) => ({
|
||||||
|
reglages: defauts(),
|
||||||
|
avertissement: { code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit les réglages locaux de racine (§ 8.5), sans rien écrire ni jamais
|
||||||
|
* bloquer. Un fichier absent rend les défauts, sans avertissement. Un
|
||||||
|
* fichier illisible rend les défauts et l'avertissement REGLAGES_ILLISIBLES
|
||||||
|
* {chemin, raison} : raison JSON pour un texte qui ne se lit pas, vide
|
||||||
|
* compris ; FORME pour une valeur connue hors de sa règle — un format qui
|
||||||
|
* n'est pas un entier ≥ 1, un papier hors de null, A4 et Lettre, un cadrage
|
||||||
|
* fautif ou en double ; le code d'une lecture que le système de fichiers
|
||||||
|
* refuse autrement que par ABSENT. Une marque d'ordre d'octets en tête est
|
||||||
|
* ignorée. Les réglages lus gardent leurs clés inconnues et leur format ;
|
||||||
|
* leurs cadrages sont triés par identifiant d'événement.
|
||||||
|
*
|
||||||
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||||
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||||
|
* @returns {Promise<{reglages: ReglagesLocaux, avertissement: {code: string, details: Object}|null}>}
|
||||||
|
* une erreur qui n'est pas une ErreurStockage remonte telle quelle
|
||||||
|
*/
|
||||||
|
export async function lireReglages(fs, racine) {
|
||||||
|
let texte;
|
||||||
|
try {
|
||||||
|
texte = await fs.lireTexte(racine, FICHIER_REGLAGES);
|
||||||
|
} catch (erreur) {
|
||||||
|
if (!(erreur instanceof ErreurStockage)) throw erreur;
|
||||||
|
if (erreur.code === 'ABSENT') return { reglages: defauts(), avertissement: null };
|
||||||
|
return illisibles(erreur.code);
|
||||||
|
}
|
||||||
|
let lu;
|
||||||
|
try {
|
||||||
|
lu = JSON.parse(texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte);
|
||||||
|
} catch {
|
||||||
|
return illisibles('JSON');
|
||||||
|
}
|
||||||
|
if (fauteDeReglages(lu) !== null) return illisibles('FORME');
|
||||||
|
return { reglages: { ...lu, cadrages: [...lu.cadrages].sort(parEvenement) }, avertissement: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Le cadrage de l'événement d'identifiant evenement, ou null quand les
|
||||||
|
* réglages n'en portent pas.
|
||||||
|
*
|
||||||
|
* @param {ReglagesLocaux} reglages
|
||||||
|
* @param {string} evenement
|
||||||
|
* @returns {{k: number, tx: number, ty: number}|null}
|
||||||
|
*/
|
||||||
|
export function cadrageDe({ cadrages }, evenement) {
|
||||||
|
const cadrage = cadrages.find((candidat) => candidat.evenement === evenement);
|
||||||
|
return cadrage === undefined ? null : { k: cadrage.k, tx: cadrage.tx, ty: cadrage.ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les réglages reçus, le cadrage de l'événement remplacé, ou ajouté à son
|
||||||
|
* rang ; ceux reçus ne changent pas.
|
||||||
|
*
|
||||||
|
* @param {ReglagesLocaux} reglages
|
||||||
|
* @param {string} evenement identifiant de l'événement, non vide
|
||||||
|
* @param {{k: number, tx: number, ty: number}} cadrage k fini > 0, tx et
|
||||||
|
* ty finis
|
||||||
|
* @returns {ReglagesLocaux}
|
||||||
|
* @throws {TypeError} une valeur hors de sa règle
|
||||||
|
*/
|
||||||
|
export function avecCadrage(reglages, evenement, { k, tx, ty }) {
|
||||||
|
const cadrage = { evenement, k, tx, ty };
|
||||||
|
const faute = fauteDeCadrage(cadrage, 'cadrage');
|
||||||
|
if (faute !== null) throw new TypeError(`avecCadrage : ${faute} hors de sa règle`);
|
||||||
|
const autres = reglages.cadrages.filter((autre) => autre.evenement !== evenement);
|
||||||
|
return { ...reglages, cadrages: [...autres, cadrage].sort(parEvenement) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les réglages reçus, le papier remplacé ; ceux reçus ne changent pas.
|
||||||
|
*
|
||||||
|
* @param {ReglagesLocaux} reglages
|
||||||
|
* @param {null|'A4'|'Lettre'} papier
|
||||||
|
* @returns {ReglagesLocaux}
|
||||||
|
* @throws {TypeError} un autre papier
|
||||||
|
*/
|
||||||
|
export function avecPapier(reglages, papier) {
|
||||||
|
if (papier !== null && !PAPIERS.includes(papier)) {
|
||||||
|
throw new TypeError(`avecPapier : papier null, A4 ou Lettre attendu, reçu ${JSON.stringify(papier)}`);
|
||||||
|
}
|
||||||
|
return { ...reglages, papier };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texte canonique des réglages : JSON sur une ligne, les clés de chaque
|
||||||
|
* objet rangées par unités UTF-16, clés inconnues comprises, les cadrages
|
||||||
|
* triés par identifiant d'événement, une fin de ligne finale. Deux réglages
|
||||||
|
* égaux s'écrivent pareil, quel que soit l'ordre de leurs clés ou de leurs
|
||||||
|
* cadrages.
|
||||||
|
*
|
||||||
|
* @param {ReglagesLocaux} reglages
|
||||||
|
* @returns {string}
|
||||||
|
* @throws {TypeError} une valeur connue hors de sa règle, que la lecture
|
||||||
|
* refuserait
|
||||||
|
*/
|
||||||
|
export function serialiserReglages(reglages) {
|
||||||
|
const faute = fauteDeReglages(reglages);
|
||||||
|
if (faute !== null) throw new TypeError(`serialiserReglages : ${faute || 'les réglages'} hors de sa règle`);
|
||||||
|
return `${JSON.stringify(rangee({ ...reglages, cadrages: [...reglages.cadrages].sort(parEvenement) }))}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrit les réglages dans racine : leur texte canonique, par une écriture
|
||||||
|
* atomique de FICHIER_REGLAGES à sa racine. Le dossier de travail doit
|
||||||
|
* exister : aucune écriture ne le recrée (§ 8.6).
|
||||||
|
*
|
||||||
|
* @param {import('./systeme_fichiers.js').SystemeFichiers} fs
|
||||||
|
* @param {import('./systeme_fichiers.js').Racine} racine
|
||||||
|
* @param {ReglagesLocaux} reglages
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
* @throws {TypeError} des réglages hors de leur règle, avant toute écriture
|
||||||
|
* @throws {ErreurStockage} ECRITURE, le fichier intact
|
||||||
|
*/
|
||||||
|
export async function ecrireReglages(fs, racine, reglages) {
|
||||||
|
await fs.ecrireAtomique(racine, FICHIER_REGLAGES, serialiserReglages(reglages));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rien = () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écrivain différé des réglages (§ 8.5). La première demande arme un
|
||||||
|
* minuteur de delaiMs ; celles qui suivent avant son terme remplacent les
|
||||||
|
* réglages en attente sans le réarmer : le délai est borné, et un zoom
|
||||||
|
* continu écrit une fois par délai. À son terme, ecrire reçoit les derniers
|
||||||
|
* réglages demandés. Les écritures s'enchaînent sans jamais se chevaucher :
|
||||||
|
* chacune attend la fin de la précédente.
|
||||||
|
*
|
||||||
|
* La dernière demande l'emporte sur tous les chemins. Chaque demande avance
|
||||||
|
* un compteur, que chaque écriture relève au moment où elle prend les
|
||||||
|
* réglages en attente. Une écriture refusée ne remet ses réglages en attente
|
||||||
|
* que quand rien n'attend et que le compteur n'a pas bougé depuis ce relevé ;
|
||||||
|
* la demande suivante, ou vider, les écrit alors. Une demande venue
|
||||||
|
* entre-temps, qu'elle attende encore ou que son écriture soit déjà lancée,
|
||||||
|
* est plus récente que les réglages refusés, qui ne reviennent pas.
|
||||||
|
*
|
||||||
|
* Le refus d'une écriture qu'a lancée le minuteur ne devient jamais un rejet
|
||||||
|
* que rien ne traite : il va à signaler quand il est donné, et se tait sinon.
|
||||||
|
* vider annule le minuteur, écrit tout de suite les réglages en attente et
|
||||||
|
* rend la promesse de cette écriture, qui rejette sur son refus ; sans
|
||||||
|
* réglages en attente, il n'écrit rien et rend une promesse que la fin
|
||||||
|
* de l'écriture en cours résout.
|
||||||
|
*
|
||||||
|
* @param {Object} parametres
|
||||||
|
* @param {(reglages: ReglagesLocaux) => Promise<void>} parametres.ecrire
|
||||||
|
* @param {(fonction: () => void, delaiMs: number) => *} parametres.planifier
|
||||||
|
* arme un minuteur et rend de quoi l'annuler
|
||||||
|
* @param {(minuteur: *) => void} parametres.annuler
|
||||||
|
* @param {number} parametres.delaiMs nombre fini ≥ 0
|
||||||
|
* @param {((erreur: *) => void)|null} [parametres.signaler]
|
||||||
|
* @returns {{demander: (reglages: ReglagesLocaux) => void, vider: () => Promise<void>}}
|
||||||
|
* @throws {TypeError} un paramètre hors de sa forme
|
||||||
|
*/
|
||||||
|
export function creerEcrivainDiffere({ ecrire, planifier, annuler, delaiMs, signaler = null }) {
|
||||||
|
for (const [nom, fonction] of [
|
||||||
|
['ecrire', ecrire],
|
||||||
|
['planifier', planifier],
|
||||||
|
['annuler', annuler],
|
||||||
|
]) {
|
||||||
|
if (typeof fonction !== 'function') throw new TypeError(`creerEcrivainDiffere : ${nom} n'est pas une fonction`);
|
||||||
|
}
|
||||||
|
if (signaler !== null && typeof signaler !== 'function') {
|
||||||
|
throw new TypeError("creerEcrivainDiffere : signaler n'est pas une fonction");
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(delaiMs) || delaiMs < 0) {
|
||||||
|
throw new TypeError(`creerEcrivainDiffere : délai fini ≥ 0 attendu, reçu ${JSON.stringify(delaiMs)}`);
|
||||||
|
}
|
||||||
|
// Les réglages à écrire, ou null ; le nombre de demandes reçues ; le
|
||||||
|
// minuteur armé, ou null ; la fin de la dernière écriture lancée, qui ne
|
||||||
|
// rejette jamais.
|
||||||
|
let attente = null;
|
||||||
|
let demandes = 0;
|
||||||
|
let minuteur = null;
|
||||||
|
let file = Promise.resolve();
|
||||||
|
|
||||||
|
// Lance l'écriture des réglages en attente à la suite de la précédente, et
|
||||||
|
// relève le nombre de demandes au moment où elle les prend. Rend sa
|
||||||
|
// promesse, qui rejette sur son refus après avoir remis ces réglages en
|
||||||
|
// attente quand rien n'attend et qu'aucune demande n'a suivi ce relevé.
|
||||||
|
function ecrireEnAttente() {
|
||||||
|
const reglages = attente;
|
||||||
|
const releve = demandes;
|
||||||
|
attente = null;
|
||||||
|
const ecriture = file.then(() => ecrire(reglages));
|
||||||
|
file = ecriture.then(rien, rien);
|
||||||
|
return ecriture.then(rien, (erreur) => {
|
||||||
|
if (attente === null && demandes === releve) attente = reglages;
|
||||||
|
throw erreur;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
demander(reglages) {
|
||||||
|
demandes += 1;
|
||||||
|
attente = reglages;
|
||||||
|
if (minuteur !== null) return;
|
||||||
|
minuteur = planifier(() => {
|
||||||
|
minuteur = null;
|
||||||
|
ecrireEnAttente().catch((erreur) => {
|
||||||
|
if (signaler !== null) signaler(erreur);
|
||||||
|
});
|
||||||
|
}, delaiMs);
|
||||||
|
},
|
||||||
|
vider() {
|
||||||
|
if (minuteur !== null) {
|
||||||
|
annuler(minuteur);
|
||||||
|
minuteur = null;
|
||||||
|
}
|
||||||
|
return attente === null ? file : ecrireEnAttente();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
437
src/stockage/reglages_locaux.test.js
Normal file
437
src/stockage/reglages_locaux.test.js
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
// Épreuves des réglages locaux (§ 8.5) : la lecture, jamais bloquante, qui
|
||||||
|
// rend les défauts et un avertissement pour un fichier illisible ; la forme
|
||||||
|
// canonique, qui garde les clés inconnues d'une construction plus récente ;
|
||||||
|
// l'écriture atomique à la racine ; l'écrivain différé, à délai borné, sous
|
||||||
|
// un planificateur factice. Les réglages se lisent et s'écrivent sur le
|
||||||
|
// système de fichiers d'épreuve.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||||
|
import { describe, test } from '../../test/lanceur.js';
|
||||||
|
import { ErreurStockage } from './erreurs.js';
|
||||||
|
import {
|
||||||
|
FICHIER_REGLAGES,
|
||||||
|
avecCadrage,
|
||||||
|
avecPapier,
|
||||||
|
cadrageDe,
|
||||||
|
creerEcrivainDiffere,
|
||||||
|
ecrireReglages,
|
||||||
|
lireReglages,
|
||||||
|
serialiserReglages,
|
||||||
|
} from './reglages_locaux.js';
|
||||||
|
|
||||||
|
const DOC = { id: 'documents', chemin: 'C:\\Users\\Exemple\\Documents\\Gestion table tournante Libre' };
|
||||||
|
|
||||||
|
// Les réglages d'un dossier qui n'en a pas : ni papier choisi, ni cadrage.
|
||||||
|
const DEFAUTS = Object.freeze({ format: 1, papier: null, cadrages: Object.freeze([]) });
|
||||||
|
|
||||||
|
// Un disque neuf dont la racine documents existe, et son premier processus ;
|
||||||
|
// texte, quand il est donné, est le fichier des réglages.
|
||||||
|
function disque(texte = null) {
|
||||||
|
const fs = creerFichiersSimules({ disque: new Map() });
|
||||||
|
fs.deposerDossier('documents');
|
||||||
|
if (texte !== null) fs.deposer('documents', FICHIER_REGLAGES, texte);
|
||||||
|
return fs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'avertissement d'un fichier illisible, de raison donnée.
|
||||||
|
const illisibles = (raison) => ({ code: 'REGLAGES_ILLISIBLES', details: { chemin: FICHIER_REGLAGES, raison } });
|
||||||
|
|
||||||
|
// Trois réglages successifs : le papier, puis un cadrage, puis ce cadrage
|
||||||
|
// zoomé et déplacé.
|
||||||
|
const R1 = { format: 1, papier: 'A4', cadrages: [] };
|
||||||
|
const R2 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 1, tx: 0, ty: 0 }] };
|
||||||
|
const R3 = { format: 1, papier: 'A4', cadrages: [{ evenement: 'evt-a', k: 2, tx: -40, ty: 15 }] };
|
||||||
|
|
||||||
|
// Un planificateur factice : planifier inscrit un minuteur et rend son
|
||||||
|
// numéro, annuler le retire, echoir déclenche ceux qui restent, dans l'ordre
|
||||||
|
// de leur armement ; armements et annulations comptent les appels.
|
||||||
|
function planificateur() {
|
||||||
|
const minuteurs = new Map();
|
||||||
|
const compte = { armements: 0, annulations: 0 };
|
||||||
|
return {
|
||||||
|
compte,
|
||||||
|
planifier: (fonction, delai) => {
|
||||||
|
compte.armements += 1;
|
||||||
|
minuteurs.set(compte.armements, { fonction, delai });
|
||||||
|
return compte.armements;
|
||||||
|
},
|
||||||
|
annuler: (numero) => {
|
||||||
|
compte.annulations += 1;
|
||||||
|
minuteurs.delete(numero);
|
||||||
|
},
|
||||||
|
echoir: () => {
|
||||||
|
const echus = [...minuteurs.values()];
|
||||||
|
minuteurs.clear();
|
||||||
|
for (const { fonction } of echus) fonction();
|
||||||
|
},
|
||||||
|
delais: () => [...minuteurs.values()].map(({ delai }) => delai),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laisse s'écouler les promesses en attente et les rejets non traités.
|
||||||
|
const attendre = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Exécute travail en relevant chaque rejet de promesse que rien ne traite ;
|
||||||
|
// rend ce relevé.
|
||||||
|
async function rejetsNonTraites(travail) {
|
||||||
|
const rejets = [];
|
||||||
|
const ecouteur = (raison) => rejets.push(raison);
|
||||||
|
process.on('unhandledRejection', ecouteur);
|
||||||
|
try {
|
||||||
|
await travail();
|
||||||
|
await attendre();
|
||||||
|
await attendre();
|
||||||
|
} finally {
|
||||||
|
process.off('unhandledRejection', ecouteur);
|
||||||
|
}
|
||||||
|
return rejets;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('réglages locaux : lire, jamais bloquant (§ 8.5)', () => {
|
||||||
|
test('un fichier absent rend les défauts, sans avertissement ni écriture', async () => {
|
||||||
|
const fs = disque();
|
||||||
|
assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: null });
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fichier illisible rend les défauts et REGLAGES_ILLISIBLES, sa raison nommée, sans lever ni écrire', async () => {
|
||||||
|
const cadrage = (plus) => JSON.stringify({ evenement: 'evt-a', k: 1, tx: 0, ty: 0, ...plus });
|
||||||
|
const avecCadrages = (...cadrages) => `{"cadrages":[${cadrages.join(',')}],"format":1,"papier":null}\n`;
|
||||||
|
// Par cas : le texte du fichier, et la raison de l'avertissement.
|
||||||
|
const cas = [
|
||||||
|
['un texte vide', '', 'JSON'],
|
||||||
|
['du JSON invalide', '{"papier":', 'JSON'],
|
||||||
|
['une liste', '[]\n', 'FORME'],
|
||||||
|
['un format absent', '{"cadrages":[],"papier":null}\n', 'FORME'],
|
||||||
|
['un format non entier', '{"cadrages":[],"format":1.5,"papier":null}\n', 'FORME'],
|
||||||
|
['un format nul', '{"cadrages":[],"format":0,"papier":null}\n', 'FORME'],
|
||||||
|
['un papier absent', '{"cadrages":[],"format":1}\n', 'FORME'],
|
||||||
|
['un papier inconnu', '{"cadrages":[],"format":1,"papier":"A3"}\n', 'FORME'],
|
||||||
|
['des cadrages absents', '{"format":1,"papier":null}\n', 'FORME'],
|
||||||
|
['un cadrage qui n’est pas un objet', avecCadrages('[]'), 'FORME'],
|
||||||
|
['un événement vide', avecCadrages(cadrage({ evenement: '' })), 'FORME'],
|
||||||
|
['un zoom nul', avecCadrages(cadrage({ k: 0 })), 'FORME'],
|
||||||
|
['un zoom négatif', avecCadrages(cadrage({ k: -1 })), 'FORME'],
|
||||||
|
['une translation en texte', avecCadrages(cadrage({ tx: '10' })), 'FORME'],
|
||||||
|
['une translation nulle', avecCadrages(cadrage({ ty: null })), 'FORME'],
|
||||||
|
['un cadrage en double', avecCadrages(cadrage({}), cadrage({ k: 2 })), 'FORME'],
|
||||||
|
];
|
||||||
|
for (const [nom, texte, raison] of cas) {
|
||||||
|
const fs = disque(texte);
|
||||||
|
assert.deepEqual(
|
||||||
|
await lireReglages(fs, DOC),
|
||||||
|
{ reglages: DEFAUTS, avertissement: illisibles(raison) },
|
||||||
|
nom,
|
||||||
|
);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0, nom);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une lecture refusée par le système de fichiers donne les défauts et son code pour raison ; une panne du processus remonte', async () => {
|
||||||
|
const fs = disque('{"cadrages":[],"format":1,"papier":"A4"}\n');
|
||||||
|
fs.lireTexte = async (racine, chemin) => {
|
||||||
|
throw new ErreurStockage('LECTURE', { chemin, dossier: racine.chemin, cause: 'EACCES' });
|
||||||
|
};
|
||||||
|
assert.deepEqual(await lireReglages(fs, DOC), { reglages: DEFAUTS, avertissement: illisibles('LECTURE') });
|
||||||
|
|
||||||
|
const coupe = disque('{"cadrages":[],"format":1,"papier":"A4"}\n');
|
||||||
|
coupe.pannes.couperApres(0);
|
||||||
|
await assert.rejects(lireReglages(coupe, DOC), PanneSimulee);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un fichier lisible rend son papier et ses cadrages triés par événement, ses clés inconnues et son format gardés ; une marque d’ordre d’octets en tête est admise', async () => {
|
||||||
|
const texte =
|
||||||
|
'\u{FEFF}{"format":2,"papier":"Lettre","theme":"sombre","cadrages":[' +
|
||||||
|
'{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20,"rotation":90},' +
|
||||||
|
'{"evenement":"evt-a","k":1.25,"tx":0,"ty":0}]}\n';
|
||||||
|
const fs = disque(texte);
|
||||||
|
const { reglages, avertissement } = await lireReglages(fs, DOC);
|
||||||
|
assert.equal(avertissement, null);
|
||||||
|
assert.deepEqual(reglages, {
|
||||||
|
format: 2,
|
||||||
|
papier: 'Lettre',
|
||||||
|
theme: 'sombre',
|
||||||
|
cadrages: [
|
||||||
|
{ evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 },
|
||||||
|
{ evenement: 'evt-b', k: 0.5, tx: 10, ty: -20, rotation: 90 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(cadrageDe(reglages, 'evt-b'), { k: 0.5, tx: 10, ty: -20 });
|
||||||
|
assert.equal(cadrageDe(reglages, 'evt-c'), null);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('réglages locaux : la forme canonique et l’écriture', () => {
|
||||||
|
test('serialiserReglages range les clés à chaque niveau et les cadrages par identifiant d’événement, et finit sur une fin de ligne : deux constructions dans deux ordres donnent le même texte', () => {
|
||||||
|
const attendu =
|
||||||
|
'{"cadrages":[{"evenement":"evt-a","k":1.25,"tx":0,"ty":0},' +
|
||||||
|
'{"evenement":"evt-b","k":0.5,"tx":10,"ty":-20}],"format":1,"papier":"A4"}\n';
|
||||||
|
const a = avecCadrage(
|
||||||
|
avecCadrage(avecPapier(DEFAUTS, 'A4'), 'evt-b', { k: 0.5, tx: 10, ty: -20 }),
|
||||||
|
'evt-a',
|
||||||
|
{ k: 1.25, tx: 0, ty: 0 },
|
||||||
|
);
|
||||||
|
const b = avecPapier(
|
||||||
|
avecCadrage(avecCadrage(DEFAUTS, 'evt-a', { k: 1.25, tx: 0, ty: 0 }), 'evt-b', { k: 0.5, tx: 10, ty: -20 }),
|
||||||
|
'A4',
|
||||||
|
);
|
||||||
|
const aLaMain = {
|
||||||
|
papier: 'A4',
|
||||||
|
format: 1,
|
||||||
|
cadrages: [
|
||||||
|
{ ty: -20, tx: 10, k: 0.5, evenement: 'evt-b' },
|
||||||
|
{ evenement: 'evt-a', k: 1.25, tx: 0, ty: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
for (const reglages of [a, b, aLaMain]) assert.equal(serialiserReglages(reglages), attendu);
|
||||||
|
assert.equal(
|
||||||
|
serialiserReglages({ zeta: { b: 1, a: [2, { d: 3, c: 4 }] }, ...DEFAUTS }),
|
||||||
|
'{"cadrages":[],"format":1,"papier":null,"zeta":{"a":[2,{"c":4,"d":3}],"b":1}}\n',
|
||||||
|
);
|
||||||
|
assert.throws(() => serialiserReglages({ ...DEFAUTS, papier: 'A3' }), TypeError);
|
||||||
|
const zoomIndefini = { evenement: 'evt-a', k: Number.NaN, tx: 0, ty: 0 };
|
||||||
|
assert.throws(() => serialiserReglages({ ...DEFAUTS, cadrages: [zoomIndefini] }), TypeError);
|
||||||
|
assert.throws(() => serialiserReglages(null), TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("avecCadrage remplace le cadrage d'un événement ou l'ajoute à son rang, avecPapier pose le papier, sans modifier les réglages reçus ; une valeur hors de sa règle lève TypeError", () => {
|
||||||
|
const depart = avecCadrage(DEFAUTS, 'evt-b', { k: 0.5, tx: 10, ty: -20 });
|
||||||
|
const copie = structuredClone(depart);
|
||||||
|
const ajoute = avecCadrage(depart, 'evt-a', { k: 1, tx: 1, ty: 1 });
|
||||||
|
const remplace = avecCadrage(ajoute, 'evt-b', { k: 2, tx: 0, ty: 0 });
|
||||||
|
assert.deepEqual(depart, copie);
|
||||||
|
assert.deepEqual(DEFAUTS, { format: 1, papier: null, cadrages: [] });
|
||||||
|
assert.deepEqual(remplace.cadrages, [
|
||||||
|
{ evenement: 'evt-a', k: 1, tx: 1, ty: 1 },
|
||||||
|
{ evenement: 'evt-b', k: 2, tx: 0, ty: 0 },
|
||||||
|
]);
|
||||||
|
const lettre = avecPapier(remplace, 'Lettre');
|
||||||
|
assert.deepEqual([remplace.papier, lettre.papier, avecPapier(lettre, null).papier], [null, 'Lettre', null]);
|
||||||
|
assert.deepEqual(lettre.cadrages, remplace.cadrages);
|
||||||
|
const refus = [
|
||||||
|
['un zoom nul', () => avecCadrage(DEFAUTS, 'evt-a', { k: 0, tx: 0, ty: 0 })],
|
||||||
|
['un zoom négatif', () => avecCadrage(DEFAUTS, 'evt-a', { k: -1, tx: 0, ty: 0 })],
|
||||||
|
['une translation infinie', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: Number.POSITIVE_INFINITY, ty: 0 })],
|
||||||
|
['une translation absente', () => avecCadrage(DEFAUTS, 'evt-a', { k: 1, tx: 0 })],
|
||||||
|
['un événement vide', () => avecCadrage(DEFAUTS, '', { k: 1, tx: 0, ty: 0 })],
|
||||||
|
['un papier inconnu', () => avecPapier(DEFAUTS, 'A3')],
|
||||||
|
];
|
||||||
|
for (const [nom, appel] of refus) assert.throws(appel, TypeError, nom);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ecrireReglages écrit le texte canonique par une seule écriture atomique à la racine, sans créer un dossier de travail qui manque", async () => {
|
||||||
|
const fs = disque();
|
||||||
|
const ecritures = [];
|
||||||
|
const ecrire = fs.ecrireAtomique;
|
||||||
|
fs.ecrireAtomique = (racine, chemin, texte) => {
|
||||||
|
ecritures.push(chemin);
|
||||||
|
return ecrire(racine, chemin, texte);
|
||||||
|
};
|
||||||
|
await ecrireReglages(fs, DOC, R3);
|
||||||
|
assert.deepEqual(ecritures, [FICHIER_REGLAGES]);
|
||||||
|
assert.equal(fs.compteurs.ecritures, 1);
|
||||||
|
assert.equal(fs.contenu('documents', FICHIER_REGLAGES), serialiserReglages(R3));
|
||||||
|
assert.deepEqual(await lireReglages(fs, DOC), { reglages: R3, avertissement: null });
|
||||||
|
|
||||||
|
const sansDossier = creerFichiersSimules({ disque: new Map() });
|
||||||
|
await assert.rejects(ecrireReglages(sansDossier, DOC, R1), (erreur) => erreur.code === 'ECRITURE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une clé inconnue d'un fichier lisible survit à la réécriture, à tout niveau, son format compris : le réglage d'une construction plus récente reste", async () => {
|
||||||
|
const texte =
|
||||||
|
'{"cadrages":[{"evenement":"evt-a","k":1,"rotation":90,"tx":0,"ty":0}],' +
|
||||||
|
'"format":2,"papier":null,"theme":"sombre"}\n';
|
||||||
|
const fs = disque(texte);
|
||||||
|
const { reglages } = await lireReglages(fs, DOC);
|
||||||
|
await ecrireReglages(fs, DOC, avecPapier(reglages, 'A4'));
|
||||||
|
assert.equal(fs.contenu('documents', FICHIER_REGLAGES), texte.replace('"papier":null', '"papier":"A4"'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('réglages locaux : l’écriture différée (§ 8.2, § 8.5)', () => {
|
||||||
|
test('trois demandes rapprochées arment un seul minuteur, jamais réarmé, et donnent une écriture, la dernière ; une demande après elle en arme un autre', async () => {
|
||||||
|
const ecrits = [];
|
||||||
|
const minuterie = planificateur();
|
||||||
|
const ecrivain = creerEcrivainDiffere({
|
||||||
|
ecrire: async (reglages) => {
|
||||||
|
ecrits.push(reglages);
|
||||||
|
},
|
||||||
|
planifier: minuterie.planifier,
|
||||||
|
annuler: minuterie.annuler,
|
||||||
|
delaiMs: 500,
|
||||||
|
});
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
ecrivain.demander(R2);
|
||||||
|
ecrivain.demander(R3);
|
||||||
|
assert.deepEqual(minuterie.compte, { armements: 1, annulations: 0 });
|
||||||
|
assert.deepEqual(minuterie.delais(), [500]);
|
||||||
|
assert.deepEqual(ecrits, []);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual(ecrits, [R3]);
|
||||||
|
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
assert.deepEqual(minuterie.compte, { armements: 2, annulations: 0 });
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual(ecrits, [R3, R1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vider annule le minuteur et écrit tout de suite la demande en attente, et rend la promesse de l'écriture ; sans demande en attente, il n'écrit rien", async () => {
|
||||||
|
const ecrits = [];
|
||||||
|
const minuterie = planificateur();
|
||||||
|
const ecrivain = creerEcrivainDiffere({
|
||||||
|
ecrire: async (reglages) => {
|
||||||
|
ecrits.push(reglages);
|
||||||
|
},
|
||||||
|
planifier: minuterie.planifier,
|
||||||
|
annuler: minuterie.annuler,
|
||||||
|
delaiMs: 500,
|
||||||
|
});
|
||||||
|
await ecrivain.vider();
|
||||||
|
assert.deepEqual([ecrits, minuterie.compte], [[], { armements: 0, annulations: 0 }]);
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
ecrivain.demander(R2);
|
||||||
|
const promesse = ecrivain.vider();
|
||||||
|
assert.deepEqual([minuterie.delais(), minuterie.compte.annulations], [[], 1]);
|
||||||
|
await promesse;
|
||||||
|
assert.deepEqual(ecrits, [R2]);
|
||||||
|
minuterie.echoir();
|
||||||
|
await ecrivain.vider();
|
||||||
|
assert.deepEqual(ecrits, [R2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une écriture refusée au terme du minuteur va au signaleur, jamais en rejet non traité ; les réglages restent en attente pour la demande suivante ou pour vider, qui rejette à son tour", async () => {
|
||||||
|
let refuser = true;
|
||||||
|
const ecrits = [];
|
||||||
|
const signales = [];
|
||||||
|
const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EROFS' });
|
||||||
|
const ecrire = async (reglages) => {
|
||||||
|
if (refuser) throw refus;
|
||||||
|
ecrits.push(reglages);
|
||||||
|
};
|
||||||
|
const minuterie = planificateur();
|
||||||
|
const commun = { ecrire, planifier: minuterie.planifier, annuler: minuterie.annuler, delaiMs: 500 };
|
||||||
|
const rejets = await rejetsNonTraites(async () => {
|
||||||
|
const ecrivain = creerEcrivainDiffere({ ...commun, signaler: (erreur) => signales.push(erreur) });
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual([signales, ecrits], [[refus], []]);
|
||||||
|
await assert.rejects(ecrivain.vider(), (erreur) => erreur === refus);
|
||||||
|
refuser = false;
|
||||||
|
await ecrivain.vider();
|
||||||
|
assert.deepEqual(ecrits, [R1]);
|
||||||
|
|
||||||
|
// Sans signaleur, le refus se tait ; la demande suivante remplace
|
||||||
|
// celle qui attend, et l'écrit.
|
||||||
|
const muet = creerEcrivainDiffere(commun);
|
||||||
|
refuser = true;
|
||||||
|
muet.demander(R2);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
refuser = false;
|
||||||
|
muet.demander(R3);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual(ecrits, [R1, R3]);
|
||||||
|
});
|
||||||
|
assert.deepEqual(rejets, []);
|
||||||
|
assert.equal(signales.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("les écritures ne se chevauchent jamais : celle d'une demande suivante attend la fin de la précédente, et vider attend celle en cours", async () => {
|
||||||
|
const appels = [];
|
||||||
|
const liberations = [];
|
||||||
|
const minuterie = planificateur();
|
||||||
|
const ecrivain = creerEcrivainDiffere({
|
||||||
|
ecrire: (reglages) => {
|
||||||
|
appels.push(reglages);
|
||||||
|
return new Promise((resolve) => liberations.push(resolve));
|
||||||
|
},
|
||||||
|
planifier: minuterie.planifier,
|
||||||
|
annuler: minuterie.annuler,
|
||||||
|
delaiMs: 500,
|
||||||
|
});
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
ecrivain.demander(R2);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual(appels, [R1]);
|
||||||
|
liberations[0]();
|
||||||
|
await attendre();
|
||||||
|
assert.deepEqual(appels, [R1, R2]);
|
||||||
|
let fini = false;
|
||||||
|
const fin = ecrivain.vider().then(() => {
|
||||||
|
fini = true;
|
||||||
|
});
|
||||||
|
await attendre();
|
||||||
|
assert.equal(fini, false);
|
||||||
|
liberations[1]();
|
||||||
|
await fin;
|
||||||
|
assert.equal(fini, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la dernière demande l'emporte : une écriture refusée pendant que la suivante attend la fin ne revient pas en attente, et vider n'écrit ensuite rien de plus", async () => {
|
||||||
|
// Les deux premières écritures se tiennent jusqu'à ce que l'épreuve les
|
||||||
|
// libère ou les refuse ; les suivantes réussissent tout de suite. ecrits
|
||||||
|
// reçoit les réglages de chaque écriture réussie.
|
||||||
|
const ecrits = [];
|
||||||
|
const tenues = [];
|
||||||
|
const signales = [];
|
||||||
|
const refus = new ErreurStockage('ECRITURE', { chemin: FICHIER_REGLAGES, dossier: DOC.chemin, cause: 'EBUSY' });
|
||||||
|
const minuterie = planificateur();
|
||||||
|
const rejets = await rejetsNonTraites(async () => {
|
||||||
|
const ecrivain = creerEcrivainDiffere({
|
||||||
|
ecrire: (reglages) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const liberer = () => {
|
||||||
|
ecrits.push(reglages);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
if (tenues.length < 2) tenues.push({ liberer, refuser: reject });
|
||||||
|
else liberer();
|
||||||
|
}),
|
||||||
|
planifier: minuterie.planifier,
|
||||||
|
annuler: minuterie.annuler,
|
||||||
|
delaiMs: 500,
|
||||||
|
signaler: (erreur) => signales.push(erreur),
|
||||||
|
});
|
||||||
|
ecrivain.demander(R1);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
ecrivain.demander(R2);
|
||||||
|
minuterie.echoir();
|
||||||
|
await attendre();
|
||||||
|
assert.equal(tenues.length, 1);
|
||||||
|
tenues[0].refuser(refus);
|
||||||
|
await attendre();
|
||||||
|
assert.equal(tenues.length, 2);
|
||||||
|
tenues[1].liberer();
|
||||||
|
await ecrivain.vider();
|
||||||
|
assert.deepEqual(ecrits, [R2]);
|
||||||
|
assert.deepEqual(minuterie.delais(), []);
|
||||||
|
});
|
||||||
|
assert.deepEqual([rejets, signales], [[], [refus]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creerEcrivainDiffere refuse des paramètres hors de leur forme, TypeError", () => {
|
||||||
|
const valides = { ecrire: async () => {}, planifier: () => 1, annuler: () => {}, delaiMs: 500 };
|
||||||
|
const refus = [
|
||||||
|
['ecrire absent', { ...valides, ecrire: undefined }],
|
||||||
|
['planifier absent', { ...valides, planifier: null }],
|
||||||
|
['annuler absent', { ...valides, annuler: 'non' }],
|
||||||
|
['un délai négatif', { ...valides, delaiMs: -1 }],
|
||||||
|
['un délai qui n’est pas un nombre', { ...valides, delaiMs: '500' }],
|
||||||
|
['un signaleur qui n’est pas une fonction', { ...valides, signaler: 1 }],
|
||||||
|
];
|
||||||
|
for (const [nom, parametres] of refus) assert.throws(() => creerEcrivainDiffere(parametres), TypeError, nom);
|
||||||
|
assert.equal(typeof creerEcrivainDiffere({ ...valides, signaler: null }).demander, 'function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -109,7 +109,11 @@ import { ErreurStockage } from './erreurs.js';
|
||||||
* les entrées directes du dossier, triées par nom, unités UTF-16
|
* les entrées directes du dossier, triées par nom, unités UTF-16
|
||||||
* comparées une à une ; ABSENT pour un dossier absent
|
* comparées une à une ; ABSENT pour un dossier absent
|
||||||
* @property {(r: Racine, chemin: string) => Promise<void>} creerDossier
|
* @property {(r: Racine, chemin: string) => Promise<void>} creerDossier
|
||||||
* parents compris ; un dossier existant n'est pas une faute
|
* '' crée la racine elle-même, parents compris ; un autre chemin
|
||||||
|
* crée ce qui lui manque sous la racine, qui doit exister : une
|
||||||
|
* racine absente lève ECRITURE, sans rien créer, car un dossier de
|
||||||
|
* travail disparu ne renaît pas en silence (§ 8.6). Un dossier
|
||||||
|
* existant n'est pas une faute
|
||||||
* @property {(r: Racine, de: string, vers: string) => Promise<void>} deplacer
|
* @property {(r: Racine, de: string, vers: string) => Promise<void>} deplacer
|
||||||
* un fichier ; refuse une cible existante (EXISTE), comparée comme le
|
* un fichier ; refuse une cible existante (EXISTE), comparée comme le
|
||||||
* système compare les noms, si bien que sans égard à la casse une
|
* système compare les noms, si bien que sans égard à la casse une
|
||||||
|
|
|
||||||
|
|
@ -312,11 +312,21 @@
|
||||||
* @property {string} hote
|
* @property {string} hote
|
||||||
* @property {string} depuis
|
* @property {string} depuis
|
||||||
*
|
*
|
||||||
* @typedef {Object} ReglagesLocaux reglages_locaux.json
|
* @typedef {Object} ReglagesLocaux reglages_locaux.json (reglages_locaux.js),
|
||||||
* @property {number} format
|
* JSON sur une ligne, les clés de chaque objet rangées par unités
|
||||||
|
* UTF-16 ; une clé inconnue, à tout niveau, se garde à la réécriture
|
||||||
|
* @property {number} format entier ≥ 1, gardé tel que lu
|
||||||
* @property {null|'A4'|'Lettre'} papier
|
* @property {null|'A4'|'Lettre'} papier
|
||||||
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
||||||
* triés par identifiant d'événement
|
* triés par identifiant d'événement, un par événement ; k fini
|
||||||
|
* > 0, tx et ty finis
|
||||||
|
*
|
||||||
|
* Avertissement de la lecture des réglages locaux, qui ne lève jamais :
|
||||||
|
*
|
||||||
|
* REGLAGES_ILLISIBLES {chemin, raison} le fichier ne se lit pas, et
|
||||||
|
* les défauts s'appliquent ;
|
||||||
|
* raison JSON, FORME, ou le code
|
||||||
|
* du refus de lecture
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,22 @@ describe('coquille sur un vrai disque', () => {
|
||||||
assert.deepEqual(readdirSync(portable), ['garde.txt']);
|
assert.deepEqual(readdirSync(portable), ['garde.txt']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testNettoye("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||||
|
const dossier = dossierTemporaire();
|
||||||
|
const { fs, portable } = systemeSur(dossier, { publie: join('cle', 'soirees') });
|
||||||
|
const racine = (await fs.racines()).portable;
|
||||||
|
await echoue(fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
|
||||||
|
chemin: 'corbeille/2026-01-02_03-04-05',
|
||||||
|
dossier: join(portable, 'corbeille'),
|
||||||
|
cause: 'ENOENT',
|
||||||
|
});
|
||||||
|
assert.deepEqual(readdirSync(dossier), []);
|
||||||
|
await fs.creerDossier(racine, '');
|
||||||
|
assert.deepEqual(readdirSync(portable), []);
|
||||||
|
await fs.creerDossier(racine, 'corbeille/2026-01-02_03-04-05');
|
||||||
|
assert.deepEqual(readdirSync(join(portable, 'corbeille')), ['2026-01-02_03-04-05']);
|
||||||
|
});
|
||||||
|
|
||||||
testNettoye(
|
testNettoye(
|
||||||
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
|
"un dossier en lecture seule fait échouer la sonde sur EACCES, qu'elle doive y écrire ou y créer la racine",
|
||||||
{ skip: SOUS_ROOT || SOUS_WINDOWS },
|
{ skip: SOUS_ROOT || SOUS_WINDOWS },
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,10 @@ function erreurSysteme(code, syscall, chemin) {
|
||||||
* cible absolue. fs porte les fonctions de node:fs/promises dont la coquille
|
* cible absolue. fs porte les fonctions de node:fs/promises dont la coquille
|
||||||
* se sert et lève les erreurs du système dans les mêmes cas — ENOENT,
|
* se sert et lève les erreurs du système dans les mêmes cas — ENOENT,
|
||||||
* ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier
|
* ENOTDIR, EISDIR, EEXIST, ELOOP. Un lien se suit partout, sauf en dernier
|
||||||
* segment de lstat, unlink, rename et d'une ouverture exclusive (wx). Les
|
* segment de lstat, unlink, rename, mkdir sans recursive et d'une ouverture
|
||||||
* noms que rend readdir sortent dans l'ordre inverse de leur création : un
|
* exclusive (wx). mkdir sans recursive crée un seul dossier, son parent
|
||||||
* tri omis se voit.
|
* présent ; avec, tous ceux qui manquent. Les noms que rend readdir sortent
|
||||||
|
* dans l'ordre inverse de leur création : un tri omis se voit.
|
||||||
*
|
*
|
||||||
* appels chaque appel, « fonction chemin [drapeaux | cible] », les
|
* appels chaque appel, « fonction chemin [drapeaux | cible] », les
|
||||||
* écritures d'une poignée comprises (write, sync, close)
|
* écritures d'une poignée comprises (write, sync, close)
|
||||||
|
|
@ -229,7 +230,13 @@ function creerDisque() {
|
||||||
},
|
},
|
||||||
async mkdir(chemin, options) {
|
async mkdir(chemin, options) {
|
||||||
appel('mkdir', chemin);
|
appel('mkdir', chemin);
|
||||||
assert.deepEqual(options, { recursive: true }, 'mkdir sans recursive');
|
if (options === undefined) {
|
||||||
|
const { chemin: resolu, noeud } = resoudre(chemin, false, 'mkdir');
|
||||||
|
if (noeud !== undefined) throw erreurSysteme('EEXIST', 'mkdir', chemin);
|
||||||
|
creer(resolu, 'dossier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.deepEqual(options, { recursive: true }, 'mkdir aux options inconnues');
|
||||||
const segments = chemin.split('/').filter((segment) => segment !== '');
|
const segments = chemin.split('/').filter((segment) => segment !== '');
|
||||||
let courant = '/';
|
let courant = '/';
|
||||||
for (let i = 0; i < segments.length; i += 1) {
|
for (let i = 0; i < segments.length; i += 1) {
|
||||||
|
|
@ -746,6 +753,20 @@ describe('coquille, fichiers : lecture, liste, dossiers, déplacement, suppressi
|
||||||
await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR'));
|
await refuse(fichiers.creerDossier(DOC, 'occupe/sous'), 'ECRITURE', ecriture('occupe/sous', `${DOCUMENTS}/occupe`, 'ENOTDIR'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||||
|
const { disque, fichiers } = preparer();
|
||||||
|
await refuse(
|
||||||
|
fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'),
|
||||||
|
'ECRITURE',
|
||||||
|
ecriture('corbeille/2026-01-02_03-04-05', `${DATA}/corbeille`, 'ENOENT'),
|
||||||
|
);
|
||||||
|
assert.equal(disque.existe(PUBLIE), false);
|
||||||
|
await fichiers.creerDossier(PORTABLE, '');
|
||||||
|
assert.deepEqual(disque.noms(DATA), []);
|
||||||
|
await fichiers.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05');
|
||||||
|
assert.deepEqual(disque.noms(`${DATA}/corbeille`), ['2026-01-02_03-04-05']);
|
||||||
|
});
|
||||||
|
|
||||||
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
|
test("deplacer porte un fichier ; EXISTE pour une cible présente, ABSENT pour une source absente, ECRITURE pour un dossier, un dossier de cible absent ou un renommage refusé, essayé une fois", async () => {
|
||||||
const { disque, fichiers, pauses } = preparer();
|
const { disque, fichiers, pauses } = preparer();
|
||||||
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
disque.deposer(`${DOCUMENTS}/soiree.gtt.json`, '\u{E9}tat');
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,11 @@
|
||||||
// quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et
|
// quand la casse ne compte pas ; à côté de lui, hors de la Map, son horloge et
|
||||||
// sa table des processus. Les pannes et les compteurs sont ceux de l'instance.
|
// sa table des processus. Les pannes et les compteurs sont ceux de l'instance.
|
||||||
//
|
//
|
||||||
// Une racine n'existe qu'une fois créée — par sonder, creerDossier ou, sans
|
// Une racine n'existe qu'une fois créée — par sonder, creerDossier de '' ou,
|
||||||
// compter, deposerDossier —, et aucune écriture ne crée le dossier qui porte
|
// sans compter, deposerDossier —, et aucune écriture ne crée le dossier qui
|
||||||
// sa cible : l'épreuve voit ainsi le dossier de travail qu'aucun code n'a
|
// porte sa cible, ni creerDossier d'un autre chemin la racine : l'épreuve voit
|
||||||
// créé, ou qui a disparu, comme le verrait la coquille.
|
// ainsi le dossier de travail qu'aucun code n'a créé, ou qui a disparu, comme
|
||||||
|
// le verrait la coquille.
|
||||||
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
import { ErreurStockage } from '../src/stockage/erreurs.js';
|
||||||
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
|
import { exigerCheminRelatif } from '../src/stockage/systeme_fichiers.js';
|
||||||
|
|
||||||
|
|
@ -202,10 +203,10 @@ export function creerFichiersSimules({
|
||||||
const maintenant = () => etat.horloge;
|
const maintenant = () => etat.horloge;
|
||||||
|
|
||||||
// Ce que désigne un chemin relatif à une racine : son chemin absolu en
|
// Ce que désigne un chemin relatif à une racine : son chemin absolu en
|
||||||
// parties, la clé dans le disque de chacun de ses préfixes, sa clé et celle
|
// parties, la clé dans le disque de chacun de ses préfixes, sa clé, celle
|
||||||
// de son parent, son nom, et le dossier que nomme une ECRITURE — celui qui le
|
// de son parent et celle de la racine, son nom, et le dossier que nomme une
|
||||||
// porte, ou la racine elle-même pour ''. CHEMIN_REFUSE pour un chemin refusé
|
// ECRITURE — celui qui le porte, ou la racine elle-même pour ''.
|
||||||
// ou une racine inconnue.
|
// CHEMIN_REFUSE pour un chemin refusé ou une racine inconnue.
|
||||||
function localiser(racine, chemin) {
|
function localiser(racine, chemin) {
|
||||||
const segments = exigerCheminRelatif(chemin);
|
const segments = exigerCheminRelatif(chemin);
|
||||||
const base = racines.get(racine?.id);
|
const base = racines.get(racine?.id);
|
||||||
|
|
@ -221,6 +222,7 @@ export function creerFichiersSimules({
|
||||||
cleDe,
|
cleDe,
|
||||||
cle: cleDe(n),
|
cle: cleDe(n),
|
||||||
cleParent: n > 1 ? cleDe(n - 1) : null,
|
cleParent: n > 1 ? cleDe(n - 1) : null,
|
||||||
|
cleRacine: cleDe(n - segments.length),
|
||||||
nom: parties[n - 1],
|
nom: parties[n - 1],
|
||||||
dossier: absolu(segments.length > 0 ? n - 1 : n),
|
dossier: absolu(segments.length > 0 ? n - 1 : n),
|
||||||
};
|
};
|
||||||
|
|
@ -418,9 +420,12 @@ export function creerFichiersSimules({
|
||||||
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
|
return entrees.sort((a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// '' crée la racine, parents compris ; un autre chemin ne crée que ce qui
|
||||||
|
// manque sous elle, et une racine absente lève ECRITURE, ENOENT.
|
||||||
creerDossier: primitive('ecritures', (racine, chemin) => {
|
creerDossier: primitive('ecritures', (racine, chemin) => {
|
||||||
const loc = localiser(racine, chemin);
|
const loc = localiser(racine, chemin);
|
||||||
exigerInscriptible(loc);
|
exigerInscriptible(loc);
|
||||||
|
if (chemin !== '' && !disque.has(loc.cleRacine)) throw refus(loc, 'ENOENT');
|
||||||
creerDossiers(loc, loc.parties.length, avancer);
|
creerDossiers(loc, loc.parties.length, avancer);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -586,4 +586,18 @@ describe('fichiers simulés : préconditions et dossiers', () => {
|
||||||
);
|
);
|
||||||
assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']);
|
assert.deepEqual(noms(await fs.lister(DOC, '')), ['corbeille', 'soiree.gtt.json']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creerDossier de '' crée la racine, parents compris ; sous une racine absente, un autre chemin lève ECRITURE, ENOENT, sans rien créer", async () => {
|
||||||
|
const fs = creerFichiersSimules({ executable: 'E:\\soirees' });
|
||||||
|
await echoue(fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05'), 'ECRITURE', {
|
||||||
|
chemin: 'corbeille/2026-01-02_03-04-05',
|
||||||
|
dossier: `${DATA}\\corbeille`,
|
||||||
|
cause: 'ENOENT',
|
||||||
|
});
|
||||||
|
await echoue(fs.lister(PORTABLE, ''), 'ABSENT', { chemin: '' });
|
||||||
|
await fs.creerDossier(PORTABLE, '');
|
||||||
|
assert.deepEqual(await fs.lister(PORTABLE, ''), []);
|
||||||
|
await fs.creerDossier(PORTABLE, 'corbeille/2026-01-02_03-04-05');
|
||||||
|
assert.deepEqual(noms(await fs.lister(PORTABLE, 'corbeille')), ['2026-01-02_03-04-05']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue