322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
|
|
// © 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();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|