Download app/static/js/validate.js from nvikou/Sheetly: direct link, hf CLI and curl.
- Browser
- Download file 11.1 kB
-
https://huggingface.co/spaces/nvikou/Sheetly/resolve/main/app/static/js/validate.js
- Command line
-
hf download hf://spaces/nvikou/Sheetly/app/static/js/validate.js
-
curl -L -o validate.js https://huggingface.co/spaces/nvikou/Sheetly/resolve/main/app/static/js/validate.js
11.1 kB
| (() => { | |
| const queueList = document.getElementById("queueList"); | |
| const queueSearch = document.getElementById("queueSearch"); | |
| const queueCount = document.getElementById("queueCount"); | |
| const counter = document.getElementById("counter"); | |
| const form = document.getElementById("fieldsForm"); | |
| const preview = document.getElementById("preview"); | |
| const issuesEl = document.getElementById("issues"); | |
| const typeBadge = document.getElementById("typeBadge"); | |
| const fileBadge = document.getElementById("fileBadge"); | |
| const saveBtn = document.getElementById("saveBtn"); | |
| const prevBtn = document.getElementById("prevBtn"); | |
| const nextBtn = document.getElementById("nextBtn"); | |
| const toast = document.getElementById("toast"); | |
| let docs = []; | |
| let filtered = []; | |
| let currentId = null; | |
| let current = null; | |
| const params = new URLSearchParams(location.search); | |
| const focusId = params.get("id"); | |
| function showToast(message) { | |
| toast.textContent = message; | |
| toast.classList.add("show"); | |
| setTimeout(() => toast.classList.remove("show"), 1800); | |
| } | |
| function typeLabel(type) { | |
| return (type || "other").replaceAll("_", " "); | |
| } | |
| function fieldLabel(key) { | |
| return key.replaceAll("_", " "); | |
| } | |
| function needsReview(doc) { | |
| const conf = doc.confidence || {}; | |
| return Object.values(conf).some( | |
| (v) => v && v.needs_review | |
| ); | |
| } | |
| async function load() { | |
| const [readyRes, validatedRes] = await Promise.all([ | |
| fetch("/api/documents?limit=200&status=ready"), | |
| fetch("/api/documents?limit=200&status=validated"), | |
| ]); | |
| const ready = await readyRes.json(); | |
| const validated = await validatedRes.json(); | |
| const map = new Map(); | |
| [...ready, ...validated].forEach((d) => map.set(d.id, d)); | |
| docs = Array.from(map.values()).sort( | |
| (a, b) => | |
| new Date(b.created_at) - new Date(a.created_at) | |
| ); | |
| filtered = docs; | |
| queueCount.textContent = String(docs.length); | |
| renderQueue(); | |
| if (!docs.length) { | |
| preview.innerHTML = | |
| '<p class="preview-empty">No documents ready for review yet. Upload files first.</p>'; | |
| form.innerHTML = | |
| '<p class="fields-empty">Nothing to validate.</p>'; | |
| updateCounter(-1); | |
| return; | |
| } | |
| const start = | |
| docs.find((d) => d.id === focusId) || docs[0]; | |
| await show(start.id); | |
| } | |
| function renderQueue() { | |
| const q = (queueSearch.value || "").trim().toLowerCase(); | |
| filtered = docs.filter((d) => { | |
| if (!q) return true; | |
| return ( | |
| d.filename.toLowerCase().includes(q) || | |
| d.doc_type.toLowerCase().includes(q) | |
| ); | |
| }); | |
| queueList.innerHTML = ""; | |
| filtered.forEach((d) => { | |
| const li = document.createElement("li"); | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = | |
| "queue-item" + | |
| (d.id === currentId ? " active" : ""); | |
| const review = needsReview(d) | |
| ? '<span class="badge warn">review</span>' | |
| : '<span class="badge ok">ok</span>'; | |
| const dup = d.is_duplicate | |
| ? '<span class="badge warn">duplicate</span>' | |
| : ""; | |
| btn.innerHTML = | |
| `<div class="name">${escapeHtml(d.filename)}</div>` + | |
| `<div class="row">` + | |
| `<span class="badge type-${escapeAttr(d.doc_type)}">` + | |
| `${escapeHtml(typeLabel(d.doc_type))}</span>` + | |
| review + | |
| dup + | |
| `</div>`; | |
| btn.addEventListener("click", () => show(d.id)); | |
| li.appendChild(btn); | |
| queueList.appendChild(li); | |
| }); | |
| if (!filtered.length) { | |
| queueList.innerHTML = | |
| '<li class="preview-empty">No matches.</li>'; | |
| } | |
| } | |
| function updateCounter(index) { | |
| if (index < 0) { | |
| counter.innerHTML = "<strong>0</strong> / 0 documents"; | |
| return; | |
| } | |
| counter.innerHTML = | |
| `<strong>${index + 1}</strong> / ${docs.length} documents`; | |
| } | |
| async function show(id) { | |
| currentId = id; | |
| const res = await fetch(`/api/documents/${id}`); | |
| current = await res.json(); | |
| const index = docs.findIndex((d) => d.id === id); | |
| updateCounter(index); | |
| typeBadge.textContent = typeLabel(current.doc_type); | |
| typeBadge.className = `badge type-${current.doc_type}`; | |
| fileBadge.textContent = current.filename; | |
| renderPreview(current); | |
| renderIssues(current.issues || []); | |
| renderFields(current); | |
| renderQueue(); | |
| history.replaceState(null, "", `/validate?id=${id}`); | |
| } | |
| async function renderPreview(doc) { | |
| const url = `/api/documents/${doc.id}/file`; | |
| const name = (doc.filename || "").toLowerCase(); | |
| const missing = | |
| '<p class="preview-empty">Preview unavailable — ' + | |
| "the original file was removed. Extracted fields " + | |
| "on the right are still valid.</p>"; | |
| if (/\.(png|jpe?g|webp|gif|tif{1,2})$/.test(name)) { | |
| const img = new Image(); | |
| img.alt = "document"; | |
| img.onerror = () => { | |
| preview.innerHTML = missing; | |
| }; | |
| img.src = url; | |
| preview.innerHTML = ""; | |
| preview.appendChild(img); | |
| return; | |
| } | |
| if (name.endsWith(".pdf")) { | |
| preview.innerHTML = | |
| `<iframe src="${url}" title="document"></iframe>`; | |
| return; | |
| } | |
| try { | |
| const res = await fetch(url); | |
| if (!res.ok) { | |
| preview.innerHTML = missing; | |
| return; | |
| } | |
| if (name.endsWith(".txt")) { | |
| const text = await res.text(); | |
| preview.innerHTML = | |
| `<pre style="white-space:pre-wrap;margin:0;font:inherit;line-height:1.5">${escapeHtml(text)}</pre>`; | |
| return; | |
| } | |
| preview.innerHTML = | |
| `<p class="preview-empty"><a href="${url}" target="_blank">Open ${escapeHtml(doc.filename)}</a></p>`; | |
| } catch { | |
| preview.innerHTML = missing; | |
| } | |
| } | |
| function renderIssues(issues) { | |
| issuesEl.innerHTML = ""; | |
| issues.forEach((issue) => { | |
| const li = document.createElement("li"); | |
| li.textContent = issue.message || issue.code; | |
| if (issue.severity === "error") { | |
| li.classList.add("error"); | |
| } | |
| issuesEl.appendChild(li); | |
| }); | |
| } | |
| function renderFields(doc) { | |
| form.innerHTML = ""; | |
| const data = doc.extracted || {}; | |
| const conf = doc.confidence || {}; | |
| Object.keys(data).forEach((key) => { | |
| if ( | |
| key === "lines" || | |
| key === "items" || | |
| key === "parties" | |
| ) { | |
| const wrap = document.createElement("div"); | |
| wrap.className = "field"; | |
| wrap.innerHTML = | |
| `<label><span>${fieldLabel(key)}</span></label>` + | |
| `<textarea name="${key}" rows="6">` + | |
| `${escapeHtml(JSON.stringify(data[key], null, 2))}` + | |
| `</textarea>`; | |
| form.appendChild(wrap); | |
| return; | |
| } | |
| const meta = conf[key] || {}; | |
| const wrap = document.createElement("div"); | |
| wrap.className = | |
| "field" + | |
| (meta.needs_review ? " needs-review" : ""); | |
| const score = | |
| meta.score != null | |
| ? `<span class="conf">${(meta.score * 100).toFixed(0)}%</span>` | |
| : ""; | |
| wrap.innerHTML = | |
| `<label><span>${fieldLabel(key)}</span>${score}</label>` + | |
| `<input name="${key}" value="${escapeAttr( | |
| data[key] == null ? "" : data[key] | |
| )}" />`; | |
| form.appendChild(wrap); | |
| }); | |
| } | |
| function escapeHtml(s) { | |
| return String(s) | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">"); | |
| } | |
| function escapeAttr(s) { | |
| return String(s).replace(/"/g, """); | |
| } | |
| function collectPayload() { | |
| const extracted = {}; | |
| const fd = new FormData(form); | |
| for (const [key, value] of fd.entries()) { | |
| if ( | |
| key === "lines" || | |
| key === "items" || | |
| key === "parties" | |
| ) { | |
| try { | |
| extracted[key] = JSON.parse(value); | |
| } catch { | |
| extracted[key] = value; | |
| } | |
| continue; | |
| } | |
| if (value === "") { | |
| extracted[key] = null; | |
| continue; | |
| } | |
| if ( | |
| [ | |
| "amount_ht", | |
| "vat_amount", | |
| "amount_ttc", | |
| "amount", | |
| "quantity", | |
| ].includes(key) | |
| ) { | |
| const n = Number(value); | |
| extracted[key] = Number.isFinite(n) ? n : value; | |
| } else if (key === "auto_renewal") { | |
| extracted[key] = | |
| value === "true" || value === "1"; | |
| } else { | |
| extracted[key] = value; | |
| } | |
| } | |
| return extracted; | |
| } | |
| saveBtn.addEventListener("click", async () => { | |
| if (!current) return; | |
| const body = { | |
| extracted: collectPayload(), | |
| doc_type: current.doc_type, | |
| }; | |
| const res = await fetch(`/api/documents/${current.id}`, { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(body), | |
| }); | |
| if (!res.ok) { | |
| showToast("Save failed"); | |
| return; | |
| } | |
| current = await res.json(); | |
| const idx = docs.findIndex((d) => d.id === current.id); | |
| if (idx >= 0) docs[idx] = current; | |
| renderIssues(current.issues || []); | |
| renderFields(current); | |
| renderQueue(); | |
| showToast("Corrections saved"); | |
| }); | |
| prevBtn.addEventListener("click", () => { | |
| const i = docs.findIndex((d) => d.id === currentId); | |
| if (i > 0) show(docs[i - 1].id); | |
| }); | |
| nextBtn.addEventListener("click", () => { | |
| const i = docs.findIndex((d) => d.id === currentId); | |
| if (i >= 0 && i < docs.length - 1) { | |
| show(docs[i + 1].id); | |
| } | |
| }); | |
| queueSearch.addEventListener("input", renderQueue); | |
| load(); | |
| })(); | |