[ADD] storage: web file system over the origin private file system
Under the web platform, used to develop and test on Linux, events live
in the browser's origin private file system: one root, with no
executable folder, no atomic rename and no lock, which it declares so
the start-up banner can say so (§ 8.8). Imports come through a file
input and exports through a download link. The same contract suite as
the test double runs on it in Chromium, its exclusivity check skipped
because there is no lock to test.
Checked: the contract suite in the browser project; one review round.
--- FR ---
[ADD] stockage : système de fichiers web, sur le stockage de l'origine
Sous la plateforme web, qui sert au développement et aux épreuves sous
Linux, les événements vivent dans le stockage privé de l'origine du
navigateur : une seule racine, sans dossier d'exécutable, sans renommage
atomique ni verrou, ce qu'il déclare pour que le bandeau de démarrage le
dise (§ 8.8). Les imports passent par un champ de fichier, les exports
par un lien de téléchargement. La suite de contrat du double s'y joue
dans Chromium, sauf l'exclusivité, faute de verrou à éprouver.
Vérifié : la suite de contrat sous le projet navigateur ; une revue.
Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:45 -04:00
|
|
|
// © 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. Un octet qui n'est pas
|
|
|
|
|
// de l'UTF-8 se lit U+FFFD.
|
|
|
|
|
const UTF8 = new TextEncoder();
|
|
|
|
|
const DECODEUR = new TextDecoder('utf-8', { 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 ;
|
[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
2026-10-06 23:17:05 -04:00
|
|
|
// 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
|
[ADD] storage: web file system over the origin private file system
Under the web platform, used to develop and test on Linux, events live
in the browser's origin private file system: one root, with no
executable folder, no atomic rename and no lock, which it declares so
the start-up banner can say so (§ 8.8). Imports come through a file
input and exports through a download link. The same contract suite as
the test double runs on it in Chromium, its exclusivity check skipped
because there is no lock to test.
Checked: the contract suite in the browser project; one review round.
--- FR ---
[ADD] stockage : système de fichiers web, sur le stockage de l'origine
Sous la plateforme web, qui sert au développement et aux épreuves sous
Linux, les événements vivent dans le stockage privé de l'origine du
navigateur : une seule racine, sans dossier d'exécutable, sans renommage
atomique ni verrou, ce qu'il déclare pour que le bandeau de démarrage le
dise (§ 8.8). Les imports passent par un champ de fichier, les exports
par un lien de téléchargement. La suite de contrat du double s'y joue
dans Chromium, sauf l'exclusivité, faute de verrou à éprouver.
Vérifié : la suite de contrat sous le projet navigateur ; une revue.
Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:45 -04:00
|
|
|
// l'OPFS : NotFoundError pour un dossier absent, TypeMismatchError pour un
|
|
|
|
|
// fichier à sa place.
|
|
|
|
|
async function dossierDe(segments, creer = false) {
|
|
|
|
|
const options = { create: creer };
|
[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
2026-10-06 23:17:05 -04:00
|
|
|
const produit = { create: creer && segments.length === 0 };
|
|
|
|
|
let dossier = await (await navigator.storage.getDirectory()).getDirectoryHandle(DOSSIER_PRODUIT, produit);
|
[ADD] storage: web file system over the origin private file system
Under the web platform, used to develop and test on Linux, events live
in the browser's origin private file system: one root, with no
executable folder, no atomic rename and no lock, which it declares so
the start-up banner can say so (§ 8.8). Imports come through a file
input and exports through a download link. The same contract suite as
the test double runs on it in Chromium, its exclusivity check skipped
because there is no lock to test.
Checked: the contract suite in the browser project; one review round.
--- FR ---
[ADD] stockage : système de fichiers web, sur le stockage de l'origine
Sous la plateforme web, qui sert au développement et aux épreuves sous
Linux, les événements vivent dans le stockage privé de l'origine du
navigateur : une seule racine, sans dossier d'exécutable, sans renommage
atomique ni verrou, ce qu'il déclare pour que le bandeau de démarrage le
dise (§ 8.8). Les imports passent par un champ de fichier, les exports
par un lien de téléchargement. La suite de contrat du double s'y joue
dans Chromium, sauf l'exclusivité, faute de verrou à éprouver.
Vérifié : la suite de contrat sous le projet navigateur ; une revue.
Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
2026-10-06 17:03:45 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Texte d'un fichier : ses octets décodés tels quels.
|
|
|
|
|
const lire = async (poignee) => DECODEUR.decode(await (await poignee.getFile()).arrayBuffer());
|
|
|
|
|
|
|
|
|
|
// É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 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';
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
lireTexte: async (racine, chemin) => {
|
|
|
|
|
const loc = localiser(racine, chemin);
|
|
|
|
|
if (loc.nom === null) throw absent(chemin);
|
|
|
|
|
try {
|
|
|
|
|
return await lire(await (await dossierDe(loc.parent)).getFileHandle(loc.nom));
|
|
|
|
|
} catch (erreur) {
|
|
|
|
|
throw echecLecture(loc, erreur);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|