The whole-iteration review found ways to lose or corrupt data. A state saved in another encoding opened with its accents turned to U+FFFD and was then overwritten; decoding is now strict, the state opens read-only, and a gesture from its secours first moves it to the trash. A journal decodes line by line, so one bad byte drops only the lines after it. A vanished journal is rewritten whole, never appended to. Deleting refuses an event another session holds; switching to writing rereads the disk; an import that changes exclu says which reservations it suspends. Checked: 1504 node, 35 browser, 29 node-long; depot.js, journal.js 100 %. --- FR --- [FIX] stockage, séance : UTF-8 strict, journal perdu, verrou, exclusions La revue d'ensemble a trouvé des façons de perdre ou d'abîmer des données. Un état enregistré dans un autre encodage s'ouvrait, ses accents changés en U+FFFD, puis s'écrasait ; le décodage est strict, l'état s'ouvre en lecture seule, et un geste depuis son secours le range d'abord à la corbeille. Le journal se décode ligne par ligne : un octet fautif n'écarte que la suite. Un journal disparu se réécrit en entier, jamais par ajout. Supprimer refuse un événement qu'une autre séance tient ; passer en écriture relit le disque ; un import qui change exclu dit quelles réservations il suspend. Vérifié : 1504 node, 35 navigateur, 29 node-long ; depot.js, journal.js 100 %. Assisted-by: Claude Opus 5.5
460 lines
19 KiB
JavaScript
460 lines
19 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
// Système de fichiers de la plateforme web (§ 13.1, § 13.4) : l'interface de
|
|
// systeme_fichiers.js portée par l'OPFS du navigateur, que
|
|
// navigator.storage.getDirectory() ouvre. Avec fichiers_electron.js, ce module
|
|
// est l'une des deux exceptions nommées de la frontière des couches : il
|
|
// touche navigator pour l'OPFS, et document pour le champ de fichier et le
|
|
// lien de téléchargement. Il n'y touche qu'à l'appel d'une primitive : le
|
|
// charger hors d'un navigateur ne lève rien.
|
|
//
|
|
// Une seule racine, documents : le dossier gestion_table_tournante_libre de
|
|
// l'OPFS, affiché « /gestion_table_tournante_libre ». Ni exécutable publié,
|
|
// ni données applicatives, ni racine portable ; le support est inconnu.
|
|
//
|
|
// La plateforme n'offre ni renommage atomique ni verrou (§ 8.8) et le dit :
|
|
// renommageAtomique et verrouDisponible sont faux, et l'application
|
|
// l'annonce au démarrage. ecrireAtomique et ajouterLigne écrivent par un flux
|
|
// de l'OPFS ouvert sur la cible (createWritable) : le contenu n'atteint la
|
|
// cible qu'à la fermeture du flux ; un échec avant elle abandonne le flux et
|
|
// retire le fichier que l'appel a créé, si bien que la cible reste telle
|
|
// qu'avant l'appel. Rien ne garantit ce que devient une fermeture
|
|
// interrompue. deplacer copie les octets de la source sous la cible, puis
|
|
// retire la source ; un échec du retrait retire la copie. verrouiller rend
|
|
// { pris: true } sans rien écrire, et deverrouiller n'efface rien.
|
|
//
|
|
// L'OPFS compare les noms à la lettre : la casse et la forme de normalisation
|
|
// distinguent deux fichiers, d'où insensibleCasse faux. Il ne date pas un
|
|
// dossier, que lister date de 0. Pendant l'écriture d'un flux, Chromium peut
|
|
// poser à côté de la cible un fichier d'échange, <nom>.crswap, que lister
|
|
// rend comme toute entrée ; un fichier qui disparaît entre la liste et sa
|
|
// lecture n'est pas rendu.
|
|
//
|
|
// L'OPFS et le fichier choisi lèvent des DOMException, que le module traduit
|
|
// en codes. Une lecture qui ne trouve pas l'entrée — NotFoundError, ou
|
|
// TypeMismatchError pour une entrée d'un autre genre que celui demandé —
|
|
// lève ABSENT ; toute autre exception d'une lecture lève LECTURE, car une
|
|
// panne n'est pas une absence ; tout échec d'une écriture lève ECRITURE. Les
|
|
// deux portent {chemin, dossier, cause}, dont la cause est le nom de
|
|
// l'exception : SecurityError pour un OPFS que le navigateur refuse à
|
|
// l'origine, NotReadableError pour un fichier qui change pendant qu'on le
|
|
// lit, NotFoundError pour une écriture dans un dossier absent,
|
|
// TypeMismatchError, QuotaExceededError… Le chemin '' désigne la racine
|
|
// elle-même, un dossier.
|
|
//
|
|
// Sous web, ni explorateur ni dialogue de dossier : ouvrirDansExplorateur et
|
|
// choisirDossier lèvent NON_DISPONIBLE {primitive}. choisirFichierAImporter
|
|
// passe par un champ de fichier, que le navigateur n'ouvre que dans le
|
|
// prolongement d'un geste de l'opérateur, et qui ne dit du fichier choisi
|
|
// que son nom : l'échec de sa lecture le nomme, dossier null.
|
|
// enregistrerSous passe par un lien de téléchargement : le navigateur range
|
|
// le fichier sans dire où ni si l'opérateur y renonce, et la primitive rend
|
|
// le nom proposé.
|
|
import { ErreurStockage } from './erreurs.js';
|
|
import { exigerCheminRelatif } from './systeme_fichiers.js';
|
|
|
|
// Le dossier du produit dans l'OPFS, et la racine documents qui le désigne.
|
|
const DOSSIER_PRODUIT = 'gestion_table_tournante_libre';
|
|
const DOCUMENTS = 'documents';
|
|
const CHEMIN_DOCUMENTS = `/${DOSSIER_PRODUIT}`;
|
|
|
|
// Nom du témoin de la sonde (§ 8.6), qui porte aussi ce nom pour texte.
|
|
const TEMOIN = '.gtt-temoin';
|
|
|
|
// Cause d'une écriture qui vise un dossier — la racine elle-même, ou un
|
|
// dossier là où un fichier est attendu —, celle que lève l'OPFS.
|
|
const TYPE_DIFFERENT = 'TypeMismatchError';
|
|
|
|
// Cause d'une sonde dont le témoin relu diffère du texte écrit.
|
|
const RELECTURE_DIFFERENTE = 'RELECTURE_DIFFERENTE';
|
|
|
|
// Un fichier s'écrit en UTF-8 sans marque d'ordre d'octets, et se lit sans
|
|
// retirer celle qu'il porte, que Blob.text retirerait. Le décodage est
|
|
// strict : un octet qui n'est pas de l'UTF-8 ne se lit pas U+FFFD.
|
|
const UTF8 = new TextEncoder();
|
|
const DECODEUR = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
|
|
// Un nettoyage qui échoue ne masque pas l'échec qu'il suit.
|
|
const ignorer = () => {};
|
|
|
|
// Vrai pour l'exception de l'OPFS qui dit qu'une entrée manque : absente, ou
|
|
// d'un autre genre que celui demandé.
|
|
const estAbsence = (erreur) => erreur?.name === 'NotFoundError' || erreur?.name === TYPE_DIFFERENT;
|
|
|
|
// Cause d'une ECRITURE ou d'une LECTURE : le nom de l'exception, ou son
|
|
// texte quand elle n'en porte pas.
|
|
const causeDe = (erreur) => (typeof erreur?.name === 'string' && erreur.name !== '' ? erreur.name : String(erreur));
|
|
|
|
function exigerChaine(valeur, quoi) {
|
|
if (typeof valeur !== 'string') throw new TypeError(`${quoi} n'est pas une chaîne`);
|
|
}
|
|
|
|
function exigerSeance(seance) {
|
|
if (typeof seance !== 'string' || seance === '') throw new TypeError("la séance n'est pas une chaîne non vide");
|
|
}
|
|
|
|
// Ce que désigne un chemin relatif à la racine : ses segments, ceux du
|
|
// dossier qui le porte, son nom — null pour '', la racine elle-même —, et le
|
|
// chemin affichable de ce dossier, celui de la racine pour ''. CHEMIN_REFUSE
|
|
// pour un chemin refusé ou une racine inconnue, reconnue par son seul
|
|
// identifiant.
|
|
function localiser(racine, chemin) {
|
|
const segments = exigerCheminRelatif(chemin);
|
|
if (racine?.id !== DOCUMENTS) throw new ErreurStockage('CHEMIN_REFUSE', { chemin });
|
|
const parent = segments.slice(0, -1);
|
|
return {
|
|
chemin,
|
|
segments,
|
|
parent,
|
|
nom: segments.length > 0 ? segments[segments.length - 1] : null,
|
|
dossier: [CHEMIN_DOCUMENTS, ...parent].join('/'),
|
|
};
|
|
}
|
|
|
|
const refus = (loc, cause) => new ErreurStockage('ECRITURE', { chemin: loc.chemin, dossier: loc.dossier, cause });
|
|
|
|
const absent = (chemin) => new ErreurStockage('ABSENT', { chemin });
|
|
|
|
// Ce que devient l'exception d'une lecture de loc : ABSENT quand l'entrée
|
|
// manque, LECTURE sinon.
|
|
const echecLecture = (loc, erreur) =>
|
|
estAbsence(erreur)
|
|
? absent(loc.chemin)
|
|
: new ErreurStockage('LECTURE', { chemin: loc.chemin, dossier: loc.dossier, cause: causeDe(erreur) });
|
|
|
|
// Exécute travail, une écriture sur loc, et rend ce qu'il rend : une
|
|
// ErreurStockage passe telle quelle, toute autre exception devient ECRITURE.
|
|
async function ecrire(loc, travail) {
|
|
try {
|
|
return await travail();
|
|
} catch (erreur) {
|
|
if (erreur instanceof ErreurStockage) throw erreur;
|
|
throw refus(loc, causeDe(erreur));
|
|
}
|
|
}
|
|
|
|
// Poignée du dossier que désignent les segments sous le dossier du produit ;
|
|
// 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
|
|
// fichier à sa place.
|
|
async function dossierDe(segments, creer = false) {
|
|
const options = { create: creer };
|
|
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);
|
|
return dossier;
|
|
}
|
|
|
|
// Le dossier que désignent les segments, ou null quand il n'existe pas.
|
|
async function dossierExistant(segments) {
|
|
try {
|
|
return await dossierDe(segments);
|
|
} catch (erreur) {
|
|
if (estAbsence(erreur)) return null;
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
// Genre de l'entrée nom du dossier : 'fichier', 'dossier', ou null quand elle
|
|
// n'existe pas.
|
|
async function genreDe(dossier, nom) {
|
|
try {
|
|
await dossier.getFileHandle(nom);
|
|
return 'fichier';
|
|
} catch (erreur) {
|
|
if (erreur?.name === TYPE_DIFFERENT) return 'dossier';
|
|
if (erreur?.name === 'NotFoundError') return null;
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
// Retire l'entrée nom du dossier ; une entrée absente n'est pas une faute.
|
|
async function retirer(dossier, nom) {
|
|
try {
|
|
await dossier.removeEntry(nom);
|
|
} catch (erreur) {
|
|
if (erreur?.name !== 'NotFoundError') throw erreur;
|
|
}
|
|
}
|
|
|
|
// Ce que porte un fichier : { texte, octets }, ses octets et leur décodage
|
|
// tel quel ; texte null quand ils ne sont pas de l'UTF-8.
|
|
async function lire(poignee) {
|
|
const octets = new Uint8Array(await (await poignee.getFile()).arrayBuffer());
|
|
try {
|
|
return { texte: DECODEUR.decode(octets), octets };
|
|
} catch {
|
|
return { texte: null, octets };
|
|
}
|
|
}
|
|
|
|
// Écrit le fichier nom du dossier par un flux de l'OPFS, que remplir(flux,
|
|
// poignee) alimente ; garder ouvre le flux sur le contenu présent, sinon sur
|
|
// un contenu vide. Le contenu n'atteint le fichier qu'à la fermeture du flux.
|
|
// Un échec abandonne le flux, retire le fichier quand l'appel l'a créé, puis
|
|
// se relance : le fichier reste tel qu'avant l'appel.
|
|
async function ecrireParFlux(dossier, nom, garder, remplir) {
|
|
let poignee;
|
|
let cree = false;
|
|
try {
|
|
poignee = await dossier.getFileHandle(nom);
|
|
} catch (erreur) {
|
|
if (erreur?.name !== 'NotFoundError') throw erreur;
|
|
poignee = await dossier.getFileHandle(nom, { create: true });
|
|
cree = true;
|
|
}
|
|
let flux = null;
|
|
try {
|
|
flux = await poignee.createWritable({ keepExistingData: garder });
|
|
await remplir(flux, poignee);
|
|
await flux.close();
|
|
} catch (erreur) {
|
|
if (flux !== null) await flux.abort().catch(ignorer);
|
|
if (cree) await dossier.removeEntry(nom).catch(ignorer);
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
// Description d'une entrée d'un dossier, ou null pour un fichier disparu
|
|
// entre la liste et sa lecture.
|
|
async function decrire(poignee) {
|
|
if (poignee.kind === 'directory') return { nom: poignee.name, type: 'dossier', taille: 0, modifie: 0 };
|
|
try {
|
|
const fichier = await poignee.getFile();
|
|
return { nom: poignee.name, type: 'fichier', taille: fichier.size, modifie: fichier.lastModified };
|
|
} catch (erreur) {
|
|
if (erreur?.name === 'NotFoundError') return null;
|
|
throw erreur;
|
|
}
|
|
}
|
|
|
|
// Ordre des entrées : leurs noms comparés par unités UTF-16, une à une.
|
|
const parNom = (a, b) => (a.nom < b.nom ? -1 : a.nom > b.nom ? 1 : 0);
|
|
|
|
// La source de deplacer : le dossier qui la porte et ses octets. ABSENT quand
|
|
// elle n'existe pas ; ECRITURE TypeMismatchError quand c'est un dossier.
|
|
async function lireSource(loc) {
|
|
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
|
const dossier = await dossierExistant(loc.parent);
|
|
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
|
|
if (genre === null) throw absent(loc.chemin);
|
|
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
|
|
const fichier = await (await dossier.getFileHandle(loc.nom)).getFile();
|
|
return { dossier, octets: await fichier.arrayBuffer() };
|
|
}
|
|
|
|
// Écrit les octets sous la cible de deplacer, et rend le dossier qui la
|
|
// porte. EXISTE quand une entrée y porte déjà ce nom, quel qu'en soit le
|
|
// genre, et pour '', la racine elle-même.
|
|
async function copierVers(loc, octets) {
|
|
if (loc.nom === null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
|
|
const dossier = await dossierDe(loc.parent);
|
|
if ((await genreDe(dossier, loc.nom)) !== null) throw new ErreurStockage('EXISTE', { chemin: loc.chemin });
|
|
await ecrireParFlux(dossier, loc.nom, false, (flux) => flux.write(octets));
|
|
return dossier;
|
|
}
|
|
|
|
// Ouvre le dialogue du navigateur par un champ de fichier, hors du document,
|
|
// et rend le nom et les octets du fichier choisi ; null quand l'opérateur
|
|
// annule — l'événement cancel — ou que le champ revient sans fichier. Un
|
|
// fichier choisi qui ne se lit pas lève ABSENT ou LECTURE sous son nom.
|
|
function choisirFichier() {
|
|
return new Promise((resoudre, rejeter) => {
|
|
const champ = document.createElement('input');
|
|
champ.type = 'file';
|
|
champ.addEventListener('cancel', () => resoudre(null), { once: true });
|
|
champ.addEventListener(
|
|
'change',
|
|
() => {
|
|
const fichier = champ.files[0];
|
|
if (fichier === undefined) {
|
|
resoudre(null);
|
|
return;
|
|
}
|
|
fichier.arrayBuffer().then(
|
|
(tampon) => resoudre({ nom: fichier.name, octets: new Uint8Array(tampon) }),
|
|
(erreur) => rejeter(echecLecture({ chemin: fichier.name, dossier: null }, erreur)),
|
|
);
|
|
},
|
|
{ once: true },
|
|
);
|
|
champ.click();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Système de fichiers de la plateforme web, sur l'OPFS. Chaque appel rend un
|
|
* système neuf ; tous partagent l'OPFS de l'origine.
|
|
*
|
|
* @returns {import('./systeme_fichiers.js').SystemeFichiers}
|
|
*/
|
|
export function creerFichiersWeb() {
|
|
return {
|
|
nature: 'web',
|
|
renommageAtomique: false,
|
|
verrouDisponible: false,
|
|
|
|
emplacements: async () => ({ executable: null, donneesApplicatives: [], insensibleCasse: false, separateur: '/' }),
|
|
|
|
racines: async () => ({ portable: null, documents: { id: DOCUMENTS, chemin: CHEMIN_DOCUMENTS } }),
|
|
|
|
choisirDossier: async () => {
|
|
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'choisirDossier' });
|
|
},
|
|
|
|
// Crée le dossier du produit, efface un témoin resté, écrit le témoin, le
|
|
// relit, l'efface. Un échec rend sa cause et efface le témoin qu'il
|
|
// laisse ; seule une racine inconnue lève.
|
|
sonder: async (racine) => {
|
|
localiser(racine, '');
|
|
let dossier = null;
|
|
try {
|
|
dossier = await dossierDe([], true);
|
|
await retirer(dossier, TEMOIN);
|
|
await ecrireParFlux(dossier, TEMOIN, false, (flux) => flux.write(UTF8.encode(TEMOIN)));
|
|
const { texte: relu } = await lire(await dossier.getFileHandle(TEMOIN));
|
|
await retirer(dossier, TEMOIN);
|
|
return relu === TEMOIN
|
|
? { inscriptible: true, cause: null }
|
|
: { inscriptible: false, cause: RELECTURE_DIFFERENTE };
|
|
} catch (erreur) {
|
|
if (dossier !== null) await retirer(dossier, TEMOIN).catch(ignorer);
|
|
return { inscriptible: false, cause: causeDe(erreur) };
|
|
}
|
|
},
|
|
|
|
typeSupport: async (racine) => {
|
|
localiser(racine, '');
|
|
return 'inconnu';
|
|
},
|
|
|
|
// Des octets hors UTF-8 lèvent LECTURE, cause UTF8_INVALIDE, qui porte
|
|
// ces octets.
|
|
lireTexte: async (racine, chemin) => {
|
|
const loc = localiser(racine, chemin);
|
|
if (loc.nom === null) throw absent(chemin);
|
|
let lu;
|
|
try {
|
|
lu = await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
|
} catch (erreur) {
|
|
throw echecLecture(loc, erreur);
|
|
}
|
|
if (lu.texte === null) {
|
|
const details = { chemin: loc.chemin, dossier: loc.dossier, cause: 'UTF8_INVALIDE', octets: lu.octets };
|
|
throw new ErreurStockage('LECTURE', details);
|
|
}
|
|
return lu.texte;
|
|
},
|
|
|
|
ecrireAtomique: async (racine, chemin, texte) => {
|
|
exigerChaine(texte, 'le texte');
|
|
const loc = localiser(racine, chemin);
|
|
await ecrire(loc, async () => {
|
|
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
|
const octets = UTF8.encode(texte);
|
|
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, false, (flux) => flux.write(octets));
|
|
});
|
|
},
|
|
|
|
// Le flux garde le contenu présent et écrit la ligne à sa fin.
|
|
ajouterLigne: async (racine, chemin, ligne) => {
|
|
exigerChaine(ligne, 'la ligne');
|
|
if (/[\r\n]/.test(ligne)) throw new TypeError('la ligne porte une fin de ligne');
|
|
const loc = localiser(racine, chemin);
|
|
await ecrire(loc, async () => {
|
|
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
|
const octets = UTF8.encode(`${ligne}\n`);
|
|
await ecrireParFlux(await dossierDe(loc.parent), loc.nom, true, async (flux, poignee) => {
|
|
const { size } = await poignee.getFile();
|
|
await flux.write({ type: 'write', position: size, data: octets });
|
|
});
|
|
});
|
|
},
|
|
|
|
lister: async (racine, chemin) => {
|
|
const loc = localiser(racine, chemin);
|
|
const entrees = [];
|
|
try {
|
|
for await (const poignee of (await dossierDe(loc.segments)).values()) {
|
|
const entree = await decrire(poignee);
|
|
if (entree !== null) entrees.push(entree);
|
|
}
|
|
} catch (erreur) {
|
|
throw echecLecture(loc, erreur);
|
|
}
|
|
return entrees.sort(parNom);
|
|
},
|
|
|
|
creerDossier: async (racine, chemin) => {
|
|
const loc = localiser(racine, chemin);
|
|
await ecrire(loc, () => dossierDe(loc.segments, true));
|
|
},
|
|
|
|
// Un échec de la lecture de la source ou de son retrait nomme la source ;
|
|
// un échec de la copie nomme la cible.
|
|
deplacer: async (racine, de, vers) => {
|
|
const source = localiser(racine, de);
|
|
const cible = localiser(racine, vers);
|
|
const { dossier: dossierSource, octets } = await ecrire(source, () => lireSource(source));
|
|
const dossierCible = await ecrire(cible, () => copierVers(cible, octets));
|
|
await ecrire(source, async () => {
|
|
try {
|
|
await dossierSource.removeEntry(source.nom);
|
|
} catch (erreur) {
|
|
await dossierCible.removeEntry(cible.nom).catch(ignorer);
|
|
throw erreur;
|
|
}
|
|
});
|
|
},
|
|
|
|
// Retire un fichier ; un dossier, la racine comprise, lève ECRITURE
|
|
// TypeMismatchError et reste.
|
|
supprimer: async (racine, chemin) => {
|
|
const loc = localiser(racine, chemin);
|
|
await ecrire(loc, async () => {
|
|
if (loc.nom === null) throw refus(loc, TYPE_DIFFERENT);
|
|
const dossier = await dossierExistant(loc.parent);
|
|
const genre = dossier === null ? null : await genreDe(dossier, loc.nom);
|
|
if (genre === 'dossier') throw refus(loc, TYPE_DIFFERENT);
|
|
if (genre === 'fichier') await retirer(dossier, loc.nom);
|
|
});
|
|
},
|
|
|
|
verrouiller: async (racine, chemin, seance) => {
|
|
exigerSeance(seance);
|
|
localiser(racine, chemin);
|
|
return { pris: true };
|
|
},
|
|
|
|
deverrouiller: async (racine, chemin, seance) => {
|
|
exigerSeance(seance);
|
|
localiser(racine, chemin);
|
|
},
|
|
|
|
ouvrirDansExplorateur: async (racine) => {
|
|
localiser(racine, '');
|
|
throw new ErreurStockage('NON_DISPONIBLE', { primitive: 'ouvrirDansExplorateur' });
|
|
},
|
|
|
|
choisirFichierAImporter: () => choisirFichier(),
|
|
|
|
// Le clic remet au navigateur l'adresse du contenu, qui se libère à la
|
|
// tâche suivante.
|
|
enregistrerSous: async (nomPropose, octets) => {
|
|
if (typeof nomPropose !== 'string' || nomPropose === '') {
|
|
throw new TypeError("le nom proposé n'est pas une chaîne non vide");
|
|
}
|
|
if (!(octets instanceof Uint8Array)) throw new TypeError("les octets ne sont pas un Uint8Array");
|
|
const adresse = URL.createObjectURL(new Blob([octets], { type: 'application/octet-stream' }));
|
|
const lien = document.createElement('a');
|
|
lien.href = adresse;
|
|
lien.download = nomPropose;
|
|
lien.click();
|
|
setTimeout(() => URL.revokeObjectURL(adresse), 0);
|
|
return nomPropose;
|
|
},
|
|
};
|
|
}
|