The journal is one JSON line per entry, after a line that pairs it with its event by id. An entry carries its frozen label, a display timestamp, and either a snapshot or a patch from the line before; a snapshot every fifty revisions keeps any instant within 49 applications. Reading stops at the first unreadable line and counts what it drops. Returns never truncate: undo, redo and jumps are entries, and the current thread is told from abandoned ones. Past 600 entries it prunes on a snapshot boundary, never below 500. Checked: unit tests, and every instant rebuilt over long sequences. --- FR --- [ADD] stockage : journal en ajout, instantané tous les cinquante, fils Le journal est une ligne JSON par entrée, après une ligne qui l'apparie à son événement par identifiant. Une entrée porte son libellé figé, un horodatage d'affichage, et soit un instantané, soit un correctif depuis la ligne précédente ; un instantané toutes les cinquante révisions tient tout instant à 49 applications. La lecture s'arrête à la première ligne illisible et compte ce qu'elle écarte. Revenir ne tronque jamais : défaire, refaire et sauter sont des entrées, et le fil courant se distingue des fils abandonnés. Au-delà de 600 entrées, élagage sur une frontière d'instantané, jamais sous 500. Vérifié : épreuves unitaires ; tous les instants reconstruits. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb
459 lines
20 KiB
JavaScript
459 lines
20 KiB
JavaScript
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||
|
||
// Le journal d'un événement (§ 8.2, § 8.3, § 8.6, § 8.8) : le fichier
|
||
// <base>.gtt-journal.jsonl, un objet JSON par ligne, fin de ligne LF, dont
|
||
// types.js décrit chaque sorte de ligne. La première ligne l'apparie à son
|
||
// état par l'identifiant de l'événement ; suivent les entrées, une par geste
|
||
// achevé, et les jalons, qui nomment l'instant d'une révision sans changer la
|
||
// charge. L'ordre est celui des lignes, jamais celui des horodatages, qui ne
|
||
// servent qu'à l'affichage.
|
||
//
|
||
// Le module n'a ni fichier ni horloge. Il rend le texte d'une ligne, sans fin
|
||
// de ligne — l'ajout au fichier appartient au système de fichiers —, lit le
|
||
// texte entier d'un journal, restitue la charge d'un instant, suit le fil
|
||
// courant et élague. Une table par sorte de ligne — OUVERTURE, ENTREE, JALON
|
||
// — donne à la fois l'ordre des clés à l'écriture et la règle de chaque clé
|
||
// à la lecture : l'écriture refuse par TypeError la ligne dont la lecture
|
||
// refuserait la forme. La suite des révisions et la cadence des instantanés,
|
||
// que la lecture contrôle aussi, dépendent des lignes voisines : elles
|
||
// appartiennent à l'appelant.
|
||
//
|
||
// Une entrée porte soit l'instantané de la charge qui en résulte, soit le
|
||
// correctif qui y mène depuis la charge de l'entrée qui la précède dans
|
||
// l'ordre des lignes (correctifs.js). L'instantané tombe sur la première
|
||
// entrée du journal, puis toutes les INTERVALLE_INSTANTANE révisions comptées
|
||
// depuis elle : restituer un instant part de l'instantané le plus proche, à
|
||
// ou avant lui, et applique au plus INTERVALLE_INSTANTANE − 1 correctifs.
|
||
//
|
||
// La lecture s'arrête à la première ligne illisible : JSON invalide, forme
|
||
// fausse, révision qui ne suit pas la précédente, cadence des instantanés
|
||
// rompue, jalon d'une révision qui n'est pas lue, ligne finale sans fin de
|
||
// ligne, que laisse une écriture interrompue. Cette ligne et toutes celles
|
||
// qui la suivent sont écartées et comptées : reprendre après elle ferait
|
||
// suivre un correctif à un instant qui n'est pas le sien (§ 8.6).
|
||
import { canoniser } from './canonique.js';
|
||
import { appliquer, difference } from './correctifs.js';
|
||
import { FORMAT, SCHEMA, clesRangees, premiereFaute } from './document.js';
|
||
|
||
/** Révisions d'un instantané au suivant (§ 8.6). */
|
||
export const INTERVALLE_INSTANTANE = 50;
|
||
/** Entrées que l'élagage garde au moins : le plancher l'emporte (§ 8.6). */
|
||
export const PLANCHER_ENTREES = 500;
|
||
/** Entrées au-delà desquelles le journal s'élague (§ 8.6). */
|
||
export const PLAFOND_ENTREES = 600;
|
||
|
||
/**
|
||
* Un journal lu (lireJournal) : l'identifiant de l'événement, null quand
|
||
* l'ouverture est illisible ; les entrées, puis les jalons, chacun dans
|
||
* l'ordre des lignes, tels que le texte les porte ; et le nombre de lignes
|
||
* écartées. Les révisions des entrées se suivent ; la première porte un
|
||
* instantané et une version.
|
||
*
|
||
* @typedef {Object} Journal
|
||
* @property {string|null} evenement
|
||
* @property {import('./types.js').LigneEntree[]} entrees
|
||
* @property {import('./types.js').LigneJalon[]} jalons
|
||
* @property {number} ecartees
|
||
*/
|
||
|
||
// Règles de la charge et d'une proposition dans le schéma du fichier d'état.
|
||
const regleDuChamp = (regle, cle) => regle.champs.find(([nom]) => nom === cle)[1];
|
||
const CHARGE = regleDuChamp(SCHEMA, 'charge');
|
||
const PROPOSITION = regleDuChamp(CHARGE, 'propositions').element;
|
||
|
||
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
||
// AAAA-MM-JJTHH:MM:SS±HH:MM, la forme que rend l'horloge de l'application.
|
||
// Le calendrier ne se contrôle pas : un horodatage ne décide d'aucun ordre.
|
||
const HORODATAGE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
|
||
const SENS = [null, 'defaire', 'refaire', 'revenir'];
|
||
|
||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||
const estRevision = (valeur) => Number.isSafeInteger(valeur) && valeur >= 1;
|
||
const estTexte = (valeur) => typeof valeur === 'string' && valeur !== '';
|
||
const estHorodatage = (valeur) => typeof valeur === 'string' && HORODATAGE.test(valeur);
|
||
|
||
// Chemin de la première valeur de charge que l'analyse du fichier d'état
|
||
// refuserait, ou null : la forme de la charge, propositions et retenu lus
|
||
// comme conteneurs, puis la forme de chaque proposition, que canoniser
|
||
// exige. Un retenu hors de sa règle reste admis, comme le contrôle des
|
||
// placements le garde (§ 8.9).
|
||
function fauteDeCharge(charge) {
|
||
return (
|
||
premiereFaute(charge, CHARGE, 'charge') ??
|
||
charge.propositions
|
||
.map((proposition, rang) => premiereFaute(proposition, PROPOSITION, `charge.propositions[${rang}]`))
|
||
.find((faute) => faute !== null) ??
|
||
null
|
||
);
|
||
}
|
||
|
||
// Une sorte de ligne : ses champs, paires [clé, admet] dans l'ordre des clés
|
||
// du texte, et l'ensemble de ces clés. admet(valeur, ligne) dit si une valeur
|
||
// est admise à sa clé ; il peut lire les clés qui la précèdent, déjà admises.
|
||
const sorte = (champs) => ({ champs, cles: new Set(champs.map(([cle]) => cle)) });
|
||
|
||
const OUVERTURE = sorte([
|
||
['type', (valeur) => valeur === 'journal'],
|
||
['format', (valeur) => valeur === FORMAT],
|
||
['evenement', estTexte],
|
||
]);
|
||
|
||
const ENTREE = sorte([
|
||
['type', (valeur) => valeur === 'entree'],
|
||
['revision', estRevision],
|
||
['libelle', estTexte],
|
||
['horodatage', estHorodatage],
|
||
['sens', (valeur) => SENS.includes(valeur)],
|
||
// Un retour vise une révision antérieure à la sienne ; un geste n'en vise
|
||
// aucune.
|
||
['retour', (valeur, { sens, revision }) => (sens === null ? valeur === null : estRevision(valeur) && valeur < revision)],
|
||
['produitVersion', (valeur) => valeur === null || estTexte(valeur)],
|
||
['instantane', (valeur) => valeur === null || fauteDeCharge(valeur) === null],
|
||
// Exactement l'un des deux : le correctif quand l'instantané manque.
|
||
['correctif', (valeur, { instantane }) => (instantane === null ? Array.isArray(valeur) : valeur === null)],
|
||
]);
|
||
|
||
const JALON = sorte([
|
||
['type', (valeur) => valeur === 'jalon'],
|
||
['revision', estRevision],
|
||
['nom', estTexte],
|
||
['horodatage', estHorodatage],
|
||
]);
|
||
|
||
// Première clé de ligne hors de la règle de sa sorte, ou null : '' quand la
|
||
// ligne n'est pas un objet, puis chaque champ dans l'ordre de la sorte,
|
||
// absent ou refusé, puis la plus petite clé inconnue.
|
||
function champFautif(ligne, { champs, cles }) {
|
||
if (!estObjet(ligne)) return '';
|
||
for (const [cle, admet] of champs) {
|
||
if (!Object.hasOwn(ligne, cle) || !admet(ligne[cle], ligne)) return cle;
|
||
}
|
||
return clesRangees(ligne).find((cle) => !cles.has(cle)) ?? null;
|
||
}
|
||
|
||
// Texte d'une ligne, ses clés dans l'ordre de sa sorte, sans fin de ligne ;
|
||
// JSON.stringify n'écrit aucune fin de ligne brute. Lève TypeError, au nom
|
||
// de fonction, quand la lecture refuserait la ligne.
|
||
function ecrire(sorteDeLigne, valeurs, fonction) {
|
||
const ligne = Object.fromEntries(sorteDeLigne.champs.map(([cle]) => [cle, valeurs[cle]]));
|
||
const cle = champFautif(ligne, sorteDeLigne);
|
||
if (cle !== null) throw new TypeError(`${fonction} : ${cle} hors de sa règle`);
|
||
return JSON.stringify(ligne);
|
||
}
|
||
|
||
/**
|
||
* Première ligne d'un journal, qui l'apparie à son état par l'identifiant de
|
||
* l'événement (§ 8.6) ; sans fin de ligne. Le format est FORMAT, celui des
|
||
* charges que le journal porte. Lève TypeError quand evenement n'est pas une
|
||
* chaîne non vide.
|
||
*
|
||
* @param {string} evenement
|
||
* @returns {string}
|
||
*/
|
||
export function ligneOuverture(evenement) {
|
||
return ecrire(OUVERTURE, { type: 'journal', format: FORMAT, evenement }, 'ligneOuverture');
|
||
}
|
||
|
||
/**
|
||
* Ligne d'une entrée (§ 8.2, § 8.3), sans fin de ligne : sa révision, son
|
||
* libellé figé, son horodatage d'affichage, sens et retour pour une entrée
|
||
* de retour, la version de la construction quand elle se note (§ 8.8), puis
|
||
* l'instantané de la copie canonique de apres quand instantane est vrai, ou
|
||
* le correctif de avant à apres sinon (difference). L'instantané et le
|
||
* correctif ne dépendent que des deux charges, non de l'ordre de leurs clés
|
||
* ni de leurs listes. Ni avant ni apres ne sont modifiées.
|
||
*
|
||
* L'appelant passe instantane = estInstantane(revision, première révision
|
||
* du journal), et produitVersion à la première entrée et quand la
|
||
* construction change ; la lecture refuse une ligne qui rompt l'un ou
|
||
* l'autre.
|
||
*
|
||
* Lève TypeError quand la lecture refuserait la ligne — révision qui n'est
|
||
* pas un entier ≥ 1, libellé vide, horodatage qui n'a pas la forme
|
||
* AAAA-MM-JJTHH:MM:SS±HH:MM, sens inconnu, retour présent sans sens, absent
|
||
* avec un sens, ou qui ne précède pas la révision, version vide —, quand
|
||
* instantane n'est pas un booléen, quand apres n'a pas la forme que
|
||
* l'analyse du fichier d'état admet — clés inconnues comprises —, chaque
|
||
* proposition selon sa règle, et, pour un correctif, quand avant manque ou
|
||
* ne se canonise pas.
|
||
*
|
||
* @param {Object} entree
|
||
* @param {number} entree.revision
|
||
* @param {string} entree.libelle
|
||
* @param {string} entree.horodatage
|
||
* @param {null|'defaire'|'refaire'|'revenir'} [entree.sens]
|
||
* @param {number|null} [entree.retour]
|
||
* @param {string|null} [entree.produitVersion]
|
||
* @param {import('./types.js').Charge} [entree.avant] requise pour un correctif
|
||
* @param {import('./types.js').Charge} entree.apres
|
||
* @param {boolean} entree.instantane
|
||
* @returns {string}
|
||
*/
|
||
export function ligneEntree({
|
||
revision,
|
||
libelle,
|
||
horodatage,
|
||
sens = null,
|
||
retour = null,
|
||
produitVersion = null,
|
||
avant,
|
||
apres,
|
||
instantane,
|
||
}) {
|
||
if (typeof instantane !== 'boolean') {
|
||
throw new TypeError(`ligneEntree : instantane booléen attendu, reçu ${JSON.stringify(instantane)}`);
|
||
}
|
||
// La charge reçue, telle quelle : une clé hors du schéma, que la copie
|
||
// canonique tairait, est refusée comme l'analyse la refuserait.
|
||
const faute = fauteDeCharge(apres);
|
||
if (faute !== null) throw new TypeError(`ligneEntree : ${faute} hors de sa règle`);
|
||
const ligne = {
|
||
type: 'entree',
|
||
revision,
|
||
libelle,
|
||
horodatage,
|
||
sens,
|
||
retour,
|
||
produitVersion,
|
||
instantane: instantane ? canoniser(apres) : null,
|
||
correctif: instantane ? null : difference(avant, apres),
|
||
};
|
||
return ecrire(ENTREE, ligne, 'ligneEntree');
|
||
}
|
||
|
||
/**
|
||
* Ligne d'un jalon (§ 8.3), sans fin de ligne : il nomme l'instant de la
|
||
* révision donnée, ne change pas la charge et ne prend pas de révision.
|
||
* Lève TypeError quand la révision n'est pas un entier ≥ 1, le nom vide, ou
|
||
* l'horodatage hors de sa forme.
|
||
*
|
||
* @param {{revision: number, nom: string, horodatage: string}} jalon
|
||
* @returns {string}
|
||
*/
|
||
export function ligneJalon({ revision, nom, horodatage }) {
|
||
return ecrire(JALON, { type: 'jalon', revision, nom, horodatage }, 'ligneJalon');
|
||
}
|
||
|
||
/**
|
||
* Vrai quand l'entrée revision porte un instantané, dans un journal dont la
|
||
* première entrée est premiere : premiere elle-même, puis toutes les
|
||
* INTERVALLE_INSTANTANE révisions comptées depuis elle ; jamais une révision
|
||
* qui précède premiere.
|
||
*
|
||
* @param {number} revision
|
||
* @param {number} premiere
|
||
* @returns {boolean}
|
||
*/
|
||
export function estInstantane(revision, premiere) {
|
||
return revision >= premiere && (revision - premiere) % INTERVALLE_INSTANTANE === 0;
|
||
}
|
||
|
||
// Valeur JSON d'une ligne, ou undefined quand elle n'en est pas une.
|
||
function analyserLigne(ligne) {
|
||
try {
|
||
return JSON.parse(ligne);
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
// Vrai quand ligne est l'entrée qui suit entrees : sa forme admise, et la
|
||
// révision qui suit la dernière, l'instantané là où la cadence le veut ; la
|
||
// première entrée, de révision quelconque, porte un instantané et sa
|
||
// version.
|
||
function entreeSuivante(ligne, entrees) {
|
||
if (champFautif(ligne, ENTREE) !== null) return false;
|
||
if (entrees.length === 0) return ligne.produitVersion !== null && ligne.instantane !== null;
|
||
return (
|
||
ligne.revision === entrees[entrees.length - 1].revision + 1 &&
|
||
(ligne.instantane !== null) === estInstantane(ligne.revision, entrees[0].revision)
|
||
);
|
||
}
|
||
|
||
// Vrai quand ligne est un jalon d'une révision déjà lue.
|
||
function jalonDe(ligne, entrees) {
|
||
return (
|
||
champFautif(ligne, JALON) === null &&
|
||
entrees.length > 0 &&
|
||
ligne.revision >= entrees[0].revision &&
|
||
ligne.revision <= entrees[entrees.length - 1].revision
|
||
);
|
||
}
|
||
|
||
// Lecture d'un texte de journal : le journal lu ; les lignes lues après
|
||
// l'ouverture, entrées et jalons dans l'ordre du texte ; et les segments du
|
||
// texte coupé à chaque fin de ligne, la marque d'ordre d'octets retirée, le
|
||
// dernier segment suivant la dernière fin de ligne. Chaque segment suivi
|
||
// d'une fin de ligne est une ligne ; le dernier aussi quand il n'est pas
|
||
// vide, et la lecture l'écarte.
|
||
function lire(texte) {
|
||
const segments = (texte.startsWith(MARQUE_ORDRE_OCTETS) ? texte.slice(1) : texte).split('\n');
|
||
const completes = segments.length - 1;
|
||
const lignes = segments[completes] === '' ? completes : completes + 1;
|
||
const journal = { evenement: null, entrees: [], jalons: [], ecartees: lignes };
|
||
const lues = [];
|
||
const ouverture = completes > 0 ? analyserLigne(segments[0]) : undefined;
|
||
if (champFautif(ouverture, OUVERTURE) !== null) return { journal, lues, segments };
|
||
journal.evenement = ouverture.evenement;
|
||
for (let rang = 1; rang < completes; rang += 1) {
|
||
const ligne = analyserLigne(segments[rang]);
|
||
if (entreeSuivante(ligne, journal.entrees)) journal.entrees.push(ligne);
|
||
else if (jalonDe(ligne, journal.entrees)) journal.jalons.push(ligne);
|
||
else break;
|
||
lues.push(ligne);
|
||
}
|
||
journal.ecartees = lignes - 1 - lues.length;
|
||
return { journal, lues, segments };
|
||
}
|
||
|
||
/**
|
||
* Lit le texte d'un journal (§ 8.6), sans rien écrire (§ 8.4). Une marque
|
||
* d'ordre d'octets en tête est ignorée ; un retour chariot avant la fin de
|
||
* ligne aussi, que JSON tient pour un blanc.
|
||
*
|
||
* La lecture s'arrête à la première ligne illisible : JSON invalide ; forme
|
||
* fausse, toute clé absente, refusée ou inconnue, et l'instantané d'une
|
||
* forme que l'analyse du fichier d'état refuse ; une ouverture d'un autre
|
||
* format ; une entrée dont la révision ne suit pas la précédente, ou dont
|
||
* l'instantané manque ou abonde au regard de la cadence (estInstantane) ;
|
||
* une première entrée sans version ; un jalon d'une révision qui n'est pas
|
||
* encore lue ; une ligne finale sans fin de ligne. Cette ligne et toutes
|
||
* celles qui la suivent, entrées et jalons confondus — une ligne illisible
|
||
* ne dit pas sa sorte —, sont écartées et comptées dans ecartees. Une
|
||
* ouverture illisible écarte tout, et evenement vaut null.
|
||
*
|
||
* Un correctif se lit sans s'appliquer : reconstruire lève CORRECTIF sur
|
||
* celui qui ne s'applique pas.
|
||
*
|
||
* @param {string} texte
|
||
* @returns {Journal}
|
||
*/
|
||
export function lireJournal(texte) {
|
||
return lire(texte).journal;
|
||
}
|
||
|
||
/**
|
||
* Charge après l'entrée revision (§ 8.3) : l'instantané le plus proche, à ou
|
||
* avant elle, puis chaque correctif jusqu'à elle, dans l'ordre des lignes —
|
||
* au plus INTERVALLE_INSTANTANE − 1. Rend une charge neuve, dans l'ordre
|
||
* canonique, qui ne partage rien avec le journal. Une entrée de retour rend
|
||
* la charge de sa cible, que son correctif ou son instantané portent.
|
||
*
|
||
* Lève ErreurStockage('CORRECTIF', { rang }) d'appliquer (correctifs.js) sur
|
||
* un correctif qui ne s'applique pas : il gâte les révisions de la sienne à
|
||
* l'instantané suivant, et aucune autre. Lève RangeError quand revision
|
||
* n'est pas celle d'une entrée du journal.
|
||
*
|
||
* @param {Journal} journal
|
||
* @param {number} revision
|
||
* @returns {import('./types.js').Charge}
|
||
*/
|
||
export function reconstruire({ entrees }, revision) {
|
||
const rang = entrees.findIndex((entree) => entree.revision === revision);
|
||
if (rang === -1) throw new RangeError(`reconstruire : révision ${String(revision)} absente du journal`);
|
||
let base = rang;
|
||
while (entrees[base].instantane === null) base -= 1;
|
||
let charge = canoniser(entrees[base].instantane);
|
||
for (let suivante = base + 1; suivante <= rang; suivante += 1) {
|
||
charge = appliquer(charge, entrees[suivante].correctif);
|
||
}
|
||
return charge;
|
||
}
|
||
|
||
/**
|
||
* Le fil courant (§ 8.3), par le rejeu des entrées dans l'ordre des lignes.
|
||
* La position d'une entrée ordinaire est elle-même ; celle d'une entrée de
|
||
* retour, la position de l'entrée qu'elle vise. Une entrée ordinaire pose la
|
||
* position et vide la pile de refaire ; un retour defaire ou revenir empile
|
||
* la position courante et pose celle de sa cible ; un retour refaire dépile
|
||
* et pose celle de sa cible.
|
||
*
|
||
* Le prédécesseur d'une position est la position de l'entrée qui la précède
|
||
* dans l'ordre des lignes, null pour la première entrée du journal. Un retour
|
||
* dont la cible précède la première entrée — l'élagage l'a retirée — porte
|
||
* la charge de cette cible sans en connaître la position : il est sa propre
|
||
* position, sans prédécesseur, et le fil s'arrête à lui.
|
||
*
|
||
* Rend position, la position courante, null pour un journal sans entrée ;
|
||
* pile, les positions à refaire, le sommet en dernier ; courant, le fil
|
||
* courant, la chaîne des prédécesseurs depuis la position, de la plus récente
|
||
* à la plus ancienne ; cibleDefaire, le prédécesseur de la position, et
|
||
* cibleRefaire, le sommet de la pile, null quand ils n'existent pas. Les
|
||
* entrées ordinaires hors du fil courant forment les fils abandonnés.
|
||
*
|
||
* @param {Journal} journal
|
||
* @returns {{position: number|null, pile: number[], courant: number[], cibleDefaire: number|null, cibleRefaire: number|null}}
|
||
*/
|
||
export function fil({ entrees }) {
|
||
const positions = new Map();
|
||
const sansPredecesseur = new Set();
|
||
const pile = [];
|
||
let position = null;
|
||
for (const { revision, sens, retour } of entrees) {
|
||
let cible = revision;
|
||
if (sens === null) {
|
||
pile.length = 0;
|
||
} else {
|
||
if (positions.has(retour)) cible = positions.get(retour);
|
||
else sansPredecesseur.add(revision);
|
||
if (sens === 'refaire') pile.pop();
|
||
else if (position !== null) pile.push(position);
|
||
}
|
||
positions.set(revision, cible);
|
||
position = cible;
|
||
}
|
||
const predecesseur = (p) => (sansPredecesseur.has(p) ? null : (positions.get(p - 1) ?? null));
|
||
const courant = [];
|
||
for (let p = position; p !== null; p = predecesseur(p)) courant.push(p);
|
||
return { position, pile, courant, cibleDefaire: courant[1] ?? null, cibleRefaire: pile.at(-1) ?? null };
|
||
}
|
||
|
||
/**
|
||
* Version de la construction qui a produit l'entrée revision (§ 8.8) : la
|
||
* dernière produitVersion non nulle à ou avant elle. null quand revision
|
||
* n'est pas celle d'une entrée du journal.
|
||
*
|
||
* @param {Journal} journal
|
||
* @param {number} revision
|
||
* @returns {string|null}
|
||
*/
|
||
export function versionDe({ entrees }, revision) {
|
||
for (let rang = entrees.findIndex((entree) => entree.revision === revision); rang >= 0; rang -= 1) {
|
||
if (entrees[rang].produitVersion !== null) return entrees[rang].produitVersion;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Texte du journal élagué (§ 8.6), ou null quand ses entrées lisibles ne
|
||
* dépassent pas PLAFOND_ENTREES. La coupe tombe sur l'instantané le plus
|
||
* récent qui garde au moins PLANCHER_ENTREES entrées : le plancher l'emporte
|
||
* sur le plafond. Le texte rendu porte l'ouverture, puis les lignes lues dont
|
||
* la révision atteint la coupe — les jalons des entrées retirées partent
|
||
* avec elles —, puis les lignes que la lecture écarte, telles quelles.
|
||
* L'entrée de la coupe porte la version de la construction qui l'a produite
|
||
* (versionDe), qu'elle hérite sinon d'une entrée retirée ; toute autre
|
||
* ligne gardée se recopie octet pour octet. La marque d'ordre d'octets ne
|
||
* se recopie pas. Les instantanés gardés suivent la cadence comptée depuis
|
||
* la coupe, celle d'avant : le texte rendu se relit, et chaque instant gardé
|
||
* s'y restitue comme avant.
|
||
*
|
||
* @param {string} texte
|
||
* @returns {string|null}
|
||
*/
|
||
export function elaguer(texte) {
|
||
const { journal, lues, segments } = lire(texte);
|
||
const { entrees } = journal;
|
||
if (entrees.length <= PLAFOND_ENTREES) return null;
|
||
let rang = entrees.length - PLANCHER_ENTREES;
|
||
while (entrees[rang].instantane === null) rang -= 1;
|
||
const coupe = entrees[rang];
|
||
const gardees = [ligneOuverture(journal.evenement)];
|
||
lues.forEach((ligne, i) => {
|
||
if (ligne.revision < coupe.revision) return;
|
||
if (ligne !== coupe || coupe.produitVersion !== null) gardees.push(segments[i + 1]);
|
||
else gardees.push(ecrire(ENTREE, { ...coupe, produitVersion: versionDe(journal, coupe.revision) }, 'elaguer'));
|
||
});
|
||
return [...gardees, ...segments.slice(1 + lues.length)].join('\n');
|
||
}
|