CSV Validator (Browser + Server)
Here is a lightweight CSV validator you can drop into any website. It lets someone upload a CSV and instantly tells them what’s wrong: missing headers, mismatched column counts, blank required fields, and simple format checks (email/URL/number).
Why it’s useful: CSV imports fail for the same boring reasons every time. This makes the errors obvious before someone hits “Import” and blows up your pipeline.
What it does:
- Validates required headers (order optional).
- Flags row column count mismatches (broken commas/quotes).
- Checks required fields are not empty.
- Optional format rules: email, URL, integer, number.
- Shows a readable report: line number + what failed.
Install:
- Create:
/tools/csv-validator/ - Save the file below as:
index.php - Edit the config section (required headers + rules).
- Visit:
/tools/csv-validator/
<?php
declare(strict_types=1);
/**
* Tiny CSV Validator — One File (No DB)
* File: /tools/csv-validator/index.php
*
* - Client-side validation for instant feedback
* - Optional server-side validation endpoint (POST file) for parity
*/
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: strict-origin-when-cross-origin');
$CFG = [
'tool_name' => 'Tiny CSV Validator',
'desc' => 'Validate headers, column counts, required fields, and basic formats before importing.',
'canonical' => 'https://your-site.com/tools/csv-validator/', // change
// REQUIRED HEADERS (case-insensitive)
// Example CSV must include these columns (in any order).
'required_headers' => ['name','email','url'],
// REQUIRED FIELDS (subset of headers)
'required_fields' => ['name','email'],
// FORMAT RULES per column
// Supported: email, url, int, number
'rules' => [
'email' => 'email',
'url' => 'url',
],
// Safety
'max_bytes' => 2_000_000, // 2MB
'max_rows' => 10_000,
];
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
function is_email(string $s): bool {
return filter_var($s, FILTER_VALIDATE_EMAIL) !== false;
}
function is_url(string $s): bool {
if ($s === '') return false;
// allow schemeless? keep strict-ish
return filter_var($s, FILTER_VALIDATE_URL) !== false;
}
function is_int_str(string $s): bool {
return (bool)preg_match('/^-?\d+$/', $s);
}
function is_number_str(string $s): bool {
return (bool)preg_match('/^-?(?:\d+|\d*\.\d+)$/', $s);
}
function normalize_header(string $s): string {
$s = trim($s);
$s = strtolower($s);
// normalize spaces/underscores
$s = preg_replace('/\s+/', ' ', $s) ?? $s;
return $s;
}
function server_validate_csv(string $csv, array $CFG): array {
$errors = [];
$warnings = [];
// Basic BOM strip
$csv = preg_replace('/^\xEF\xBB\xBF/', '', $csv) ?? $csv;
// Parse with fgetcsv semantics using memory stream
$fp = fopen('php://memory', 'r+');
fwrite($fp, $csv);
rewind($fp);
$headers = fgetcsv($fp);
if (!$headers || !is_array($headers)) {
return ['ok'=>false,'errors'=>[['line'=>1,'msg'=>'Missing header row.']],'warnings'=>[]];
}
$norm = [];
foreach ($headers as $i => $col) {
$norm[$i] = normalize_header((string)$col);
}
// map header => index
$map = [];
foreach ($norm as $i => $col) {
if ($col !== '' && !isset($map[$col])) $map[$col] = $i;
}
// required headers
foreach ($CFG['required_headers'] as $req) {
$reqN = normalize_header((string)$req);
if (!isset($map[$reqN])) $errors[] = ['line'=>1,'msg'=>"Missing required header: {$req}"];
}
if ($errors) return ['ok'=>false,'errors'=>$errors,'warnings'=>$warnings];
$colCount = count($headers);
$line = 1;
$rows = 0;
while (($row = fgetcsv($fp)) !== false) {
$line++;
if ($row === [null] || $row === null) continue;
$rows++;
if ($rows > (int)$CFG['max_rows']) {
$warnings[] = ['line'=>$line,'msg'=>'Stopped early: max rows reached.'];
break;
}
if (count($row) !== $colCount) {
$errors[] = ['line'=>$line,'msg'=>"Column count mismatch (expected {$colCount}, got ".count($row).")."];
continue;
}
// required fields
foreach ($CFG['required_fields'] as $rf) {
$rfN = normalize_header((string)$rf);
$idx = $map[$rfN] ?? null;
if ($idx === null) continue;
$val = trim((string)($row[$idx] ?? ''));
if ($val === '') $errors[] = ['line'=>$line,'msg'=>"Required field empty: {$rf}"];
}
// rules
foreach ($CFG['rules'] as $col => $rule) {
$cN = normalize_header((string)$col);
$idx = $map[$cN] ?? null;
if ($idx === null) continue;
$val = trim((string)($row[$idx] ?? ''));
if ($val === '') continue; // allow blank unless required
$ok = true;
if ($rule === 'email') $ok = is_email($val);
if ($rule === 'url') $ok = is_url($val);
if ($rule === 'int') $ok = is_int_str($val);
if ($rule === 'number') $ok = is_number_str($val);
if (!$ok) $errors[] = ['line'=>$line,'msg'=>"Invalid {$rule} in column '{$col}': {$val}"];
}
}
fclose($fp);
return ['ok'=>count($errors)===0,'errors'=>$errors,'warnings'=>$warnings];
}
// -------- optional server endpoint --------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv'])) {
if ((int)($_FILES['csv']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'Upload failed.']]], JSON_UNESCAPED_SLASHES);
exit;
}
$tmp = (string)($_FILES['csv']['tmp_name'] ?? '');
$sz = (int)($_FILES['csv']['size'] ?? 0);
if ($tmp === '' || $sz <= 0) {
http_response_code(400);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'Empty upload.']]], JSON_UNESCAPED_SLASHES);
exit;
}
if ($sz > (int)$CFG['max_bytes']) {
http_response_code(413);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok'=>false,'errors'=>[['line'=>0,'msg'=>'File too large.']]], JSON_UNESCAPED_SLASHES);
exit;
}
$csv = (string)@file_get_contents($tmp);
$r = server_validate_csv($csv, $CFG);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($r, JSON_UNESCAPED_SLASHES);
exit;
}
// -------- page --------
$title = $CFG['tool_name'];
$desc = $CFG['desc'];
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title><?php echo h($title); ?></title>
<meta name="description" content="<?php echo h($desc); ?>" />
<link rel="canonical" href="<?php echo h((string)$CFG['canonical']); ?>" />
<meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1" />
<style>
:root{
--bg:#0b0f16; --panel:#111a26; --panel2:#0c1420;
--text:#e8eef8; --muted:rgba(232,238,248,.72);
--line:rgba(255,255,255,.10); --accent:#2a8cff;
--shadow:0 18px 55px rgba(0,0,0,.45); --r:16px;
--ok:#7CFF9A; --warn:#FFD37C; --bad:#FF7C7C;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}
.wrap{max-width:980px;margin:0 auto;padding:18px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px;margin:12px 0;box-shadow:var(--shadow)}
.muted{color:var(--muted)}
.top{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center}
.pill{display:inline-block;padding:4px 10px;border-radius:999px;background:rgba(42,140,255,.14);border:1px solid rgba(42,140,255,.25);font-weight:900;font-size:12px}
.btn{display:inline-flex;align-items:center;gap:8px;border:0;border-radius:12px;padding:10px 12px;background:var(--accent);color:#06101a;font-weight:900;cursor:pointer}
input[type=file]{width:100%;padding:10px;border-radius:12px;border:1px solid var(--line);background:var(--panel2);color:var(--text)}
pre{margin:0;padding:12px;border-radius:14px;border:1px solid var(--line);background:var(--panel2);overflow:auto;white-space:pre-wrap}
code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
.row{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:12px}
.c7{grid-column:span 7}
.c5{grid-column:span 5}
@media(max-width:920px){.c7,.c5{grid-column:span 12}}
.ok{color:var(--ok)} .warn{color:var(--warn)} .bad{color:var(--bad)}
table{width:100%;border-collapse:collapse}
th,td{padding:10px;border-bottom:1px solid rgba(255,255,255,.08);vertical-align:top}
th{font-size:12px;opacity:.75;text-align:left}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<div class="top">
<div>
<div class="pill">CSV Tool</div>
<h1 style="margin:10px 0 6px;font-size:22px"><?php echo h($title); ?></h1>
<div class="muted"><?php echo h($desc); ?></div>
</div>
</div>
</div>
<div class="row">
<div class="card c7">
<b>Upload a CSV</b>
<div class="muted" style="margin-top:6px">
Required headers: <code><?php echo h(implode(', ', $CFG['required_headers'])); ?></code>
</div>
<div style="margin-top:10px;display:grid;gap:10px">
<input id="csvFile" type="file" accept=".csv,text/csv" />
<button class="btn" type="button" id="validateBtn">Validate</button>
<div class="muted" style="font-size:13px;line-height:1.45">
Validates in the browser first (fast). Optionally also checks via PHP (same rules).
</div>
</div>
</div>
<div class="card c5">
<b>Sample CSV</b>
<div class="muted" style="margin-top:6px">Copy this into a file called <code>test.csv</code> to see it pass.</div>
<pre><code>name,email,url
Jane Doe,jane@example.com,https://example.com
John Smith,john@example.com,https://example.org</code></pre>
</div>
</div>
<div class="card" id="reportCard" style="display:none">
<div class="top">
<b>Report</b>
<div id="summary" class="muted"></div>
</div>
<div style="margin-top:10px;overflow:auto">
<table>
<thead><tr><th>Type</th><th>Line</th><th>Message</th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
</div>
</div>
<script>
(function(){
var REQUIRED_HEADERS = <?php echo json_encode(array_values($CFG['required_headers']), JSON_UNESCAPED_SLASHES); ?>;
var REQUIRED_FIELDS = <?php echo json_encode(array_values($CFG['required_fields']), JSON_UNESCAPED_SLASHES); ?>;
var RULES = <?php echo json_encode($CFG['rules'], JSON_UNESCAPED_SLASHES); ?>;
function normHeader(s){
return String(s||"").trim().toLowerCase().replace(/\s+/g," ");
}
function isEmail(s){
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(s||"").trim());
}
function isURL(s){
try { new URL(String(s||"").trim()); return true; } catch(e){ return false; }
}
function isInt(s){
return /^-?\d+$/.test(String(s||"").trim());
}
function isNumber(s){
return /^-?(?:\d+|\d*\.\d+)$/.test(String(s||"").trim());
}
// Minimal CSV parser that respects quotes.
// Good enough for validation + line counts.
function parseCSV(text){
text = String(text||"");
// strip UTF-8 BOM
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
text = text.replace(/^\uFEFF/, "");
var rows = [];
var row = [];
var cur = "";
var inQ = false;
for (var i=0;i<text.length;i++){
var ch = text[i];
if (inQ){
if (ch === '"'){
if (text[i+1] === '"'){ cur += '"'; i++; }
else inQ = false;
} else {
cur += ch;
}
} else {
if (ch === '"'){ inQ = true; }
else if (ch === ','){ row.push(cur); cur=""; }
else if (ch === '\n'){
row.push(cur); cur="";
// trim CR
if (row.length===1 && row[0]==="" && rows.length===0) { row=[]; continue; }
rows.push(row); row=[];
} else if (ch === '\r'){
// ignore
} else {
cur += ch;
}
}
}
row.push(cur);
if (row.length>1 || row[0] !== "") rows.push(row);
return rows;
}
function validateRows(rows){
var errors = [];
var warnings = [];
if (!rows || !rows.length) {
errors.push({line:1,msg:"Empty file."});
return {ok:false, errors:errors, warnings:warnings};
}
var header = rows[0];
if (!header || !header.length) {
errors.push({line:1,msg:"Missing header row."});
return {ok:false, errors:errors, warnings:warnings};
}
var norm = header.map(normHeader);
var map = {};
for (var i=0;i<norm.length;i++){
if (norm[i] && map[norm[i]] == null) map[norm[i]] = i;
}
REQUIRED_HEADERS.forEach(function(h){
var k = normHeader(h);
if (map[k] == null) errors.push({line:1,msg:"Missing required header: " + h});
});
if (errors.length) return {ok:false, errors:errors, warnings:warnings};
var colCount = header.length;
for (var r=1;r<rows.length;r++){
var line = r+1;
var row = rows[r];
if (row.length !== colCount) {
errors.push({line:line,msg:"Column count mismatch (expected " + colCount + ", got " + row.length + ")."});
continue;
}
REQUIRED_FIELDS.forEach(function(f){
var idx = map[normHeader(f)];
if (idx == null) return;
var v = String(row[idx]||"").trim();
if (!v) errors.push({line:line,msg:"Required field empty: " + f});
});
Object.keys(RULES).forEach(function(col){
var idx = map[normHeader(col)];
if (idx == null) return;
var v = String(row[idx]||"").trim();
if (!v) return;
var rule = RULES[col];
var ok = true;
if (rule === "email") ok = isEmail(v);
if (rule === "url") ok = isURL(v);
if (rule === "int") ok = isInt(v);
if (rule === "number") ok = isNumber(v);
if (!ok) errors.push({line:line,msg:"Invalid " + rule + " in column '" + col + "': " + v});
});
}
return {ok: errors.length===0, errors: errors, warnings: warnings};
}
function renderReport(r){
var card = document.getElementById("reportCard");
var rowsEl = document.getElementById("rows");
var sum = document.getElementById("summary");
card.style.display = "block";
rowsEl.innerHTML = "";
var ok = r.ok;
var e = r.errors || [];
var w = r.warnings || [];
sum.innerHTML = ok
? '<span class="ok"><b>Looks good.</b></span> No errors.'
: '<span class="bad"><b>Problems found.</b></span> Errors: ' + e.length;
function add(type, line, msg){
var tr = document.createElement("tr");
tr.innerHTML =
'<td><b class="'+(type==="ERROR"?"bad":"warn")+'">'+type+'</b></td>' +
'<td>'+line+'</td>' +
'<td>'+msg.replace(/[<>&]/g, function(c){ return ({'<':'<','>':'>','&':'&'}[c]); })+'</td>';
rowsEl.appendChild(tr);
}
e.forEach(function(x){ add("ERROR", x.line, x.msg); });
w.forEach(function(x){ add("WARN", x.line, x.msg); });
if (ok && !w.length) {
add("WARN", "—", "Tip: hook this into your import flow and block uploads until it passes.");
}
}
var fileEl = document.getElementById("csvFile");
var btn = document.getElementById("validateBtn");
btn.addEventListener("click", function(){
var f = fileEl.files && fileEl.files[0];
if (!f) return alert("Choose a CSV file first.");
var reader = new FileReader();
reader.onload = function(){
var text = String(reader.result || "");
var rows = parseCSV(text);
var r = validateRows(rows);
renderReport(r);
// Optional: also validate via server for parity
// (Uncomment if you want both checks)
/*
var fd = new FormData();
fd.append("csv", f);
fetch(location.href, {method:"POST", body:fd})
.then(function(res){ return res.json(); })
.then(function(sr){
// merge results (show server errors too)
if (sr && sr.errors && sr.errors.length) {
renderReport({ok:false, errors: sr.errors, warnings: sr.warnings||[]});
}
})
.catch(function(){});
*/
};
reader.readAsText(f);
});
})();
</script>
</body>
</html>
Comments (0)
No comments yet — be the first.