347 lines
10 KiB
JavaScript
347 lines
10 KiB
JavaScript
const MAX_SYNC_FILES = 50;
|
|
|
|
const MAX_ASYNC_FILES = 500;
|
|
|
|
const JOB_CHUNK_SIZE = 500;
|
|
|
|
const LONG_POLL_MS = 30000;
|
|
|
|
const TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
|
|
|
|
|
|
function $(id) {
|
|
|
|
return document.getElementById(id);
|
|
|
|
}
|
|
|
|
|
|
|
|
function getBaseUrl() {
|
|
|
|
const input = $("baseUrl").value.trim();
|
|
|
|
return (input || window.location.origin).replace(/\/$/, "");
|
|
|
|
}
|
|
|
|
|
|
|
|
function getIncludeRejected() {
|
|
|
|
return $("includeRejected").checked;
|
|
|
|
}
|
|
|
|
|
|
|
|
function getSelectedFiles(inputId) {
|
|
|
|
const input = $(inputId);
|
|
|
|
return Array.from(input.files || []);
|
|
|
|
}
|
|
|
|
|
|
|
|
function validateFiles(files, maxFiles) {
|
|
|
|
if (files.length === 0) {
|
|
|
|
throw new Error("Selecciona al menos un archivo PDF.");
|
|
|
|
}
|
|
|
|
if (files.length > maxFiles) {
|
|
|
|
throw new Error(`Máximo ${maxFiles} archivos por petición.`);
|
|
|
|
}
|
|
|
|
for (const file of files) {
|
|
|
|
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
|
|
|
throw new Error(`"${file.name}" no es un PDF.`);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async function parseError(res) {
|
|
|
|
try {
|
|
|
|
const data = await res.json();
|
|
|
|
if (data && data.error) return data.error;
|
|
|
|
} catch (_) {
|
|
|
|
/* ignore */
|
|
|
|
}
|
|
|
|
return `Error HTTP ${res.status}`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function buildMultipart(files) {
|
|
|
|
const form = new FormData();
|
|
|
|
for (const file of files) {
|
|
|
|
form.append("files", file, file.name);
|
|
|
|
}
|
|
|
|
return form;
|
|
|
|
}
|
|
|
|
|
|
|
|
function setLoading(button, loading, label) {
|
|
|
|
button.disabled = loading;
|
|
|
|
button.textContent = loading ? "Procesando…" : label;
|
|
|
|
}
|
|
|
|
|
|
|
|
function showError(containerId, message) {
|
|
|
|
const el = $(containerId);
|
|
|
|
el.hidden = false;
|
|
|
|
el.textContent = message;
|
|
|
|
}
|
|
|
|
|
|
|
|
function hideError(containerId) {
|
|
|
|
const el = $(containerId);
|
|
|
|
el.hidden = true;
|
|
|
|
el.textContent = "";
|
|
|
|
}
|
|
|
|
|
|
|
|
function formatProbability(value) {
|
|
|
|
if (typeof value !== "number") return "—";
|
|
|
|
return (value * 100).toFixed(1) + "%";
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderResults(container, results) {
|
|
|
|
container.innerHTML = "";
|
|
|
|
|
|
|
|
if (!results || results.length === 0) {
|
|
|
|
container.innerHTML = '<p class="empty">Sin resultados.</p>';
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const item of results) {
|
|
|
|
const card = document.createElement("article");
|
|
|
|
card.className = "result-card";
|
|
|
|
|
|
|
|
const header = document.createElement("div");
|
|
|
|
header.className = "result-header";
|
|
|
|
header.innerHTML = `
|
|
|
|
<h3>${escapeHtml(item.filename)}</h3>
|
|
|
|
<span class="badge badge-${item.status}">${escapeHtml(item.status)}</span>
|
|
|
|
`;
|
|
|
|
card.appendChild(header);
|
|
|
|
|
|
|
|
if (item.status === "error" && item.error) {
|
|
|
|
const err = document.createElement("p");
|
|
|
|
err.className = "result-error";
|
|
|
|
err.textContent = item.error;
|
|
|
|
card.appendChild(err);
|
|
|
|
} else {
|
|
|
|
const meta = document.createElement("p");
|
|
|
|
meta.className = "result-meta";
|
|
|
|
meta.textContent = `Servicios detectados: ${item.service_count ?? 0}`;
|
|
|
|
card.appendChild(meta);
|
|
|
|
|
|
|
|
if (item.services && item.services.length > 0) {
|
|
|
|
const table = document.createElement("table");
|
|
|
|
table.innerHTML = `
|
|
|
|
<thead>
|
|
|
|
<tr>
|
|
|
|
<th>Texto</th>
|
|
|
|
<th>Probabilidad</th>
|
|
|
|
<th>Es servicio</th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody></tbody>
|
|
|
|
`;
|
|
|
|
const tbody = table.querySelector("tbody");
|
|
|
|
for (const row of item.services) {
|
|
|
|
const tr = document.createElement("tr");
|
|
|
|
const isService =
|
|
|
|
row.is_service === undefined || row.is_service === null
|
|
|
|
? "—"
|
|
|
|
: row.is_service
|
|
|
|
? "Sí"
|
|
|
|
: "No";
|
|
|
|
tr.innerHTML = `
|
|
|
|
<td>${escapeHtml(row.text)}</td>
|
|
|
|
<td>${formatProbability(row.probability)}</td>
|
|
|
|
<td>${isService}</td>
|
|
|
|
`;
|
|
|
|
tbody.appendChild(tr);
|
|
|
|
}
|
|
|
|
card.appendChild(table);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
container.appendChild(card);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function escapeHtml(text) {
|
|
|
|
const div = document.createElement("div");
|
|
|
|
div.textContent = text ?? "";
|
|
|
|
return div.innerHTML;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function countSync(baseUrl, files, includeRejected) {
|
|
|
|
const url = new URL(`${baseUrl}/v1/count`);
|
|
|
|
if (includeRejected) url.searchParams.set("include_rejected", "true");
|
|
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
method: "POST",
|
|
|
|
body: buildMultipart(files),
|
|
|
|
});
|
|
|
|
|
|
|
|
if (!res.ok) throw new Error(await parseError(res));
|
|
|
|
const data = await res.json();
|
|
|
|
return data.results || [];
|
|
|
|
}
|
|
|
|
|
|
|
|
async function createEmptyJob(baseUrl, includeRejected) {
|
|
|
|
const res = await fetch(`${baseUrl}/v1/jobs`, {
|
|
|
|
method: "POST",
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
body: JSON.stringify({ include_rejected: includeRejected }),
|
|
|
|
});
|
|
|
|
if (!res.ok) throw new Error(await parseError(res));
|
|
|
|
const data = await res.json();
|
|
|
|
return data.job_id;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function uploadChunk(baseUrl, jobId, files, includeRejected) {
|
|
|
|
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/upload`);
|
|
|
|
if (includeRejected) url.searchParams.set("include_rejected", "true");
|
|
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
method: "POST",
|
|
|
|
body: buildMultipart(files),
|
|
|
|
});
|
|
|
|
if (!res.ok) throw new Error(await parseError(res));
|
|
|
|
return res.json();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function getJobStatus(baseUrl, jobId, waitMs = 0) {
|
|
|
|
const url = new URL(`${baseUrl}/v1/jobs/${jobId}`);
|
|
|
|
if (waitMs > 0) url.searchParams.set("wait_ms", String(waitMs));
|
|
|
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) throw new Error(await parseError(res));
|
|
|
|
return res.json();
|
|
|
|
}
|
|
|
|
|
|
|
|
async function fetchAllResults(baseUrl, jobId) {
|
|
|
|
const results = [];
|
|
|
|
let cursor = "";
|
|
|
|
let hasMore = true;
|
|
|
|
|
|
|
|
while (hasMore) {
|
|
|
|
const url = new URL(`${baseUrl}/v1/jobs/${jobId}/results`);
|
|
|
|
url.searchParams.set("limit", "500");
|
|
|
|
if (cursor) url.searchParams.set("cursor", cursor);
|
|
|
|
|
|
|
|
const res = await fetch(url);
|
|
|
|
if (!res.ok) throw new Error(await parseError(res));
|
|
|
|
const page = await res.json();
|
|
|
|
results.push(...(page.results || []));
|
|
|
|
cursor = page.next_cursor || "";
|
|
|
|
hasMore = Boolean(page.has_more);
|
|
|
|
}
|
|
|
|
|
|
|
|
return results;
|
|
|
|
}
|
|
|
|
|
|
|
|
function chunkArray(items, size) {
|
|
|
|
const chunks = [];
|
|
|
|
for (let i = 0; i < items.length; i += size) {
|
|
|
|
chunks.push(items.slice(i, i + size));
|
|
|
|
}
|
|
|
|
return chunks;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function runAsyncJob(baseUrl, files, includeRejected, onProgress) {
|
|
|
|
const jobId = await createEmptyJob(baseUrl, includeRejected);
|
|
|
|
const chunks = chunkArray(files, JOB_CHUNK_SIZE);
|
|
|
|
|
|
|
|
for (let i = 0; i < chunks.length; i++) {
|
|
|
|
onProgress({
|
|
|
|
status: "uploading",
|
|
|
|
total: files.length,
|
|
|
|
processed: 0,
|
|
|
|
failed: 0,
|
|
|
|
uploadChunk: i + 1,
|
|
|
|
uploadChunks: chunks.length,
|
|
|
|
});
|
|
|
|
await uploadChunk(baseUrl, jobId, chunks[i], includeRejected);
|
|
|
|
}
|
|
|
|
|
|
|
|
while (true) {
|
|
|
|
const status = await getJobStatus(baseUrl, jobId, LONG_POLL_MS);
|
|
|
|
onProgress(status);
|
|
|
|
if (TERMINAL_STATUSES.has(status.status)) {
|
|
|
|
if (status.status === "cancelled") {
|
|
|
|
throw new Error("El job fue cancelado.");
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return fetchAllResults(baseUrl, jobId);
|
|
|
|
}
|
|
|
|
|
|
|
|
function updateProgress(status) {
|
|
|
|
const bar = $("asyncProgress");
|
|
|
|
const label = $("asyncProgressLabel");
|
|
|
|
const total = status.total || 0;
|
|
|
|
const done = (status.processed || 0) + (status.failed || 0);
|
|
|
|
const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
|
|
|
|
|
|
|
bar.hidden = false;
|
|
|
|
label.hidden = false;
|
|
|
|
|
|
|
|
if (status.status === "uploading") {
|
|
|
|
const chunkPct =
|
|
|
|
status.uploadChunks > 0
|
|
|
|
? Math.round((status.uploadChunk / status.uploadChunks) * 100)
|
|
|
|
: 0;
|
|
|
|
bar.value = chunkPct;
|
|
|
|
label.textContent = `Subiendo lote ${status.uploadChunk}/${status.uploadChunks} (${total} archivos)…`;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
bar.value = pct;
|
|
|
|
label.textContent = `Estado: ${status.status} — ${done}/${total} procesados (${status.failed || 0} fallidos)`;
|
|
|
|
}
|
|
|
|
|
|
|
|
function initTabs() {
|
|
|
|
const tabs = document.querySelectorAll(".tab");
|
|
|
|
const panels = document.querySelectorAll(".panel");
|
|
|
|
|
|
|
|
tabs.forEach((tab) => {
|
|
|
|
tab.addEventListener("click", () => {
|
|
|
|
tabs.forEach((t) => t.classList.remove("active"));
|
|
|
|
panels.forEach((p) => p.classList.remove("active"));
|
|
|
|
tab.classList.add("active");
|
|
|
|
document.getElementById(tab.dataset.panel).classList.add("active");
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function init() {
|
|
|
|
$("baseUrl").value = window.location.origin;
|
|
|
|
|
|
|
|
initTabs();
|
|
|
|
|
|
|
|
$("syncForm").addEventListener("submit", async (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
const btn = $("syncSubmit");
|
|
|
|
hideError("syncError");
|
|
|
|
$("syncResults").innerHTML = "";
|
|
|
|
|
|
|
|
try {
|
|
|
|
const files = getSelectedFiles("syncFiles");
|
|
|
|
validateFiles(files, MAX_SYNC_FILES);
|
|
|
|
setLoading(btn, true, "Contar servicios");
|
|
|
|
const results = await countSync(getBaseUrl(), files, getIncludeRejected());
|
|
|
|
renderResults($("syncResults"), results);
|
|
|
|
} catch (err) {
|
|
|
|
showError("syncError", err.message);
|
|
|
|
} finally {
|
|
|
|
setLoading(btn, false, "Contar servicios");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
$("asyncForm").addEventListener("submit", async (e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
const btn = $("asyncSubmit");
|
|
|
|
hideError("asyncError");
|
|
|
|
$("asyncResults").innerHTML = "";
|
|
|
|
$("asyncProgress").hidden = true;
|
|
|
|
$("asyncProgressLabel").hidden = true;
|
|
|
|
|
|
|
|
try {
|
|
|
|
const files = getSelectedFiles("asyncFiles");
|
|
|
|
validateFiles(files, MAX_ASYNC_FILES);
|
|
|
|
setLoading(btn, true, "Iniciar job");
|
|
|
|
const results = await runAsyncJob(
|
|
|
|
getBaseUrl(),
|
|
|
|
files,
|
|
|
|
getIncludeRejected(),
|
|
|
|
updateProgress,
|
|
|
|
);
|
|
|
|
renderResults($("asyncResults"), results);
|
|
|
|
} catch (err) {
|
|
|
|
showError("asyncError", err.message);
|
|
|
|
} finally {
|
|
|
|
setLoading(btn, false, "Iniciar job");
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
|
|
|