Nick's real location list hit the production import and got 'Internal Server Error' with no line number - BL-027's class again, three days after the migration outage: Postgres enforces VARCHAR lengths and refuses control bytes, SQLite shrugs at both, and the importers were only ever rehearsed on SQLite. Reproduced both hazards locally (an over-long value and a NUL byte import cleanly on SQLite; either 500s Postgres wholesale). Both importers now validate per row, before any INSERT, so every dialect answers the same way - with the line number and a reason: - locations: control characters; names over 200; codes over 60; combined paths over 200 (checked where the path exists, with read-counts taken before the loop so a mid-loop rejection is not counted twice) - materials: control characters; description/unit/code over 300/20/80 And the client stops lying about it: wp-list-import.js read every response with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not reach the server' while the server was answering fine. One tolerant reader (text -> parse if it parses -> keep status) now serves import, add and patch; a real error reads 'Import refused - HTTP 500'. Pins: materials_check +2 (over-long and control-byte rows reject at line, 20/20), locations_check +1 (over-long name rejects at line, 59/59). Items: CR-005, D6, BL-027 (second instance of its class; the probe-side dialect guard it proposes is still open). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
10 KiB
JavaScript
223 lines
10 KiB
JavaScript
/* The project-list import component — T5.4's machinery, extracted (D6 / T8.6).
|
|
|
|
One implementation of paste-or-file → server-side import with dry-run →
|
|
a report that names every rejected row with its SOURCE line number → an
|
|
editable list whose entries deactivate rather than delete. The location list
|
|
(CR-005) and the material list (D6) are both instances of this; building the
|
|
material path "the same way and against the same component, not beside it"
|
|
is the T8.6 instruction, and extracting the component is what makes that
|
|
literally true rather than a copy with the names changed.
|
|
|
|
The page supplies what differs: the API base, the sample, how a row renders,
|
|
and what the add-row collects. Everything generic — the file reader feeding
|
|
the paste box (one parser, on the server), the dry-run wiring, the report
|
|
roles (a report that lost rows interrupts; a clean one does not, per T4.5) —
|
|
lives here once.
|
|
|
|
Classic script, no modules: exposes window.WPListImport. */
|
|
'use strict';
|
|
|
|
(function () {
|
|
function el(id) { return document.getElementById(id); }
|
|
|
|
window.WPListImport = function (cfg) {
|
|
// cfg.prefix DOM id prefix: '<p>-paste', '<p>-file', '<p>-file-btn',
|
|
// '<p>-check-btn', '<p>-import-btn', '<p>-sample-btn',
|
|
// '<p>-report', '<p>-add-name', '<p>-add-btn', '<p>-add-err',
|
|
// '<p>-tool', '<p>-noproject'
|
|
// cfg.api(suffix) URL builder for the project-scoped routes
|
|
// cfg.projectId() current project id ('' = not opened from a project)
|
|
// cfg.sample text the sample button loads
|
|
// cfg.loadKey response key holding the rows ('nodes' | 'items')
|
|
// cfg.render() paints the current list from state.rows
|
|
// cfg.rejectedRow(r) <li> HTML for one rejected row
|
|
// cfg.duplicateRow(r) <li> HTML for one duplicate row
|
|
// cfg.addPayload() reads the add-row; {payload} to POST or {error}
|
|
// cfg.addedMessage(body) confirmation HTML after a successful add
|
|
// cfg.noProjectMessage what to say when there is no project
|
|
// cfg.esc the page's escaper
|
|
var p = cfg.prefix;
|
|
var esc = cfg.esc;
|
|
var state = { rows: [], loaded: false };
|
|
|
|
// Every message goes through here so the role is decided in one place:
|
|
// a report that lost rows interrupts (T4.5), a clean one does not.
|
|
function say(html, isProblem) {
|
|
var box = el(p + '-report');
|
|
if (!box) return;
|
|
box.setAttribute('role', isProblem ? 'alert' : 'status');
|
|
box.innerHTML = html || '';
|
|
box.classList.toggle('is-problem', !!isProblem);
|
|
}
|
|
|
|
function setAddError(msg) {
|
|
var box = el(p + '-add-err');
|
|
if (box) box.textContent = msg || '';
|
|
var input = el(p + '-add-name');
|
|
if (input) {
|
|
if (msg) input.setAttribute('aria-invalid', 'true');
|
|
else input.removeAttribute('aria-invalid');
|
|
}
|
|
}
|
|
|
|
function load(force) {
|
|
var tool = el(p + '-tool');
|
|
var warn = el(p + '-noproject');
|
|
var pid = cfg.projectId();
|
|
if (!pid) {
|
|
if (tool) tool.style.display = 'none';
|
|
if (warn) { warn.style.display = ''; warn.textContent = cfg.noProjectMessage; }
|
|
return Promise.resolve();
|
|
}
|
|
if (tool) tool.style.display = '';
|
|
if (warn) warn.style.display = 'none';
|
|
if (state.loaded && !force) return Promise.resolve();
|
|
return fetch(cfg.api('?include_inactive=true'), { headers: { 'Accept': 'application/json' } })
|
|
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(function (data) { state.rows = data[cfg.loadKey] || []; state.loaded = true; cfg.render(); })
|
|
.catch(function (err) {
|
|
state.loaded = false;
|
|
cfg.render();
|
|
say('⚠ Could not load the list — ' + esc((err && err.message) || 'offline')
|
|
+ '. It is stored on the server, so nothing local is shown in its place.', true);
|
|
});
|
|
}
|
|
|
|
function report(res) {
|
|
var bits = [];
|
|
var problem = (res.rejected || []).length > 0 || (res.duplicates || []).length > 0;
|
|
var verb = res.dry_run ? 'would be added' : 'added';
|
|
var nCreated = (res.created || []).length;
|
|
bits.push('<p class="loc-report-line"><strong>' + res.read + ' row' + (res.read === 1 ? '' : 's')
|
|
+ ' read.</strong> ' + nCreated + ' value' + (nCreated === 1 ? '' : 's') + ' ' + verb
|
|
+ ((res.reactivated || []).length ? ', ' + res.reactivated.length + ' brought back into use' : '')
|
|
+ '.</p>');
|
|
var rowList = function (title, rows, fmt) {
|
|
if (!rows || !rows.length) return '';
|
|
return '<div class="loc-report-group"><div class="loc-report-title">' + esc(title)
|
|
+ ' (' + rows.length + ')</div><ul class="loc-report-list">' + rows.map(fmt).join('') + '</ul></div>';
|
|
};
|
|
bits.push(rowList('Rejected', res.rejected, cfg.rejectedRow));
|
|
bits.push(rowList('Duplicates, not merged', res.duplicates, cfg.duplicateRow));
|
|
if (!problem && !nCreated && !(res.reactivated || []).length) {
|
|
bits.push('<p class="loc-report-line">Nothing to do — every row is already on this project.</p>');
|
|
}
|
|
say(bits.join(''), problem);
|
|
}
|
|
|
|
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
|
|
// throws - which used to land in catch() and read as "could not reach the
|
|
// server" while the server was answering fine (found 2026-08-23, the
|
|
// production locations import). Read text, parse if it parses, keep status.
|
|
function readJson(r) {
|
|
return r.text().then(function (t) {
|
|
var j = null;
|
|
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
|
|
return { ok: r.ok, status: r.status, body: j };
|
|
});
|
|
}
|
|
|
|
function importText(dryRun) {
|
|
var text = (el(p + '-paste') || {}).value || '';
|
|
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
|
|
if (!cfg.projectId()) { load(); return; }
|
|
say('Checking…', false);
|
|
fetch(cfg.api('/import'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ text: text, dry_run: !!dryRun }),
|
|
})
|
|
.then(readJson)
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
|
return;
|
|
}
|
|
report(res.body);
|
|
if (!dryRun) return load(true);
|
|
})
|
|
.catch(function (err) {
|
|
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline')
|
|
+ '. Nothing was imported.', true);
|
|
});
|
|
}
|
|
|
|
function add() {
|
|
var read = cfg.addPayload();
|
|
if (read.error) {
|
|
setAddError(read.error);
|
|
var input = el(p + '-add-name');
|
|
if (input) input.focus();
|
|
return;
|
|
}
|
|
setAddError('');
|
|
fetch(cfg.api(''), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify(read.payload),
|
|
})
|
|
.then(readJson)
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
|
|
return;
|
|
}
|
|
var input = el(p + '-add-name');
|
|
if (input) input.value = '';
|
|
say(cfg.addedMessage(res.body), false);
|
|
return load(true);
|
|
})
|
|
.catch(function (err) { setAddError('Could not reach the server — ' + ((err && err.message) || 'offline')); });
|
|
}
|
|
|
|
function patch(id, patchBody, describe) {
|
|
return fetch(cfg.api('/' + encodeURIComponent(id)), {
|
|
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify(patchBody),
|
|
})
|
|
.then(readJson)
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
|
|
return load(true);
|
|
}
|
|
say(describe(res.body), false);
|
|
return load(true);
|
|
})
|
|
.catch(function (err) {
|
|
say('⚠ Could not reach the server — ' + esc((err && err.message) || 'offline'), true);
|
|
});
|
|
}
|
|
|
|
function wire() {
|
|
var paste = el(p + '-paste');
|
|
var file = el(p + '-file');
|
|
var btn = function (suffix) { return el(p + suffix); };
|
|
if (btn('-file-btn')) btn('-file-btn').addEventListener('click', function () { if (file) file.click(); });
|
|
if (file) file.addEventListener('change', function (ev) {
|
|
var f = ev.target.files && ev.target.files[0];
|
|
if (!f) return;
|
|
var reader = new FileReader();
|
|
reader.onload = function () {
|
|
// One parser, on the server. Reading the file here and posting its text
|
|
// is what stops "what does a blank column mean" having two answers.
|
|
if (paste) paste.value = String(reader.result || '');
|
|
say('Read <strong>' + esc(f.name) + '</strong>. Check it, then import.', false);
|
|
};
|
|
reader.onerror = function () { say('⚠ Could not read that file.', true); };
|
|
reader.readAsText(f);
|
|
ev.target.value = '';
|
|
});
|
|
if (btn('-check-btn')) btn('-check-btn').addEventListener('click', function () { importText(true); });
|
|
if (btn('-import-btn')) btn('-import-btn').addEventListener('click', function () { importText(false); });
|
|
if (btn('-sample-btn')) btn('-sample-btn').addEventListener('click', function () {
|
|
if (paste) paste.value = cfg.sample;
|
|
say('Sample values loaded into the box — obviously fake, and safe to import '
|
|
+ 'on a throwaway project.', false);
|
|
});
|
|
if (btn('-add-btn')) btn('-add-btn').addEventListener('click', add);
|
|
}
|
|
|
|
return { state: state, say: say, setAddError: setAddError, load: load,
|
|
importText: importText, add: add, patch: patch, wire: wire };
|
|
};
|
|
})();
|