Initial commit

This commit is contained in:
Olivier CROGUENNEC
2026-06-13 14:57:15 +02:00
commit 48ed7fe65e
209 changed files with 49979 additions and 0 deletions
+572
View File
@@ -0,0 +1,572 @@
/**
* Shared schedule builder — used by both simul.js and investissements.js
*/
function addMonths(isoDate, months) {
const [y, m, d] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + months, d));
return dt.toISOString().slice(0, 10);
}
/**
* Retourne le dernier jour du mois de isoDate.
* Ex : '2024-01-15' → '2024-01-31', '2024-02-01' → '2024-02-29'
*/
function lastDayOfMonth(isoDate) {
const [y, m] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m, 0)); // jour 0 du mois suivant = dernier jour du mois courant
return dt.toISOString().slice(0, 10);
}
/**
* Ajoute n mois à isoDate et positionne le résultat sur le dernier jour du mois cible.
* Évite les débordements de JavaScript (ex : 31 jan + 1 mois = 29 fév, pas 3 mar).
* Ex : addMonthsEOM('2024-01-31', 1) → '2024-02-29'
*/
function addMonthsEOM(isoDate, months) {
const [y, m] = isoDate.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + months + 1, 0)); // jour 0 = dernier du mois cible
return dt.toISOString().slice(0, 10);
}
const round2 = n => Math.round(n * 100) / 100;
/**
* Builds a payment schedule.
*
* type:
* - in_fine : intérêts périodiques selon freq, capital à l'échéance
* - amortissable: mensualité/trimestrialité constante (modèle bancaire)
* - differe : aucun versement intermédiaire, tout en une fois à l'échéance
*
* freq:
* - mensuel : une échéance par mois (offset i-1)
* - trimestriel : une échéance tous les 3 mois
* - in_fine : forcé pour differe
*
* startDate = date_premiere_echeance (1ère échéance tombe sur cette date, offset 0)
*/
export function buildSchedule({ montant, taux, duree, type, freq, startDate, finDeMois = false }) {
const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (taux / 100 / 12) * step;
const nPer = freq === 'in_fine' ? 1 : Math.round(duree / step);
const out = [];
// Calcule la date d'une échéance selon le mode fin-de-mois ou non.
const dateAt = (base, offsetMonths) =>
finDeMois ? addMonthsEOM(base, offsetMonths) : addMonths(base, offsetMonths);
if (type === 'differe') {
const interets = round2(montant * (taux / 100 / 12) * duree);
// Pour un prêt différé, date_premiere_echeance == date_cible (versement unique).
// startDate est déjà la bonne date ; en mode fin-de-mois on s'assure qu'elle
// correspond bien au dernier jour (idempotent si déjà positionné par le frontend).
out.push({
n: 1,
date: finDeMois ? lastDayOfMonth(startDate) : startDate,
capital: round2(montant),
interets,
total: round2(montant + interets),
});
} else if (type === 'in_fine') {
const interetsPer = round2(montant * rPer);
for (let i = 1; i <= nPer; i++) {
const isLast = i === nPer;
out.push({
n: i,
date: dateAt(startDate, (i - 1) * step),
capital: isLast ? round2(montant) : 0,
interets: interetsPer,
total: round2(interetsPer + (isLast ? montant : 0)),
});
}
} else {
// amortissable
const a = rPer === 0
? montant / nPer
: montant * rPer / (1 - Math.pow(1 + rPer, -nPer));
let restant = montant;
for (let i = 1; i <= nPer; i++) {
const interets = round2(restant * rPer);
const capital = round2(a - interets);
restant = round2(restant - capital);
out.push({
n: i,
date: dateAt(startDate, (i - 1) * step),
capital,
interets,
total: round2(a),
});
}
}
return out;
}
/** Nombre de mois entiers entre deux dates ISO */
function monthsDiff(isoA, isoB) {
const a = new Date(isoA);
const b = new Date(isoB);
return (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
}
/**
* Recalcule les simul_remboursements FUTURS en tenant compte des remboursements
* de capital déjà enregistrés (remboursement anticipé partiel) ET des réinvestissements.
*
* - Les entrées dont date_prevue <= dernière date de remboursement capital sont laissées intactes.
* - Les entrées suivantes sont recalculées sur la base du capital restant dû.
* - Si aucun capital n'a encore été remboursé → régénération complète.
* - Les réinvestissements planifiés après la dernière date de remboursement sont pris en compte
* pour gonfler progressivement le capital des périodes futures.
*/
export function adjustSimulForActuals(db, investissementId) {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv || !inv.taux_interet || !inv.duree_mois) return;
// Réinvestissements triés par date (peuvent être vides)
const reinvests = db.prepare(
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(investissementId);
const { total_capital } = db.prepare(
'SELECT COALESCE(SUM(capital), 0) AS total_capital FROM remboursements WHERE investissement_id = ?'
).get(investissementId);
// Aucun capital remboursé → régénération complète (avec réinvestissements si présents)
if (total_capital <= 0) {
if (reinvests.length) {
generateSimulWithReinvestissements(db, investissementId);
} else {
generateSimul(db, inv);
}
adjustFirstPartialPeriod(db, investissementId);
return;
}
// Date du dernier remboursement comportant du capital
const { last_date } = db.prepare(
'SELECT MAX(date_remb) AS last_date FROM remboursements WHERE investissement_id = ? AND capital > 0'
).get(investissementId);
// Capital effectivement investi jusqu'à la date du dernier remboursement
// (initial + réinvestissements survenus avant ou à cette date)
const capitalAtLastDate = round2(
inv.montant_investi +
reinvests.filter(r => r.date_reinvestissement <= last_date).reduce((s, r) => s + r.montant, 0)
);
const remainingCapital = round2(capitalAtLastDate - total_capital);
// Entrées de simulation à recalculer (strictement après la date du dernier remb capital)
const futureEntries = db.prepare(`
SELECT * FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue > ?
ORDER BY numero_echeance
`).all(investissementId, last_date);
if (futureEntries.length === 0) return;
// Capital restant soldé → on met tout à zéro, et on porte le capital sur l'échéance courante
if (remainingCapital <= 0) {
// Entrée simul qui couvre la période du dernier remboursement capital
const lastPaidEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu
FROM simul_remboursements
WHERE investissement_id = ? AND date_prevue <= ?
ORDER BY date_prevue DESC
LIMIT 1
`).get(investissementId, last_date);
db.transaction(() => {
// Supprimer les échéances futures devenues caduques
const stmtDel = db.prepare('DELETE FROM simul_remboursements WHERE id=?');
for (const e of futureEntries) stmtDel.run(e.id);
// Mettre à jour l'échéance courante pour qu'elle reflète le capital soldé
if (lastPaidEntry) {
db.prepare(`
UPDATE simul_remboursements
SET capital_prevu=?, total_prevu=?
WHERE id=?
`).run(
capitalAtLastDate,
round2(capitalAtLastDate + lastPaidEntry.interets_prevus),
lastPaidEntry.id,
);
}
})();
return;
}
const step = inv.freq_interets === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step;
const nFuture = futureEntries.length;
const type = inv.type_remb || 'in_fine';
// Réinvestissements encore à venir (après la date du dernier remboursement)
const futureReinvests = reinvests.filter(r => r.date_reinvestissement > last_date);
const updates = [];
if (type === 'differe') {
// Versement unique : recalcul sur capital restant + réinvestissements futurs
const entry = futureEntries[0];
const extraCapital = futureReinvests
.filter(r => r.date_reinvestissement <= entry.date_prevue)
.reduce((s, r) => s + r.montant, 0);
const totalCap = round2(remainingCapital + extraCapital);
const moisRestants = monthsDiff(last_date, entry.date_prevue);
const interets = round2(totalCap * (inv.taux_interet / 100 / 12) * moisRestants);
updates.push({ id: entry.id, capital: totalCap, interets, total: round2(totalCap + interets) });
} else if (type === 'in_fine') {
// Intérêts sur capital courant (augmente à chaque réinvestissement futur)
let capital = remainingCapital;
let reinvestIdx = 0;
futureEntries.forEach((entry, i) => {
while (reinvestIdx < futureReinvests.length &&
futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) {
capital += futureReinvests[reinvestIdx].montant;
reinvestIdx++;
}
const isLast = i === nFuture - 1;
const interets = round2(capital * rPer);
updates.push({
id: entry.id,
capital: isLast ? capital : 0,
interets,
total: round2(interets + (isLast ? capital : 0)),
});
});
} else {
// Amortissable : recalcul période par période avec capital courant
let restant = remainingCapital;
let reinvestIdx = 0;
futureEntries.forEach((entry, i) => {
while (reinvestIdx < futureReinvests.length &&
futureReinvests[reinvestIdx].date_reinvestissement <= entry.date_prevue) {
restant += futureReinvests[reinvestIdx].montant;
reinvestIdx++;
}
const periodsLeft = nFuture - i;
const a = rPer === 0
? restant / periodsLeft
: restant * rPer / (1 - Math.pow(1 + rPer, -periodsLeft));
const interets = round2(restant * rPer);
const capital = round2(a - interets);
restant = round2(restant - capital);
updates.push({ id: entry.id, capital, interets, total: round2(a) });
});
}
db.transaction(() => {
const stmt = db.prepare(`
UPDATE simul_remboursements
SET capital_prevu = ?, interets_prevus = ?, total_prevu = ?
WHERE id = ?
`);
for (const u of updates) stmt.run(u.capital, u.interets, u.total, u.id);
})();
// Ajustement de la première période partielle (mois incomplet)
adjustFirstPartialPeriod(db, investissementId);
}
/**
* Détecte et corrige la première période partielle (mois incomplet au démarrage du prêt).
*
* Pour les prêts in_fine et amortissables, la première échéance avec intérêts correspond
* parfois à un mois incomplet (souscription en milieu de mois). Si le premier remboursement
* réel avec intérêts est inférieur au montant simulé, on met à jour la ligne simul
* correspondante avec la valeur réelle et on reporte la différence sur la dernière échéance.
*
* L'ajustement est idempotent : si les valeurs sont déjà cohérentes, rien n'est modifié.
*/
function adjustFirstPartialPeriod(db, investissementId) {
const inv = db.prepare(
'SELECT type_remb FROM investissements WHERE id = ?'
).get(investissementId);
if (!inv || inv.type_remb === 'differe') return;
// Premier remboursement enregistré avec des intérêts (exclure cashback-only)
const firstRemb = db.prepare(`
SELECT date_remb, interets_bruts
FROM remboursements
WHERE investissement_id = ? AND interets_bruts > 0
ORDER BY date_remb ASC
LIMIT 1
`).get(investissementId);
if (!firstRemb) return;
const firstRembMonth = firstRemb.date_remb.slice(0, 7); // YYYY-MM
// Entrée simul du même mois YYYY-MM
const simulEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu, total_prevu
FROM simul_remboursements
WHERE investissement_id = ? AND substr(date_prevue, 1, 7) = ?
`).get(investissementId, firstRembMonth);
if (!simulEntry) return;
const diff = round2(simulEntry.interets_prevus - firstRemb.interets_bruts);
if (diff <= 0.001) return; // Pas d'écart significatif, aucune correction nécessaire
// Dernière échéance simul (celle qui absorbera la différence)
const lastEntry = db.prepare(`
SELECT id, interets_prevus, capital_prevu, total_prevu
FROM simul_remboursements
WHERE investissement_id = ?
ORDER BY numero_echeance DESC
LIMIT 1
`).get(investissementId);
if (!lastEntry || lastEntry.id === simulEntry.id) return;
const newFirstInterets = round2(firstRemb.interets_bruts);
const newFirstTotal = round2(simulEntry.capital_prevu + newFirstInterets);
const newLastInterets = round2(lastEntry.interets_prevus + diff);
const newLastTotal = round2(lastEntry.capital_prevu + newLastInterets);
db.transaction(() => {
db.prepare(
'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?'
).run(newFirstInterets, newFirstTotal, simulEntry.id);
db.prepare(
'UPDATE simul_remboursements SET interets_prevus=?, total_prevu=? WHERE id=?'
).run(newLastInterets, newLastTotal, lastEntry.id);
})();
}
/**
* Génère (ou régénère) le tableau d'amortissement en tenant compte des réinvestissements.
*
* Pour chaque période, le capital pris en compte est :
* montant_investi + SUM(reinvestissements dont date <= date_période)
*
* - in_fine : intérêts recalculés sur le capital cumulé à chaque période,
* capital final = capital total cumulé
* - differe : versement unique recalculé sur le capital total à l'échéance
* - amortissable : intérêts de base + quote-part du réinvestissement sur les périodes restantes
*/
export function generateSimulWithReinvestissements(db, investissementId) {
const inv = db.prepare(`
SELECT id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois
FROM investissements WHERE id = ?
`).get(investissementId);
if (!inv) return;
const reinvests = db.prepare(
'SELECT montant, date_reinvestissement FROM reinvestissements WHERE investissement_id = ? ORDER BY date_reinvestissement'
).all(investissementId);
// Pas de réinvestissement → génération standard
if (!reinvests.length) {
generateSimul(db, inv);
return;
}
if (!inv.taux_interet || !inv.duree_mois) return;
const startDate = inv.date_debut_simul || inv.date_premiere_echeance || inv.date_souscription;
if (!startDate) return;
const finDeMois = !!inv.echeance_fin_de_mois;
const type = inv.type_remb || 'in_fine';
const freq = inv.freq_interets || 'mensuel';
const step = freq === 'trimestriel' ? 3 : 1;
const rPer = (inv.taux_interet / 100 / 12) * step;
// Durée effective (tient compte d'une éventuelle restructuration)
let effectiveDuree = inv.duree_mois;
if (inv.date_debut_simul && inv.date_premiere_echeance && inv.date_debut_simul > inv.date_premiere_echeance) {
const elapsed = monthsDiff(inv.date_premiere_echeance, inv.date_debut_simul);
effectiveDuree = Math.max(1, inv.duree_mois - elapsed);
}
// Calendrier de base pour obtenir les dates de chaque échéance
const baseSchedule = buildSchedule({
montant: inv.montant_investi,
taux: inv.taux_interet,
duree: effectiveDuree,
type, freq, startDate, finDeMois,
});
let capital = inv.montant_investi;
let reinvestIdx = 0;
const schedule = [];
// Pour amortissable : on garde la structure du capital initial et ajoute l'impact
// du réinvestissement sur les périodes restantes (quote-part d'intérêts supplémentaires).
// Indexes des réinvestissements non encore traités dans le plan amorti.
let pendingReinvests = [];
for (let i = 0; i < baseSchedule.length; i++) {
const entry = baseSchedule[i];
// Réinvestissements dont la date tombe avant (ou sur) cette échéance
while (reinvestIdx < reinvests.length &&
reinvests[reinvestIdx].date_reinvestissement <= entry.date) {
const r = reinvests[reinvestIdx];
capital += r.montant;
// Pour amortissable : mémoriser le montant réinvesti et le nb de périodes restantes
if (type === 'amortissable') {
pendingReinvests.push({ montant: r.montant, remainingPeriods: baseSchedule.length - i });
}
reinvestIdx++;
}
const isLast = i === baseSchedule.length - 1;
let entryCapital, entryInterets, entryTotal;
if (type === 'in_fine') {
entryInterets = round2(capital * rPer);
entryCapital = isLast ? capital : 0;
entryTotal = round2(entryInterets + entryCapital);
} else if (type === 'differe') {
// Versement unique : intérêts recalculés sur le capital total depuis le début
const totalMonths = inv.duree_mois;
entryInterets = round2(capital * (inv.taux_interet / 100 / 12) * totalMonths);
entryCapital = capital;
entryTotal = round2(entryCapital + entryInterets);
} else {
// Amortissable : intérêts de base + quote-part des réinvestissements
const baseInterets = entry.interets;
const extraInterets = pendingReinvests.reduce((sum, pr) => {
return sum + round2(pr.montant * rPer);
}, 0);
entryInterets = round2(baseInterets + extraInterets);
entryCapital = entry.capital;
entryTotal = round2(entryCapital + entryInterets);
}
schedule.push({ n: entry.n, date: entry.date, capital: entryCapital, interets: entryInterets, total: entryTotal });
}
db.transaction(() => {
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(investissementId);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of schedule) {
stmt.run(investissementId, e.n, e.date, e.capital, e.interets, e.total);
}
})();
}
/**
* Génère (ou régénère) le tableau d'amortissement d'un investissement dans la DB.
* Ne fait rien si taux_interet ou duree_mois est absent.
*
* Si date_debut_simul est renseigné (restructuration de prêt), la simulation
* démarre à cette date et la durée effective est réduite du nombre de mois déjà
* écoulés depuis date_premiere_echeance, afin de ne pas allonger artificiellement le prêt.
*/
export function generateSimul(db, inv) {
const { id, montant_investi, taux_interet, duree_mois, type_remb, freq_interets,
date_premiere_echeance, date_debut_simul, date_souscription, echeance_fin_de_mois } = inv;
if (!taux_interet || !duree_mois) return;
// date_debut_simul remplace le point de départ quand le prêt a été restructuré
const startDate = date_debut_simul || date_premiere_echeance || date_souscription;
if (!startDate) return;
// Durée effective : si restructuration, on soustrait les mois déjà écoulés
// pour que la simulation se termine bien à la date cible contractuelle d'origine.
let effectiveDuree = duree_mois;
if (date_debut_simul && date_premiere_echeance && date_debut_simul > date_premiere_echeance) {
const elapsed = monthsDiff(date_premiere_echeance, date_debut_simul);
effectiveDuree = Math.max(1, duree_mois - elapsed);
}
const echeances = buildSchedule({
montant: montant_investi,
taux: taux_interet,
duree: effectiveDuree,
type: type_remb || 'in_fine',
freq: freq_interets || 'mensuel',
startDate,
finDeMois: !!echeance_fin_de_mois,
});
const tx = db.transaction(() => {
if (date_debut_simul) {
// ── Mode restructuration ──────────────────────────────────────────────
// Conserver uniquement les échéances déjà payées avant la date de restructuration
// (correspondance par mois YYYY-MM avec les remboursements réels enregistrés).
// Les échéances de la période creuse (non payées entre fin de la phase initiale
// et date_debut_simul) sont supprimées avec tout ce qui suit.
const keptEntries = db.prepare(`
SELECT sr.id, sr.numero_echeance FROM simul_remboursements sr
WHERE sr.investissement_id = ?
AND sr.date_prevue < ?
AND EXISTS (
SELECT 1 FROM remboursements r
WHERE r.investissement_id = sr.investissement_id
AND substr(r.date_remb, 1, 7) = substr(sr.date_prevue, 1, 7)
)
ORDER BY sr.numero_echeance
`).all(id, date_debut_simul);
if (keptEntries.length > 0) {
// Supprime tout sauf les entrées payées conservées
db.prepare(
`DELETE FROM simul_remboursements WHERE investissement_id = ? AND id NOT IN (${keptEntries.map(() => '?').join(',')})`
).run(id, ...keptEntries.map(e => e.id));
} else {
// Rien à conserver → suppression totale
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
}
// Numérotation absolue : position dans le prêt total = mois écoulés depuis la 1ère échéance.
// Ex : date_premiere_echeance = août 2024, date_debut_simul = juillet 2025
// → 11 mois écoulés → nouvelle échéance 1 = n° 12, dernière = n° 48 (sur 48 total).
// On ne se base PAS sur le nombre d'entrées conservées (qui peut différer si certains
// paiements in fine ne matchent pas exactement) mais sur le décalage calendaire réel.
const elapsedMonths = (date_premiere_echeance && date_debut_simul > date_premiere_echeance)
? monthsDiff(date_premiere_echeance, date_debut_simul)
: keptEntries.length; // fallback : nombre de lignes conservées
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(id, elapsedMonths + e.n, e.date, e.capital, e.interets, e.total);
}
} else {
// ── Mode standard : régénération complète ────────────────────────────
db.prepare('DELETE FROM simul_remboursements WHERE investissement_id=?').run(id);
const stmt = db.prepare(`
INSERT INTO simul_remboursements
(investissement_id, numero_echeance, date_prevue, capital_prevu, interets_prevus, total_prevu)
VALUES (?,?,?,?,?,?)
`);
for (const e of echeances) {
stmt.run(id, e.n, e.date, e.capital, e.interets, e.total);
}
}
});
tx();
}
+157
View File
@@ -0,0 +1,157 @@
/**
* zip.js — Minimal pure-Node.js ZIP builder / reader.
* No external dependencies — uses only Node's built-in zlib.
*/
import zlib from 'zlib';
// ── CRC-32 ────────────────────────────────────────────────────────────────
const _crcTable = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c;
}
return t;
})();
function crc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) crc = (crc >>> 8) ^ _crcTable[(crc ^ buf[i]) & 0xFF];
return (crc ^ 0xFFFFFFFF) >>> 0;
}
// ── DOS date/time ─────────────────────────────────────────────────────────
function dosDateTime(d = new Date()) {
const time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
const date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
return { time, date };
}
/**
* createZip(entries) → Buffer
* entries: [{ name: string, data: Buffer|string }]
*/
export function createZip(entries) {
const chunks = [];
const centralHeaders = [];
let offset = 0;
const { time: modTime, date: modDate } = dosDateTime();
for (let { name, data } of entries) {
if (typeof data === 'string') data = Buffer.from(data, 'utf8');
const nameBuf = Buffer.from(name, 'utf8');
const crc = crc32(data);
const uncompSize = data.length;
// Try deflate; use stored if compressed is larger
const deflated = zlib.deflateRawSync(data);
const useDeflate = deflated.length < uncompSize;
const compData = useDeflate ? deflated : data;
const method = useDeflate ? 8 : 0;
// Local file header
const lh = Buffer.alloc(30 + nameBuf.length);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4);
lh.writeUInt16LE(0, 6);
lh.writeUInt16LE(method, 8);
lh.writeUInt16LE(modTime, 10);
lh.writeUInt16LE(modDate, 12);
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(compData.length, 18);
lh.writeUInt32LE(uncompSize, 22);
lh.writeUInt16LE(nameBuf.length, 26);
lh.writeUInt16LE(0, 28);
nameBuf.copy(lh, 30);
chunks.push(lh, compData);
centralHeaders.push({ name: nameBuf, crc, method, modTime, modDate, compSize: compData.length, uncompSize, offset });
offset += lh.length + compData.length;
}
// Central directory
const cdStart = offset;
for (const h of centralHeaders) {
const cd = Buffer.alloc(46 + h.name.length);
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4);
cd.writeUInt16LE(20, 6);
cd.writeUInt16LE(0, 8);
cd.writeUInt16LE(h.method, 10);
cd.writeUInt16LE(h.modTime, 12);
cd.writeUInt16LE(h.modDate, 14);
cd.writeUInt32LE(h.crc, 16);
cd.writeUInt32LE(h.compSize, 20);
cd.writeUInt32LE(h.uncompSize, 24);
cd.writeUInt16LE(h.name.length, 28);
cd.writeUInt16LE(0, 30);
cd.writeUInt16LE(0, 32);
cd.writeUInt16LE(0, 34);
cd.writeUInt16LE(0, 36);
cd.writeUInt32LE(0, 38);
cd.writeUInt32LE(h.offset, 42);
h.name.copy(cd, 46);
chunks.push(cd);
offset += cd.length;
}
const cdSize = offset - cdStart;
// End of central directory
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(centralHeaders.length, 8);
eocd.writeUInt16LE(centralHeaders.length, 10);
eocd.writeUInt32LE(cdSize, 12);
eocd.writeUInt32LE(cdStart, 16);
eocd.writeUInt16LE(0, 20);
chunks.push(eocd);
return Buffer.concat(chunks);
}
/**
* readZip(buffer) → [{ name: string, data: Buffer }]
*/
export function readZip(buffer) {
// Scan backwards for EOCD signature
let eocdOffset = -1;
for (let i = buffer.length - 22; i >= Math.max(0, buffer.length - 65558); i--) {
if (buffer.readUInt32LE(i) === 0x06054b50) { eocdOffset = i; break; }
}
if (eocdOffset === -1) throw new Error('ZIP invalide : signature EOCD introuvable');
const entryCount = buffer.readUInt16LE(eocdOffset + 8);
const cdOffset = buffer.readUInt32LE(eocdOffset + 16);
const entries = [];
let pos = cdOffset;
for (let i = 0; i < entryCount; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
const method = buffer.readUInt16LE(pos + 10);
const compSize = buffer.readUInt32LE(pos + 20);
const uncompSize = buffer.readUInt32LE(pos + 24);
const nameLen = buffer.readUInt16LE(pos + 28);
const extraLen = buffer.readUInt16LE(pos + 30);
const commentLen = buffer.readUInt16LE(pos + 32);
const localOffset = buffer.readUInt32LE(pos + 42);
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
// Read local file header to get actual extra field length
const localNameLen = buffer.readUInt16LE(localOffset + 26);
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const compData = buffer.subarray(dataStart, dataStart + compSize);
const data = method === 0 ? compData : zlib.inflateRawSync(compData);
entries.push({ name, data: Buffer.from(data) });
pos += 46 + nameLen + extraLen + commentLen;
}
return entries;
}