Takes in a file, process it and output a new file with additional columns.
This commit is contained in:
Stefan Lazic
2026-07-10 15:45:02 +01:00
parent 2f387a4c1c
commit a451060c1b
876 changed files with 169373 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
(() => {
const form = document.getElementById('importForm');
const clearButton = document.getElementById('clearButton');
const progressBar = document.getElementById('importProgress');
const progressLabel = document.getElementById('progressLabel');
const progressPercent = document.getElementById('progressPercent');
const totalRowsLabel = document.getElementById('importTotalRows');
const chunkSizeLabel = document.getElementById('importChunkSize');
const statusAlert = document.getElementById('statusAlert');
const fileInput = document.getElementById('spreadsheet');
const tokenStorageKey = 'warnerImportToken';
let importToken = window.sessionStorage.getItem(tokenStorageKey) || '';
let chunkTimer = null;
let chunkInFlight = false;
if (!form || !progressBar || !progressLabel || !progressPercent || !statusAlert) {
return;
}
const setStatus = (type, message) => {
const classes = {
success: 'border-emerald-200 bg-emerald-50 text-emerald-900',
danger: 'border-rose-200 bg-rose-50 text-rose-900',
warning: 'border-amber-200 bg-amber-50 text-amber-900',
info: 'border-sky-200 bg-sky-50 text-sky-900',
};
statusAlert.className = `mt-4 rounded-2xl border px-4 py-3 text-sm font-medium ${classes[type] || classes.info}`;
statusAlert.textContent = message;
statusAlert.classList.remove('hidden');
};
const setProgress = (value, label) => {
const percent = Math.max(0, Math.min(100, Math.round(value)));
progressBar.style.width = `${percent}%`;
progressBar.setAttribute('aria-valuenow', String(percent));
progressPercent.textContent = `${percent}%`;
progressLabel.textContent = label;
};
const setRowMetrics = (totalRows, chunkSize) => {
if (totalRowsLabel) {
totalRowsLabel.textContent = Number.isFinite(totalRows) ? String(totalRows.toLocaleString()) : '0';
}
if (chunkSizeLabel && Number.isFinite(chunkSize)) {
chunkSizeLabel.textContent = String(chunkSize.toLocaleString());
}
};
const clearImportState = () => {
importToken = '';
window.sessionStorage.removeItem(tokenStorageKey);
if (chunkTimer !== null) {
window.clearTimeout(chunkTimer);
chunkTimer = null;
}
chunkInFlight = false;
};
const scheduleChunk = (offset, delay = 200) => {
if (!importToken) {
return;
}
if (chunkTimer !== null) {
window.clearTimeout(chunkTimer);
}
chunkTimer = window.setTimeout(() => {
processChunk(offset);
}, delay);
};
const parseJsonResponse = (xhr) => {
try {
return JSON.parse(xhr.responseText);
} catch (error) {
return null;
}
};
const confirmDestructiveAction = (message) => window.confirm(message);
const deriveProgressPercent = (response) => {
if (response && response.progressPercent !== undefined) {
return response.progressPercent;
}
const total = Number(response && response.total ? response.total : 0);
const offset = Number(response && response.offset ? response.offset : 0);
if (total <= 0) {
return response && response.done ? 100 : 0;
}
return Math.min(99, Math.round((offset / total) * 100));
};
const processChunk = (offset = 0) => {
if (!importToken || chunkInFlight) {
return;
}
chunkInFlight = true;
const xhr = new XMLHttpRequest();
xhr.open('POST', window.APP_CONFIG.processChunkUrl, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
chunkInFlight = false;
if (xhr.status >= 200 && xhr.status < 300) {
const response = parseJsonResponse(xhr);
if (!response) {
setStatus('danger', 'Import response was invalid.');
clearImportState();
return;
}
const progress = deriveProgressPercent(response);
const message = response.message || 'Processing workbook...';
setProgress(progress, message);
if (response.totalRows !== undefined || response.chunkSize !== undefined) {
setRowMetrics(
Number(response.totalRows ?? 0),
Number(response.chunkSize ?? (window.APP_CONFIG.importChunkSize ?? 0))
);
}
if (response.done) {
setProgress(100, response.message || 'Import complete');
setStatus('success', response.message || 'Import complete. Reloading preview...');
clearImportState();
window.setTimeout(() => {
window.location.href = window.APP_CONFIG.reloadUrl;
}, 800);
return;
}
setStatus('info', message);
scheduleChunk(response.offset !== undefined ? response.offset : (offset + 1), 200);
return;
}
const response = parseJsonResponse(xhr);
const responseMessage = response && response.message ? response.message : 'Import failed.';
setProgress(0, 'Ready to import');
setStatus('danger', responseMessage);
clearImportState();
};
xhr.send(
`action=process-chunk&token=${encodeURIComponent(importToken)}&offset=${encodeURIComponent(String(offset))}`
);
};
form.addEventListener('submit', (event) => {
event.preventDefault();
statusAlert.classList.add('hidden');
if (!fileInput.files || fileInput.files.length === 0) {
setStatus('danger', 'Please choose an Excel file before importing.');
return;
}
if (!confirmDestructiveAction('Importing a new file will wipe the current imported data before processing. Continue?')) {
setStatus('info', 'Import cancelled.');
setProgress(0, 'Ready to import');
return;
}
clearImportState();
const formData = new FormData(form);
const xhr = new XMLHttpRequest();
xhr.open('POST', window.APP_CONFIG.importUrl, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable) {
setProgress(65, 'Uploading file...');
return;
}
const percent = Math.max(1, (event.loaded / event.total) * 70);
setProgress(percent, 'Uploading file...');
};
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
const response = parseJsonResponse(xhr);
if (response && response.token) {
importToken = response.token;
window.sessionStorage.setItem(tokenStorageKey, importToken);
setProgress(5, response.message || 'Upload received. Starting chunked import...');
setRowMetrics(0, Number(window.APP_CONFIG.importChunkSize || 0));
setStatus('info', response.message || 'Upload received. Starting chunked import...');
scheduleChunk(0, 100);
return;
}
setStatus('danger', 'Upload succeeded, but the server did not return an import token.');
return;
}
const response = parseJsonResponse(xhr);
const responseMessage = response && response.message ? response.message : 'Import failed.';
setProgress(0, 'Ready to import');
setStatus('danger', responseMessage);
};
setProgress(5, 'Preparing upload...');
xhr.send(formData);
setProgress(20, 'Uploading file...');
});
if (clearButton) {
clearButton.addEventListener('click', () => {
if (!confirmDestructiveAction('This will permanently wipe all imported rows. Continue?')) {
return;
}
const xhr = new XMLHttpRequest();
xhr.open('POST', window.APP_CONFIG.clearUrl, true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = () => {
if (xhr.readyState !== XMLHttpRequest.DONE) {
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
setProgress(0, 'Ready to import');
setStatus('success', 'Imported data cleared. Reloading page...');
clearImportState();
window.setTimeout(() => {
window.location.href = window.APP_CONFIG.reloadUrl;
}, 800);
return;
}
const response = parseJsonResponse(xhr);
const responseMessage = response && response.message ? response.message : 'Clear failed.';
setStatus('danger', responseMessage);
};
xhr.send('action=clear');
});
}
if (importToken) {
setProgress(10, 'Resuming import...');
setStatus('info', 'Resuming the last import job...');
setRowMetrics(0, Number(window.APP_CONFIG.importChunkSize || 0));
scheduleChunk(0, 200);
}
})();
+81
View File
@@ -0,0 +1,81 @@
:root {
--app-bg: #f3f6fb;
--app-ink: #16202a;
--app-muted: #5d6b7a;
--app-accent: #0f6efc;
--app-accent-soft: rgba(15, 110, 252, 0.12);
}
html {
scroll-behavior: smooth;
}
body {
color: var(--app-ink);
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(15, 110, 252, 0.12), transparent 28%),
radial-gradient(circle at bottom right, rgba(13, 202, 240, 0.16), transparent 30%),
var(--app-bg);
}
.app-shell {
min-height: 100vh;
}
.app-navbar {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(10px);
}
.datatable-toolbar {
padding: 0.1rem 0;
}
.column-visibility-menu {
min-width: 18rem;
max-height: 24rem;
overflow-y: auto;
}
.datatable-page .ag-theme-quartz {
--ag-font-family: inherit;
--ag-header-background-color: rgba(255, 255, 255, 0.98);
--ag-header-foreground-color: var(--app-ink);
--ag-background-color: rgba(255, 255, 255, 0.95);
--ag-foreground-color: var(--app-ink);
--ag-border-color: rgba(22, 32, 42, 0.12);
--ag-row-hover-color: rgba(15, 110, 252, 0.06);
--ag-selected-row-background-color: rgba(15, 110, 252, 0.12);
--ag-header-column-separator-display: none;
--ag-row-border-color: rgba(22, 32, 42, 0.08);
--ag-cell-horizontal-padding: 14px;
width: 100%;
height: 72vh;
min-height: 620px;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 1rem 2.5rem rgba(22, 32, 42, 0.08);
}
.datatable-page .ag-theme-quartz .ag-header {
border-bottom: 1px solid rgba(22, 32, 42, 0.12);
}
.datatable-page .ag-theme-quartz .ag-header-cell-label {
font-weight: 700;
}
.datatable-page .ag-theme-quartz .ag-floating-filter {
border-top: 1px solid rgba(22, 32, 42, 0.08);
background: rgba(255, 255, 255, 0.98);
}
.datatable-page .ag-theme-quartz .ag-cell {
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
.datatable-page .ag-theme-quartz .ag-row {
border-bottom: 1px solid rgba(22, 32, 42, 0.06);
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
try {
$controller = \App\Http\AppController::create();
$controller->handle();
} catch (Throwable $throwable) {
http_response_code(500);
$message = htmlspecialchars($throwable->getMessage(), ENT_QUOTES, 'UTF-8');
$appName = htmlspecialchars((string) app_config('app.name', 'Spreadsheet Importer'), ENT_QUOTES, 'UTF-8');
$assetUrl = htmlspecialchars(asset_url('styles.css'), ENT_QUOTES, 'UTF-8');
echo <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$appName}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind = {
config: {
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
},
},
},
},
};
</script>
<script src="https://cdn.tailwindcss.com"></script>
<link href="{$assetUrl}" rel="stylesheet">
</head>
<body class="min-h-screen bg-slate-100 text-slate-900 antialiased">
<main class="flex min-h-screen items-center justify-center px-4 py-12">
<section class="w-full max-w-2xl rounded-[2rem] border border-white/70 bg-white/90 p-6 shadow-[0_24px_80px_rgba(15,23,42,0.1)] backdrop-blur sm:p-8">
<div class="inline-flex items-center rounded-full bg-rose-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700">Setup issue</div>
<h1 class="mt-5 text-3xl font-semibold tracking-tight">{$appName} cannot start</h1>
<p class="mt-3 text-sm leading-6 text-slate-500">The app hit a startup error while connecting to the database or loading a required dependency.</p>
<div class="mt-6 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-900">{$message}</div>
<div class="mt-5 text-sm leading-6 text-slate-500">
Check the PostgreSQL credentials in <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">config.php</code>, run <code class="rounded bg-slate-100 px-1.5 py-0.5 text-slate-700">composer run migrate</code>, and make sure the database server is running.
</div>
</section>
</main>
</body>
</html>
HTML;
}