Files
Project-SDE-WP-Suite/html/wp-list-import.js
n.siegfried 222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
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>
2026-09-01 15:26:37 -07:00

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 };
};
})();