feat: Enhance CSV cleaning service with new endpoints for CSV analysis and preview functionality. Introduce support for job management linked to extraction batches, and update the cleaning rules structure. Add new utility functions for handling CSV data and improve documentation for clarity on usage and available endpoints.

This commit is contained in:
erik
2026-07-11 00:27:05 -05:00
parent cf0ebc72ba
commit b770761832
73 changed files with 7614 additions and 2671 deletions
@@ -0,0 +1,224 @@
/**
* Auto-update desde MinIO (apps-versions/IPSExtractor).
* Sin credenciales: lee latest.json y el Setup.exe con GetObject público.
*/
const { app } = require("electron");
const crypto = require("node:crypto");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const https = require("node:https");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const { version: CURRENT_VERSION } = require("../package.json");
const LATEST_JSON_URL =
process.env.IPS_UPDATE_LATEST_URL ||
"https://s3.builker.xyz/apps-versions/IPSExtractor/latest.json";
const FETCH_TIMEOUT_MS = 12_000;
/**
* @typedef {{
* version: string;
* releaseDate?: string;
* releaseDateDisplay?: string;
* fileName: string;
* url: string;
* sha256: string;
* size?: number;
* }} UpdateManifest
*/
/**
* @param {string} a
* @param {string} b
* @returns {number} -1 if a<b, 0 if equal, 1 if a>b
*/
function compareSemver(a, b) {
const pa = String(a).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
const pb = String(b).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x < y) return -1;
if (x > y) return 1;
}
return 0;
}
/**
* @param {string} urlStr
* @param {{ onProgress?: (received: number, total: number) => void, destPath?: string }} [opts]
* @returns {Promise<{ buffer?: Buffer, destPath?: string, headers: import('http').IncomingHttpHeaders }>}
*/
function download(urlStr, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const lib = url.protocol === "http:" ? http : https;
const req = lib.get(
url,
{
timeout: FETCH_TIMEOUT_MS,
headers: { "User-Agent": `IPSExtractor-Desktop/${CURRENT_VERSION}` },
},
(res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
download(res.headers.location, opts).then(resolve, reject);
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} al descargar ${urlStr}`));
return;
}
const total = Number(res.headers["content-length"] || 0);
let received = 0;
if (opts.destPath) {
const out = fs.createWriteStream(opts.destPath);
res.on("data", (chunk) => {
received += chunk.length;
opts.onProgress?.(received, total);
});
res.pipe(out);
out.on("finish", () => resolve({ destPath: opts.destPath, headers: res.headers }));
out.on("error", reject);
res.on("error", reject);
return;
}
const chunks = [];
res.on("data", (chunk) => {
chunks.push(chunk);
received += chunk.length;
opts.onProgress?.(received, total);
});
res.on("end", () => resolve({ buffer: Buffer.concat(chunks), headers: res.headers }));
res.on("error", reject);
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("Timeout al contactar el servidor de versiones"));
});
req.on("error", reject);
});
}
/**
* @returns {Promise<{ updateAvailable: boolean, currentVersion: string, manifest?: UpdateManifest, error?: string }>}
*/
async function checkForUpdates() {
try {
const { buffer } = await download(LATEST_JSON_URL);
if (!buffer) {
return { updateAvailable: false, currentVersion: CURRENT_VERSION, error: "Respuesta vacía" };
}
/** @type {UpdateManifest} */
const manifest = JSON.parse(buffer.toString("utf8"));
if (!manifest?.version || !manifest?.url || !manifest?.fileName) {
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
error: "Manifiesto inválido",
};
}
// Si falta la fecha de DATA.txt en el manifiesto, intentar leerla junto al exe
if (!manifest.releaseDateDisplay) {
try {
const dataUrl = manifest.url.replace(/[^/]+$/, "DATA.txt");
const { buffer: dataBuf } = await download(dataUrl);
if (dataBuf) {
const display = dataBuf.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
if (display) {
manifest.releaseDateDisplay = display;
const iso = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (iso && !manifest.releaseDate) {
manifest.releaseDate = `${iso[3]}-${iso[2].padStart(2, "0")}-${iso[1].padStart(2, "0")}`;
}
}
}
} catch {
/* opcional */
}
}
const newer = compareSemver(CURRENT_VERSION, manifest.version) < 0;
return {
updateAvailable: newer,
currentVersion: CURRENT_VERSION,
manifest,
};
} catch (err) {
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* @param {UpdateManifest} manifest
* @param {(payload: { phase: string, received?: number, total?: number, percent?: number }) => void} [onProgress]
*/
async function downloadAndInstall(manifest, onProgress) {
if (!manifest?.url || !manifest?.fileName) {
throw new Error("Manifiesto incompleto");
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "ipsextractor-update-"));
const destPath = path.join(tmpDir, path.basename(manifest.fileName));
onProgress?.({ phase: "downloading", received: 0, total: manifest.size || 0, percent: 0 });
await download(manifest.url, {
destPath,
onProgress: (received, total) => {
const t = total || manifest.size || 0;
const percent = t > 0 ? Math.min(100, Math.round((received / t) * 100)) : 0;
onProgress?.({ phase: "downloading", received, total: t, percent });
},
});
onProgress?.({ phase: "verifying" });
if (manifest.sha256) {
const hash = crypto.createHash("sha256");
const fileBuf = await fsp.readFile(destPath);
hash.update(fileBuf);
const digest = hash.digest("hex").toLowerCase();
if (digest !== String(manifest.sha256).toLowerCase()) {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error("El instalador descargado no coincide con el hash esperado (sha256).");
}
}
onProgress?.({ phase: "installing" });
// NSIS silent install; la app debe cerrarse para que el instalador reemplace archivos.
const child = spawn(destPath, ["/S"], {
detached: true,
stdio: "ignore",
windowsHide: false,
});
child.unref();
setTimeout(() => {
app.quit();
}, 400);
return { ok: true, destPath };
}
module.exports = {
CURRENT_VERSION,
LATEST_JSON_URL,
compareSemver,
checkForUpdates,
downloadAndInstall,
};