version 1 - with FastAPI
This commit is contained in:
parent
ea67f3c30b
commit
db8680baa2
@ -1,35 +1,43 @@
|
||||
# api/routers/invoices.py
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
|
||||
from typing import Dict
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status, Form
|
||||
from typing import Dict, Optional
|
||||
|
||||
from api.dependencies import get_current_active_user
|
||||
from services import invoice_processor_service
|
||||
from core.config import settings
|
||||
from db.models import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_CONTENT_TYPES = ["application/pdf", "image/jpeg", "image/png", "image/tiff"]
|
||||
|
||||
@router.post("/upload", response_model=Dict[str, str])
|
||||
async def upload_invoice(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
# Nuevo parámetro: viene del formulario, es opcional y debe estar entre 0.0 y 1.0
|
||||
confidence_threshold: Optional[float] = Form(None, ge=0.0, le=1.0)
|
||||
):
|
||||
"""
|
||||
Endpoint para subir una factura, procesarla y devolver los datos extraídos.
|
||||
Requiere autenticación.
|
||||
Endpoint para subir una factura. Ahora acepta un umbral de confianza opcional.
|
||||
"""
|
||||
if not file.content_type in ["application/pdf", "image/jpeg", "image/png"]:
|
||||
raise HTTPException(status_code=400, detail="Tipo de archivo no soportado.")
|
||||
if file.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Tipo de archivo no soportado. Permitidos: {', '.join(ALLOWED_CONTENT_TYPES)}"
|
||||
)
|
||||
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
|
||||
# Pasamos el umbral recibido al servicio
|
||||
extracted_data = invoice_processor_service.process_invoice_from_bytes(
|
||||
project_id=settings.GCP_PROJECT_ID,
|
||||
location=settings.GCP_LOCATION,
|
||||
processor_id=settings.DOCAI_PROCESSOR_ID,
|
||||
file_bytes=file_bytes,
|
||||
mime_type=file.content_type
|
||||
mime_type=file.content_type,
|
||||
override_threshold=confidence_threshold
|
||||
)
|
||||
return extracted_data
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Error al procesar la factura: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error al procesar la factura: {e}"
|
||||
)
|
||||
BIN
invoice-processing-google-document-ai-fastAPI.tgz
Normal file
BIN
invoice-processing-google-document-ai-fastAPI.tgz
Normal file
Binary file not shown.
@ -1,46 +1,35 @@
|
||||
# requirements.txt
|
||||
# Dependencias de la aplicación y del toolkit unificadas
|
||||
|
||||
# Framework Web y Servidor
|
||||
# --- Core Web Framework & Server ---
|
||||
# El corazón de nuestra API y el servidor para ejecutarla.
|
||||
# [standard] incluye extras de alto rendimiento como uvloop.
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
# Base de datos (ORM)
|
||||
# --- Database & ORM ---
|
||||
# Para la interacción con nuestra base de datos SQLite.
|
||||
sqlalchemy
|
||||
# Para usar SQLite (simple para empezar)
|
||||
pydantic-settings
|
||||
|
||||
# Autenticación y Seguridad
|
||||
python-jose[cryptography]
|
||||
# --- Authentication & Security ---
|
||||
# Hashing de contraseñas y manejo de tokens JWT.
|
||||
# [bcrypt] y [cryptography] son los backends recomendados.
|
||||
passlib[bcrypt]
|
||||
python-multipart # Para subida de archivos
|
||||
python-jose[cryptography]
|
||||
|
||||
# Plantillas HTML
|
||||
# --- Data Validation & Configuration ---
|
||||
# Validación de datos en la API y carga de configuración desde .env
|
||||
pydantic-settings
|
||||
email-validator # Dependencia explícita para Pydantic EmailStr
|
||||
|
||||
# --- Frontend & File Handling ---
|
||||
# Motor de plantillas para HTML y manejo de subida de archivos.
|
||||
jinja2
|
||||
python-multipart
|
||||
|
||||
# Dependencias directas
|
||||
google-cloud-documentai==3.5.0
|
||||
python-dotenv
|
||||
pyinstaller
|
||||
# --- Google Cloud Services ---
|
||||
# El cliente oficial para interactuar con Document AI.
|
||||
google-cloud-documentai
|
||||
|
||||
# --- Utilities ---
|
||||
# Herramienta robusta para el parsing de fechas.
|
||||
python-dateutil
|
||||
|
||||
# Dependencias transitivas (fijadas para consistencia, tomadas del toolkit)
|
||||
cachetools==5.5.2
|
||||
certifi==2025.8.3
|
||||
charset-normalizer==3.4.3
|
||||
google-api-core==2.25.1
|
||||
google-auth==2.40.3
|
||||
googleapis-common-protos==1.70.0
|
||||
grpcio==1.74.0
|
||||
grpcio-status==1.74.0
|
||||
idna==3.10
|
||||
proto-plus==1.26.1
|
||||
protobuf==6.32.0
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.2
|
||||
requests==2.32.4
|
||||
rsa==4.9.1
|
||||
urllib3==2.5.0
|
||||
|
||||
# Validación de email para Pydantic
|
||||
email-validator
|
||||
|
||||
46
requirements.txt.v1
Normal file
46
requirements.txt.v1
Normal file
@ -0,0 +1,46 @@
|
||||
# requirements.txt
|
||||
# Dependencias de la aplicación y del toolkit unificadas
|
||||
|
||||
# Framework Web y Servidor
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
|
||||
# Base de datos (ORM)
|
||||
sqlalchemy
|
||||
# Para usar SQLite (simple para empezar)
|
||||
pydantic-settings
|
||||
|
||||
# Autenticación y Seguridad
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
python-multipart # Para subida de archivos
|
||||
|
||||
# Plantillas HTML
|
||||
jinja2
|
||||
|
||||
# Dependencias directas
|
||||
google-cloud-documentai==3.5.0
|
||||
python-dotenv
|
||||
pyinstaller
|
||||
python-dateutil
|
||||
|
||||
# Dependencias transitivas (fijadas para consistencia, tomadas del toolkit)
|
||||
cachetools==5.5.2
|
||||
certifi==2025.8.3
|
||||
charset-normalizer==3.4.3
|
||||
google-api-core==2.25.1
|
||||
google-auth==2.40.3
|
||||
googleapis-common-protos==1.70.0
|
||||
grpcio==1.74.0
|
||||
grpcio-status==1.74.0
|
||||
idna==3.10
|
||||
proto-plus==1.26.1
|
||||
protobuf==6.32.0
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.2
|
||||
requests==2.32.4
|
||||
rsa==4.9.1
|
||||
urllib3==2.5.0
|
||||
|
||||
# Validación de email para Pydantic
|
||||
email-validator
|
||||
57
requirements.txt.v2
Normal file
57
requirements.txt.v2
Normal file
@ -0,0 +1,57 @@
|
||||
altgraph==0.17.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.10.0
|
||||
bcrypt==4.3.0
|
||||
cachetools==5.5.2
|
||||
certifi==2025.8.3
|
||||
cffi==1.17.1
|
||||
charset-normalizer==3.4.3
|
||||
click==8.2.1
|
||||
cryptography==45.0.6
|
||||
dnspython==2.7.0
|
||||
ecdsa==0.19.1
|
||||
email_validator==2.2.0
|
||||
fastapi==0.116.1
|
||||
google-api-core==2.25.1
|
||||
google-auth==2.40.3
|
||||
google-cloud-documentai==3.5.0
|
||||
googleapis-common-protos==1.70.0
|
||||
greenlet==3.2.4
|
||||
grpcio==1.74.0
|
||||
grpcio-status==1.74.0
|
||||
h11==0.16.0
|
||||
httptools==0.6.4
|
||||
idna==3.10
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.2
|
||||
packaging==25.0
|
||||
passlib==1.7.4
|
||||
proto-plus==1.26.1
|
||||
protobuf==6.32.0
|
||||
pyasn1==0.6.1
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==2.22
|
||||
pydantic==2.11.7
|
||||
pydantic-settings==2.10.1
|
||||
pydantic_core==2.33.2
|
||||
pyinstaller==6.15.0
|
||||
pyinstaller-hooks-contrib==2025.8
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.1.1
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.20
|
||||
PyYAML==6.0.2
|
||||
requests==2.32.4
|
||||
rsa==4.9.1
|
||||
setuptools==80.9.0
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.43
|
||||
starlette==0.47.3
|
||||
typing-inspection==0.4.1
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.35.0
|
||||
uvloop==0.21.0
|
||||
watchfiles==1.1.0
|
||||
websockets==15.0.1
|
||||
@ -1,40 +1,59 @@
|
||||
# services/invoice_processor_service.py
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
# Importamos nuestro nuevo cliente GCP de forma local y limpia
|
||||
from .gcp_document_ai_client import process_document_gcp
|
||||
|
||||
# (Opcional, si tienes utilidades) from .utils import data_cleaner
|
||||
|
||||
# Importamos la configuración centralizada
|
||||
# Importamos nuestro limpiador de datos para usarlo
|
||||
from .utils import data_cleaner
|
||||
from core.config import settings
|
||||
|
||||
# --- Lógica de negocio extraída del antiguo processing.py ---
|
||||
def _get_confidence_threshold_for_field(field_type: str) -> float:
|
||||
return settings.CONFIDENCE_THRESHOLDS.get(field_type, settings.CONFIDENCE_THRESHOLDS["__default__"])
|
||||
# --- Lógica de negocio refactorizada ---
|
||||
|
||||
def _extract_specific_fields(entities: List[Any]) -> Dict[str, str]:
|
||||
def _extract_specific_fields(
|
||||
entities: List[Any],
|
||||
override_threshold: Optional[float] = None
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Filtra y normaliza entidades. Si se proporciona `override_threshold`,
|
||||
se utiliza ese valor para todos los campos. De lo contrario, utiliza
|
||||
los umbrales definidos en la configuración.
|
||||
"""
|
||||
extracted_data = {field: "Not found or low confidence" for field in settings.REQUIRED_FIELDS}
|
||||
|
||||
for entity in entities:
|
||||
entity_type = entity.type_
|
||||
threshold = _get_confidence_threshold_for_field(entity_type)
|
||||
|
||||
# Lógica de decisión del umbral
|
||||
if override_threshold is not None:
|
||||
threshold = override_threshold
|
||||
else:
|
||||
# Comportamiento original: usar la configuración por campo
|
||||
threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, settings.CONFIDENCE_THRESHOLDS["__default__"])
|
||||
|
||||
if entity_type in settings.REQUIRED_FIELDS and entity.confidence >= threshold:
|
||||
value = entity.mention_text.replace('\n', ' ').strip()
|
||||
# if entity_type == 'invoice_date':
|
||||
# value = data_cleaner.normalize_date(value) or f"Unparseable Date: '{value}'"
|
||||
|
||||
# Reactivamos la limpieza de fechas
|
||||
if entity_type == 'invoice_date':
|
||||
normalized_date = data_cleaner.normalize_date(value)
|
||||
value = normalized_date if normalized_date else f"Unparseable Date: '{value}'"
|
||||
|
||||
extracted_data[entity_type] = value
|
||||
|
||||
return extracted_data
|
||||
|
||||
# --- Función principal del servicio ---
|
||||
def process_invoice_from_bytes(file_bytes: bytes, mime_type: str) -> Dict[str, str]:
|
||||
# --- Función principal del servicio actualizada ---
|
||||
|
||||
def process_invoice_from_bytes(
|
||||
file_bytes: bytes,
|
||||
mime_type: str,
|
||||
override_threshold: Optional[float] = None # Nuevo parámetro opcional
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Orquesta el proceso completo: llama a Document AI, extrae y limpia los datos.
|
||||
Orquesta el proceso completo. Ahora pasa el umbral de confianza
|
||||
opcional a la capa de lógica de negocio.
|
||||
"""
|
||||
try:
|
||||
# 1. Llamar a la API de Google a través de nuestro cliente dedicado
|
||||
document = process_document_gcp(
|
||||
project_id=settings.GCP_PROJECT_ID,
|
||||
location=settings.GCP_LOCATION,
|
||||
@ -43,13 +62,12 @@ def process_invoice_from_bytes(file_bytes: bytes, mime_type: str) -> Dict[str, s
|
||||
mime_type=mime_type,
|
||||
)
|
||||
|
||||
# 2. Aplicar la lógica de negocio para extraer y validar los campos
|
||||
validated_data = _extract_specific_fields(document.entities)
|
||||
# Pasamos el umbral opcional a la función de extracción
|
||||
validated_data = _extract_specific_fields(document.entities, override_threshold)
|
||||
|
||||
logging.info("Documento procesado con éxito y datos validados.")
|
||||
logging.info(f"Documento procesado con éxito con un umbral de {override_threshold or 'default'}.")
|
||||
return validated_data
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error en el flujo de procesamiento de factura: {e}", exc_info=True)
|
||||
# Re-lanzamos la excepción para que el endpoint de la API la capture y devuelva un 500
|
||||
raise
|
||||
raise
|
||||
@ -5,34 +5,38 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard - ACME Invoice Processor</title>
|
||||
|
||||
<!-- ======================= INICIO DEL GUARDIA DE SEGURIDAD ======================= -->
|
||||
<script>
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) {
|
||||
// Si NO hay token, no cargues esta página. ¡Redirige al login!
|
||||
window.location.href = '/';
|
||||
}
|
||||
if (!token) { window.location.href = '/'; }
|
||||
</script>
|
||||
<!-- ======================== FIN DEL GUARDIA DE SEGURIDAD ========================= -->
|
||||
|
||||
<style>
|
||||
/* ... (tus estilos CSS no cambian) ... */
|
||||
body { font-family: sans-serif; margin: 0; background-color: #f4f4f9; }
|
||||
.navbar { background-color: #333; overflow: hidden; }
|
||||
.navbar a { float: left; display: block; color: white; text-align: center; padding: 14px 16px; text-decoration: none; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; background-color: #f4f4f9; color: #333; }
|
||||
.navbar { background-color: #2c3e50; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||
.navbar a { float: left; display: block; color: white; text-align: center; padding: 14px 20px; text-decoration: none; font-weight: bold; }
|
||||
.navbar .logout { float: right; cursor: pointer; }
|
||||
.container { max-width: 800px; margin: 2rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; }
|
||||
.upload-form { margin-top: 2rem; border: 2px dashed #ccc; padding: 2rem; border-radius: 8px; text-align: center; }
|
||||
.upload-form input[type="file"] { border: none; }
|
||||
.results { margin-top: 2rem; background-color: #e9ecef; padding: 1rem; border-radius: 8px; display: none; }
|
||||
.results pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
.container { max-width: 800px; margin: 2rem auto; padding: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.05); }
|
||||
h1, h2 { color: #2c3e50; }
|
||||
.upload-area { margin-top: 1rem; border: 2px dashed #bdc3c7; padding: 2rem; border-radius: 8px; text-align: center; background-color: #ecf0f1; }
|
||||
.upload-form button { background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 5px; cursor: pointer; font-size: 16px; font-weight: bold; margin-top: 1rem; }
|
||||
.upload-form button:disabled { background-color: #95a5a6; cursor: not-allowed; }
|
||||
/* --- Nuevo: Estilos para la configuración avanzada --- */
|
||||
.settings { margin-top: 2rem; padding: 1.5rem; background-color: #f8f9fa; border-radius: 8px; }
|
||||
.slider-container { display: flex; align-items: center; gap: 15px; }
|
||||
.slider-container label { font-weight: bold; }
|
||||
.slider-container input[type="range"] { flex-grow: 1; }
|
||||
.slider-container #confidence-value { font-weight: bold; color: #3498db; min-width: 45px; }
|
||||
/* --- Fin de nuevos estilos --- */
|
||||
.results { margin-top: 2rem; background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 1.5rem; border-radius: 8px; display: none; }
|
||||
.results-table { width: 100%; border-collapse: collapse; }
|
||||
.results-table td { padding: 12px; border-bottom: 1px solid #dee2e6; }
|
||||
.results-table td:first-child { font-weight: bold; color: #495057; width: 30%; }
|
||||
.message { text-align: center; padding: 1rem; margin-top: 1rem; border-radius: 4px; display: none; }
|
||||
.error { background-color: #f8d7da; color: #721c24; }
|
||||
.info { background-color: #d1ecf1; color: #0c5460; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- El resto del HTML no cambia -->
|
||||
<div class="navbar">
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a id="logout-button" class="logout">Cerrar Sesión</a>
|
||||
@ -40,25 +44,120 @@
|
||||
|
||||
<div class="container">
|
||||
<h1>Sube una Factura para Procesar</h1>
|
||||
<form id="upload-form" class="upload-form">
|
||||
<input type="file" id="invoice-file" name="invoice" required>
|
||||
<button type="submit">Procesar Factura</button>
|
||||
|
||||
<form id="upload-form">
|
||||
<div class="settings">
|
||||
<h2>Configuración Avanzada</h2>
|
||||
<div class="slider-container">
|
||||
<label for="confidence-slider">Confianza Mínima:</label>
|
||||
<input type="range" id="confidence-slider" min="0" max="100" value="85">
|
||||
<span id="confidence-value">85%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-area">
|
||||
<input type="file" id="invoice-file" name="invoice" required accept="application/pdf,image/jpeg,image/png,image/tiff">
|
||||
<button type="submit" id="submit-button">Procesar Factura</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="message-container" class="message"></div>
|
||||
<div id="results-container" class="results">
|
||||
<h2>Resultados de la Extracción:</h2>
|
||||
<pre id="results-data"></pre>
|
||||
<h2>Resultados de la Extracción</h2>
|
||||
<table id="results-table" class="results-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// --- El resto del script no cambia ---
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
const submitButton = document.getElementById('submit-button');
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
const resultsContainer = document.getElementById('results-container');
|
||||
const resultsTable = document.getElementById('results-table');
|
||||
const confidenceSlider = document.getElementById('confidence-slider');
|
||||
const confidenceValue = document.getElementById('confidence-value');
|
||||
|
||||
// --- Lógica para actualizar el valor del slider en la UI ---
|
||||
confidenceSlider.addEventListener('input', () => {
|
||||
confidenceValue.textContent = `${confidenceSlider.value}%`;
|
||||
});
|
||||
|
||||
document.getElementById('logout-button').addEventListener('click', () => {
|
||||
localStorage.removeItem('accessToken');
|
||||
window.location.href = '/';
|
||||
});
|
||||
const uploadForm = document.getElementById('upload-form');
|
||||
// ... (el resto del script de subida) ...
|
||||
|
||||
uploadForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
submitButton.disabled = true;
|
||||
showMessage('Procesando, por favor espera...');
|
||||
resultsContainer.style.display = 'none';
|
||||
|
||||
const fileInput = document.getElementById('invoice-file');
|
||||
const file = fileInput.files[0];
|
||||
const token = localStorage.getItem('accessToken');
|
||||
|
||||
// --- Nuevo: Leer el valor del slider ---
|
||||
const thresholdValue = confidenceSlider.value;
|
||||
const thresholdFloat = parseFloat(thresholdValue) / 100.0;
|
||||
// ------------------------------------
|
||||
|
||||
if (!file) { /* ... (código sin cambios) ... */ }
|
||||
if (!token) { /* ... (código sin cambios) ... */ }
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
// --- Nuevo: Añadir el umbral al FormData ---
|
||||
formData.append('confidence_threshold', thresholdFloat);
|
||||
// ----------------------------------------
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/invoices/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
displayResults(data);
|
||||
messageContainer.style.display = 'none';
|
||||
} else {
|
||||
showMessage(data.detail || 'Ocurrió un error.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('Error de conexión.', 'error');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
fileInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- Funciones de Ayuda para la UI ---
|
||||
function showMessage(text, type = 'info') {
|
||||
messageContainer.textContent = text;
|
||||
messageContainer.className = `message ${type}`;
|
||||
messageContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
// Limpiar tabla anterior
|
||||
resultsTable.innerHTML = '';
|
||||
|
||||
// Crear una fila por cada dato extraído
|
||||
for (const key in data) {
|
||||
const row = resultsTable.insertRow();
|
||||
const keyCell = row.insertCell(0);
|
||||
const valueCell = row.insertCell(1);
|
||||
|
||||
// Formatear la clave para que sea legible (ej. 'supplier_name' -> 'Supplier Name')
|
||||
keyCell.textContent = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
valueCell.textContent = data[key];
|
||||
}
|
||||
|
||||
resultsContainer.style.display = 'block';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user