Paste a list of destinations
diff --git a/app/main.mjs b/app/main.mjs
index c72d6fb..2eecff3 100644
--- a/app/main.mjs
+++ b/app/main.mjs
@@ -3,7 +3,7 @@ import { FONTS } from '../src/fonts.js';
import { fontFromJSON, renderText, trimX } from '../src/codec/bitfont.mjs';
import { parseTd5, buildTd5, frameToPixels, pixelsToFrame, EFFECT } from '../src/codec/td5.mjs';
import { parseTp5, buildTp5, toSimple } from '../src/codec/tp5.mjs';
-import { readXlsx, readCsv, mapRows } from '../src/codec/sheet.mjs';
+import { readXlsx, readCsv, analyseSheet, applyMapping } from '../src/codec/sheet.mjs';
// Sign sizes from the Yutong programming manual (section 2.2).
const MODELS = [
@@ -14,6 +14,7 @@ const MODELS = [
{ label: 'Other — 160 × 16', width: 160, height: 16 },
{ label: 'Other — 192 × 16', width: 192, height: 16 },
{ label: 'Other — 256 × 16', width: 256, height: 16 },
+ { label: 'Custom size…', custom: true },
];
const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma'];
@@ -477,17 +478,18 @@ function renderHeader() {
$('#company').value = state.company;
const sel = $('#model');
if (!sel.options.length) {
- for (const m of MODELS) sel.append(el('option', { value: m.width, textContent: m.label }));
+ for (const m of MODELS) {
+ sel.append(el('option', { value: m.custom ? 'custom' : String(m.width), textContent: m.label }));
+ }
}
- const known = MODELS.some((m) => m.width === state.screen.width);
- if (!known) {
- const label = `Custom — ${state.screen.width} × ${state.screen.height}`;
- let opt = [...sel.options].find((o) => o.dataset.custom);
- if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); }
- opt.value = String(state.screen.width);
- opt.textContent = label;
+ const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height);
+ sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width);
+ const custom = sel.value === 'custom';
+ $('#custom-size').hidden = !custom;
+ if (custom) {
+ $('#cust-w').value = state.screen.width;
+ $('#cust-h').value = state.screen.height;
}
- sel.value = String(state.screen.width);
const has = state.destinations.length > 0;
$('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has;
@@ -823,16 +825,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files
-/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */
+/**
+ * Show what the sheet is about to become before committing to it. Guessing the
+ * columns silently is how you end up with a list of destinations called
+ * "1", "2", "3" — so the guess is shown, and can be corrected.
+ */
+let pendingSheet = null;
+
function importRows(rows, label) {
- const { company, destinations } = mapRows(rows);
- if (!destinations.length) {
- return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true);
+ const info = analyseSheet(rows);
+ if (!info.rowCount) {
+ return toast(`No rows found in that ${label}.`, true);
}
- if (company && !state.company) state.company = company;
+ pendingSheet = { rows, info, label };
+
+ const nameSel = $('#map-name'), textSel = $('#map-text');
+ nameSel.textContent = ''; textSel.textContent = '';
+ for (const c of info.columns) {
+ if (!c.filled) continue;
+ const describe = `${c.label}${c.sample.length ? ` — ${c.sample.slice(0, 2).join(', ')}` : ''}`;
+ nameSel.append(el('option', { value: c.index, textContent: describe }));
+ textSel.append(el('option', { value: c.index, textContent: describe }));
+ }
+ nameSel.value = String(info.nameCol);
+ textSel.value = String(info.textCol);
+ nameSel.onchange = textSel.onchange = drawMappingPreview;
+
+ drawMappingPreview();
+ showDialog($('#mapdlg'));
+}
+
+function drawMappingPreview() {
+ if (!pendingSheet) return;
+ const { rows, info } = pendingSheet;
+ const nameCol = Number($('#map-name').value);
+ const textCol = Number($('#map-text').value);
+ const found = applyMapping(rows, { start: info.start, nameCol, textCol });
+
+ const host = $('#map-preview');
+ host.textContent = '';
+ const table = el('table');
+ const head = el('tr');
+ head.append(el('th', { textContent: 'On the controller' }), el('th', { textContent: 'On the sign' }));
+ table.append(head);
+ for (const d of found.slice(0, 12)) {
+ const tr = el('tr');
+ tr.append(el('td', { className: 'nm', textContent: d.name }), el('td', { className: 'led', textContent: d.text }));
+ table.append(tr);
+ }
+ host.append(table);
+
+ $('#map-count').textContent = found.length
+ ? `${found.length} destination${found.length === 1 ? '' : 's'}` + (found.length > 12 ? ' — showing the first 12' : '')
+ : 'Nothing usable in those columns — try different ones.';
+ $('#map-ok').disabled = found.length === 0;
+ pendingSheet.mapped = found;
+}
+
+/** Commit the previewed mapping into the destination list. */
+function commitSheet() {
+ const { info, mapped, label } = pendingSheet ?? {};
+ if (!mapped?.length) return;
+ if (info.company && !state.company) state.company = info.company;
let added = 0, updated = 0;
- for (const r of destinations) {
+ for (const r of mapped) {
const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) {
setName(existing, r.name);
@@ -849,6 +906,7 @@ function importRows(rows, label) {
}
}
state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected;
+ pendingSheet = null;
renderAll();
toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`);
}
@@ -917,18 +975,40 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli
$('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
$('#btn-add').onclick = addOne;
$('#btn-clear').onclick = removeAll;
+$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); };
+$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); };
$('#btn-bulk').onclick = openBulk;
$('#btn-export').onclick = exportTd5;
$('#btn-export-tp5').onclick = exportTp5;
$('#company').oninput = (e) => { state.company = e.target.value; };
-$('#model').onchange = (e) => {
- const m = MODELS.find((x) => String(x.width) === e.target.value);
- if (m) state.screen = { ...m };
+/** Re-render anything we drew ourselves; imported artwork is left alone. */
+function screenChanged() {
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll();
+}
+
+$('#model').onchange = (e) => {
+ if (e.target.value === 'custom') {
+ state.customSize = true;
+ } else {
+ const m = MODELS.find((x) => !x.custom && String(x.width) === e.target.value);
+ if (m) { state.screen = { width: m.width, height: m.height }; state.customSize = false; }
+ }
+ screenChanged();
};
+const applyCustomSize = () => {
+ const w = Math.max(8, Math.min(1024, Number($('#cust-w').value) || state.screen.width));
+ const h = Math.max(8, Math.min(64, Number($('#cust-h').value) || state.screen.height));
+ // The record stores the width in whole bytes, so it has to be a multiple of 8.
+ state.screen = { width: Math.round(w / 8) * 8, height: h };
+ state.customSize = true;
+ screenChanged();
+};
+$('#cust-w').onchange = applyCustomSize;
+$('#cust-h').onchange = applyCustomSize;
+
$('#bulk-cancel').onclick = () => closeDialog($('#bulk'));
$('#bulk-ok').onclick = () => {
const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean);
diff --git a/dist/DestoGod.html b/dist/DestoGod.html
index 04c4262..c0dbbc4 100644
--- a/dist/DestoGod.html
+++ b/dist/DestoGod.html
@@ -134,6 +134,16 @@ dialog .dlg-body{padding:20px}
dialog h3{margin:0 0 6px; font-size:16px}
dialog p{margin:0 0 14px; color:var(--dim); font-size:13px}
dialog .dlg-foot{display:flex; justify-content:flex-end; gap:9px; padding:14px 20px; border-top:1px solid var(--line)}
+#map-preview{margin-top:16px; border:1px solid var(--line); border-radius:8px; overflow:auto; max-height:230px}
+#map-preview table{border-collapse:collapse; width:100%; font-size:12px}
+#map-preview th{
+ position:sticky; top:0; background:var(--panel2); text-align:left; padding:7px 10px;
+ font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--dim); white-space:nowrap;
+}
+#map-preview td{padding:6px 10px; border-top:1px solid var(--line); white-space:nowrap}
+#map-preview .led{color:var(--amber); font-weight:600}
+#map-preview .nm{font-weight:650}
+#map-count{margin-top:9px; font-size:12px; color:var(--dim)}
textarea{min-height:170px; resize:vertical; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px}
#drop{
position:fixed; inset:0; background:#0b0e14e6; display:none; place-items:center;
@@ -160,6 +170,12 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
DestoGod bus destination signs
Company
Bus / sign
+
+
+ ×
+
+ pixels
+
Open file…
Import spreadsheet…
@@ -186,6 +202,29 @@ kbd{background:#12141a; border:1px solid var(--line); border-bottom-width:2px; b
Drop a .td5, .tp5, spreadsheet or CSV to open
+
+
+
Import from spreadsheet
+
Check the two columns below are the right ones, then import. Everything can still be edited afterwards.
+
+
+ Name on the driver's controller
+
+
+
+ Text shown on the sign
+
+
+
+
+
+
+
+
+
Paste a list of destinations
@@ -1059,57 +1098,115 @@ function readCsv(text) {
// -------------------------------------------------------------- mapping
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, '');
+const colLabel = (i) => {
+ let n = i, out = '';
+ do { out = String.fromCharCode(65 + (n % 26)) + out; n = Math.floor(n / 26) - 1; } while (n >= 0);
+ return out;
+};
/**
- * Work out which columns hold the controller name and the sign text.
- * The template that ships with these buses uses
- * `Line | Line Name | Description | Content (Display)`.
+ * Describe a sheet without committing to anything: where the header is, what
+ * columns exist with a sample of each, and a best guess at which column holds
+ * the controller name and which holds the sign text.
+ *
+ * The guess is only a starting point — the app shows it and lets you change it,
+ * because sheets in the wild do not all look like the shipped template
+ * (`Line | Line Name | Description | Content (Display)`), and silently guessing
+ * wrong is how you end up with destinations called "1", "2", "3".
*/
-function mapRows(rows) {
+function analyseSheet(rows) {
let company = '';
for (const r of rows.slice(0, 5)) {
const i = r.findIndex((c) => norm(c).startsWith('companyname'));
if (i >= 0) { company = (r[i + 1] || '').trim(); break; }
}
- const headerAt = rows.findIndex((r) => r.some((c) => {
+ let headerAt = rows.findIndex((r) => r.some((c) => {
const n = norm(c);
return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination';
}));
+ // Headers we do not recognise by name are still headers. If the top row is all
+ // words and something below it is a number, it is labelling columns, not data.
+ if (headerAt < 0) {
+ const first = rows.findIndex((r) => r.length && !r.every((c) => !c));
+ if (first >= 0) {
+ const top = rows[first].filter((c) => c !== '');
+ const below = rows.slice(first + 1).filter((r) => r.length && !r.every((c) => !c));
+ const numericBelow = below.some((r) => r.some((c) => /^\d+(\.\d+)?$/.test(String(c).trim())));
+ if (top.length > 1 && top.every((c) => !/^\d+(\.\d+)?$/.test(String(c).trim())) && numericBelow) {
+ headerAt = first;
+ }
+ }
+ }
+ const start = headerAt >= 0 ? headerAt + 1 : 0;
+ const body = rows.slice(start).filter((r) => r.length && !r.every((c) => !c));
- let nameCol = -1, textCol = -1, start = 0;
+ const width = Math.max(0, ...rows.map((r) => r.length));
+ const columns = [];
+ for (let i = 0; i < width; i++) {
+ const values = body.map((r) => (r[i] ?? '').trim()).filter(Boolean);
+ columns.push({
+ index: i,
+ label: headerAt >= 0 && rows[headerAt][i] ? String(rows[headerAt][i]).trim() : `Column ${colLabel(i)}`,
+ sample: values.slice(0, 3),
+ filled: values.length,
+ allNumeric: values.length > 0 && values.every((v) => /^\d+$/.test(v)),
+ });
+ }
+
+ let nameCol = -1, textCol = -1;
if (headerAt >= 0) {
const head = rows[headerAt].map(norm);
- // Search by priority, not by column order: the shipped template has both a
- // "Line" (a row number) and a "Line Name", and the name must win.
+ // By priority, not by column order: the shipped template has both a "Line"
+ // (a row number) and a "Line Name", and the name must win.
const pick = (...wanted) => {
for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; }
return -1;
};
nameCol = pick('linename', 'name', 'destinationname', 'route', 'line');
textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message');
- start = headerAt + 1;
}
+ if (nameCol < 0 && textCol < 0) {
+ // Nothing recognisable: the column carrying the longest words is the most
+ // likely destination text.
+ const wordy = columns.filter((c) => c.filled && !c.allNumeric);
+ const avg = (c) => c.sample.reduce((n, v) => n + v.length, 0) / (c.sample.length || 1);
+ const best = wordy.slice().sort((a, b) => avg(b) - avg(a))[0];
+ textCol = best ? best.index : (columns.find((c) => c.filled)?.index ?? 0);
+ }
+ // One recognised column stands in for the other: a destination's name is
+ // usually exactly what the sign shows, so defaulting them to the same column
+ // is far safer than picking an unrelated one.
+ if (textCol < 0) textCol = nameCol;
+ if (nameCol < 0) nameCol = textCol;
+ // A bare row number is a line code, not something to show a driver.
+ if (columns[nameCol]?.allNumeric && nameCol !== textCol) nameCol = textCol;
+ return { company, headerAt, start, columns, nameCol, textCol, rowCount: body.length };
+}
+
+/** Turn a sheet into destinations using an explicit column mapping. */
+function applyMapping(rows, { start = 0, nameCol, textCol }) {
const out = [];
for (const r of rows.slice(start)) {
if (!r.length || r.every((c) => !c)) continue;
- const cells = r.filter((c) => c !== '');
- let name = nameCol >= 0 ? (r[nameCol] || '') : '';
- let text = textCol >= 0 ? (r[textCol] || '') : '';
- if (!name && !text) { // no recognisable header — take what is there
- text = cells[cells.length - 1] || '';
- name = cells.length > 1 ? cells[0] : text;
- }
+ let name = String(r[nameCol] ?? '').trim();
+ let text = String(r[textCol] ?? '').trim();
if (!text) text = name;
- // A bare row number is a line code, not something worth showing a driver.
- if (/^\d+$/.test(name) && text && text !== name) name = text;
if (!name) name = text;
- if (!text.trim()) continue;
+ if (!text) continue;
if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) continue;
- out.push({ name: name.trim().slice(0, 16), text: text.trim() });
+ // Truncating can leave a trailing space, which would also stop this row
+ // matching an existing destination on a later import.
+ out.push({ name: name.slice(0, 16).trim(), text });
}
- return { company, destinations: out };
+ return out;
+}
+
+/** analyse + apply the guesses, for callers that do not want to ask. */
+function mapRows(rows) {
+ const a = analyseSheet(rows);
+ return { company: a.company, destinations: applyMapping(rows, a) };
}
@@ -1129,6 +1226,7 @@ const MODELS = [
{ label: 'Other — 160 × 16', width: 160, height: 16 },
{ label: 'Other — 192 × 16', width: 192, height: 16 },
{ label: 'Other — 256 × 16', width: 256, height: 16 },
+ { label: 'Custom size…', custom: true },
];
const SYSTEM_FONTS = ['Impact', 'Arial Narrow', 'Arial Black', 'Arial', 'Helvetica', 'Verdana', 'Tahoma'];
@@ -1592,17 +1690,18 @@ function renderHeader() {
$('#company').value = state.company;
const sel = $('#model');
if (!sel.options.length) {
- for (const m of MODELS) sel.append(el('option', { value: m.width, textContent: m.label }));
+ for (const m of MODELS) {
+ sel.append(el('option', { value: m.custom ? 'custom' : String(m.width), textContent: m.label }));
+ }
}
- const known = MODELS.some((m) => m.width === state.screen.width);
- if (!known) {
- const label = `Custom — ${state.screen.width} × ${state.screen.height}`;
- let opt = [...sel.options].find((o) => o.dataset.custom);
- if (!opt) { opt = el('option', { value: state.screen.width }); opt.dataset.custom = '1'; sel.append(opt); }
- opt.value = String(state.screen.width);
- opt.textContent = label;
+ const preset = MODELS.find((m) => !m.custom && m.width === state.screen.width && m.height === state.screen.height);
+ sel.value = state.customSize || !preset ? 'custom' : String(state.screen.width);
+ const custom = sel.value === 'custom';
+ $('#custom-size').hidden = !custom;
+ if (custom) {
+ $('#cust-w').value = state.screen.width;
+ $('#cust-h').value = state.screen.height;
}
- sel.value = String(state.screen.width);
const has = state.destinations.length > 0;
$('#btn-export').disabled = !has;
$('#btn-export-tp5').disabled = !has;
@@ -1938,16 +2037,71 @@ function openBulk() { $('#bulk-text').value = ''; showDialog($('#bulk')); }
// ------------------------------------------------------------------- files
-/** Bring in destinations from a spreadsheet, appending to whatever is loaded. */
+/**
+ * Show what the sheet is about to become before committing to it. Guessing the
+ * columns silently is how you end up with a list of destinations called
+ * "1", "2", "3" — so the guess is shown, and can be corrected.
+ */
+let pendingSheet = null;
+
function importRows(rows, label) {
- const { company, destinations } = mapRows(rows);
- if (!destinations.length) {
- return toast(`No destinations found in that ${label}. Expected a column of destination text.`, true);
+ const info = analyseSheet(rows);
+ if (!info.rowCount) {
+ return toast(`No rows found in that ${label}.`, true);
}
- if (company && !state.company) state.company = company;
+ pendingSheet = { rows, info, label };
+
+ const nameSel = $('#map-name'), textSel = $('#map-text');
+ nameSel.textContent = ''; textSel.textContent = '';
+ for (const c of info.columns) {
+ if (!c.filled) continue;
+ const describe = `${c.label}${c.sample.length ? ` — ${c.sample.slice(0, 2).join(', ')}` : ''}`;
+ nameSel.append(el('option', { value: c.index, textContent: describe }));
+ textSel.append(el('option', { value: c.index, textContent: describe }));
+ }
+ nameSel.value = String(info.nameCol);
+ textSel.value = String(info.textCol);
+ nameSel.onchange = textSel.onchange = drawMappingPreview;
+
+ drawMappingPreview();
+ showDialog($('#mapdlg'));
+}
+
+function drawMappingPreview() {
+ if (!pendingSheet) return;
+ const { rows, info } = pendingSheet;
+ const nameCol = Number($('#map-name').value);
+ const textCol = Number($('#map-text').value);
+ const found = applyMapping(rows, { start: info.start, nameCol, textCol });
+
+ const host = $('#map-preview');
+ host.textContent = '';
+ const table = el('table');
+ const head = el('tr');
+ head.append(el('th', { textContent: 'On the controller' }), el('th', { textContent: 'On the sign' }));
+ table.append(head);
+ for (const d of found.slice(0, 12)) {
+ const tr = el('tr');
+ tr.append(el('td', { className: 'nm', textContent: d.name }), el('td', { className: 'led', textContent: d.text }));
+ table.append(tr);
+ }
+ host.append(table);
+
+ $('#map-count').textContent = found.length
+ ? `${found.length} destination${found.length === 1 ? '' : 's'}` + (found.length > 12 ? ' — showing the first 12' : '')
+ : 'Nothing usable in those columns — try different ones.';
+ $('#map-ok').disabled = found.length === 0;
+ pendingSheet.mapped = found;
+}
+
+/** Commit the previewed mapping into the destination list. */
+function commitSheet() {
+ const { info, mapped, label } = pendingSheet ?? {};
+ if (!mapped?.length) return;
+ if (info.company && !state.company) state.company = info.company;
let added = 0, updated = 0;
- for (const r of destinations) {
+ for (const r of mapped) {
const existing = state.destinations.find((x) => x.name.trim().toUpperCase() === r.name.toUpperCase());
if (existing) {
setName(existing, r.name);
@@ -1964,6 +2118,7 @@ function importRows(rows, label) {
}
}
state.selected = state.destinations[state.destinations.length - 1]?.id ?? state.selected;
+ pendingSheet = null;
renderAll();
toast(`${label}: added ${added}${updated ? `, updated ${updated}` : ''} destination${added === 1 && !updated ? '' : 's'}.`);
}
@@ -2032,18 +2187,40 @@ $('#btn-sheet').onclick = () => { $('#file').dataset.sheet = '1'; $('#file').cli
$('#file').onchange = (e) => { if (e.target.files[0]) openFile(e.target.files[0]); e.target.value = ''; };
$('#btn-add').onclick = addOne;
$('#btn-clear').onclick = removeAll;
+$('#map-cancel').onclick = () => { pendingSheet = null; closeDialog($('#mapdlg')); };
+$('#map-ok').onclick = () => { closeDialog($('#mapdlg')); commitSheet(); };
$('#btn-bulk').onclick = openBulk;
$('#btn-export').onclick = exportTd5;
$('#btn-export-tp5').onclick = exportTp5;
$('#company').oninput = (e) => { state.company = e.target.value; };
-$('#model').onchange = (e) => {
- const m = MODELS.find((x) => String(x.width) === e.target.value);
- if (m) state.screen = { ...m };
+/** Re-render anything we drew ourselves; imported artwork is left alone. */
+function screenChanged() {
for (const d of state.destinations) for (const p of d.pages) if (!p.pristine) p.bitmap = null;
renderAll();
+}
+
+$('#model').onchange = (e) => {
+ if (e.target.value === 'custom') {
+ state.customSize = true;
+ } else {
+ const m = MODELS.find((x) => !x.custom && String(x.width) === e.target.value);
+ if (m) { state.screen = { width: m.width, height: m.height }; state.customSize = false; }
+ }
+ screenChanged();
};
+const applyCustomSize = () => {
+ const w = Math.max(8, Math.min(1024, Number($('#cust-w').value) || state.screen.width));
+ const h = Math.max(8, Math.min(64, Number($('#cust-h').value) || state.screen.height));
+ // The record stores the width in whole bytes, so it has to be a multiple of 8.
+ state.screen = { width: Math.round(w / 8) * 8, height: h };
+ state.customSize = true;
+ screenChanged();
+};
+$('#cust-w').onchange = applyCustomSize;
+$('#cust-h').onchange = applyCustomSize;
+
$('#bulk-cancel').onclick = () => closeDialog($('#bulk'));
$('#bulk-ok').onclick = () => {
const lines = $('#bulk-text').value.split('\n').map((s) => s.trim()).filter(Boolean);
diff --git a/samples/odd-headers.xlsx b/samples/odd-headers.xlsx
new file mode 100644
index 0000000..a0c26da
Binary files /dev/null and b/samples/odd-headers.xlsx differ
diff --git a/src/codec/sheet.mjs b/src/codec/sheet.mjs
index b8677a1..e997267 100644
--- a/src/codec/sheet.mjs
+++ b/src/codec/sheet.mjs
@@ -142,55 +142,113 @@ export function readCsv(text) {
// -------------------------------------------------------------- mapping
const norm = (s) => String(s ?? '').toLowerCase().replace(/[^a-z]/g, '');
+const colLabel = (i) => {
+ let n = i, out = '';
+ do { out = String.fromCharCode(65 + (n % 26)) + out; n = Math.floor(n / 26) - 1; } while (n >= 0);
+ return out;
+};
/**
- * Work out which columns hold the controller name and the sign text.
- * The template that ships with these buses uses
- * `Line | Line Name | Description | Content (Display)`.
+ * Describe a sheet without committing to anything: where the header is, what
+ * columns exist with a sample of each, and a best guess at which column holds
+ * the controller name and which holds the sign text.
+ *
+ * The guess is only a starting point — the app shows it and lets you change it,
+ * because sheets in the wild do not all look like the shipped template
+ * (`Line | Line Name | Description | Content (Display)`), and silently guessing
+ * wrong is how you end up with destinations called "1", "2", "3".
*/
-export function mapRows(rows) {
+export function analyseSheet(rows) {
let company = '';
for (const r of rows.slice(0, 5)) {
const i = r.findIndex((c) => norm(c).startsWith('companyname'));
if (i >= 0) { company = (r[i + 1] || '').trim(); break; }
}
- const headerAt = rows.findIndex((r) => r.some((c) => {
+ let headerAt = rows.findIndex((r) => r.some((c) => {
const n = norm(c);
return n === 'linename' || n === 'content' || n.startsWith('contentdisplay') || n === 'destination';
}));
+ // Headers we do not recognise by name are still headers. If the top row is all
+ // words and something below it is a number, it is labelling columns, not data.
+ if (headerAt < 0) {
+ const first = rows.findIndex((r) => r.length && !r.every((c) => !c));
+ if (first >= 0) {
+ const top = rows[first].filter((c) => c !== '');
+ const below = rows.slice(first + 1).filter((r) => r.length && !r.every((c) => !c));
+ const numericBelow = below.some((r) => r.some((c) => /^\d+(\.\d+)?$/.test(String(c).trim())));
+ if (top.length > 1 && top.every((c) => !/^\d+(\.\d+)?$/.test(String(c).trim())) && numericBelow) {
+ headerAt = first;
+ }
+ }
+ }
+ const start = headerAt >= 0 ? headerAt + 1 : 0;
+ const body = rows.slice(start).filter((r) => r.length && !r.every((c) => !c));
- let nameCol = -1, textCol = -1, start = 0;
+ const width = Math.max(0, ...rows.map((r) => r.length));
+ const columns = [];
+ for (let i = 0; i < width; i++) {
+ const values = body.map((r) => (r[i] ?? '').trim()).filter(Boolean);
+ columns.push({
+ index: i,
+ label: headerAt >= 0 && rows[headerAt][i] ? String(rows[headerAt][i]).trim() : `Column ${colLabel(i)}`,
+ sample: values.slice(0, 3),
+ filled: values.length,
+ allNumeric: values.length > 0 && values.every((v) => /^\d+$/.test(v)),
+ });
+ }
+
+ let nameCol = -1, textCol = -1;
if (headerAt >= 0) {
const head = rows[headerAt].map(norm);
- // Search by priority, not by column order: the shipped template has both a
- // "Line" (a row number) and a "Line Name", and the name must win.
+ // By priority, not by column order: the shipped template has both a "Line"
+ // (a row number) and a "Line Name", and the name must win.
const pick = (...wanted) => {
for (const w of wanted) { const i = head.indexOf(w); if (i >= 0) return i; }
return -1;
};
nameCol = pick('linename', 'name', 'destinationname', 'route', 'line');
textCol = pick('contentdisplay', 'content', 'display', 'destination', 'text', 'message');
- start = headerAt + 1;
}
+ if (nameCol < 0 && textCol < 0) {
+ // Nothing recognisable: the column carrying the longest words is the most
+ // likely destination text.
+ const wordy = columns.filter((c) => c.filled && !c.allNumeric);
+ const avg = (c) => c.sample.reduce((n, v) => n + v.length, 0) / (c.sample.length || 1);
+ const best = wordy.slice().sort((a, b) => avg(b) - avg(a))[0];
+ textCol = best ? best.index : (columns.find((c) => c.filled)?.index ?? 0);
+ }
+ // One recognised column stands in for the other: a destination's name is
+ // usually exactly what the sign shows, so defaulting them to the same column
+ // is far safer than picking an unrelated one.
+ if (textCol < 0) textCol = nameCol;
+ if (nameCol < 0) nameCol = textCol;
+ // A bare row number is a line code, not something to show a driver.
+ if (columns[nameCol]?.allNumeric && nameCol !== textCol) nameCol = textCol;
+ return { company, headerAt, start, columns, nameCol, textCol, rowCount: body.length };
+}
+
+/** Turn a sheet into destinations using an explicit column mapping. */
+export function applyMapping(rows, { start = 0, nameCol, textCol }) {
const out = [];
for (const r of rows.slice(start)) {
if (!r.length || r.every((c) => !c)) continue;
- const cells = r.filter((c) => c !== '');
- let name = nameCol >= 0 ? (r[nameCol] || '') : '';
- let text = textCol >= 0 ? (r[textCol] || '') : '';
- if (!name && !text) { // no recognisable header — take what is there
- text = cells[cells.length - 1] || '';
- name = cells.length > 1 ? cells[0] : text;
- }
+ let name = String(r[nameCol] ?? '').trim();
+ let text = String(r[textCol] ?? '').trim();
if (!text) text = name;
- // A bare row number is a line code, not something worth showing a driver.
- if (/^\d+$/.test(name) && text && text !== name) name = text;
if (!name) name = text;
- if (!text.trim()) continue;
+ if (!text) continue;
if (norm(name) === 'linename' || norm(text).startsWith('contentdisplay')) continue;
- out.push({ name: name.trim().slice(0, 16), text: text.trim() });
+ // Truncating can leave a trailing space, which would also stop this row
+ // matching an existing destination on a later import.
+ out.push({ name: name.slice(0, 16).trim(), text });
}
- return { company, destinations: out };
+ return out;
+}
+
+/** analyse + apply the guesses, for callers that do not want to ask. */
+export function mapRows(rows) {
+ const a = analyseSheet(rows);
+ return { company: a.company, destinations: applyMapping(rows, a) };
}
diff --git a/test/sheet.mjs b/test/sheet.mjs
index 7c6dcb0..893ff95 100644
--- a/test/sheet.mjs
+++ b/test/sheet.mjs
@@ -47,5 +47,13 @@ check('quoted fields with commas',
mapRows(readCsv('Line Name,Content\n"CITY, VIA MALL","CITY, VIA MALL"\n')).destinations,
[{ name: 'CITY, VIA MALL', text: 'CITY, VIA MALL' }]);
+check('unrecognised headers are still headers, and Route beats the row number',
+ mapRows(readCsv('No.,Route,Notes\n1,SCHOOL BUS,am\n2,CARMEL COLLEGE,pm\n')).destinations,
+ [{ name: 'SCHOOL BUS', text: 'SCHOOL BUS' }, { name: 'CARMEL COLLEGE', text: 'CARMEL COLLEGE' }]);
+
+check('no recognisable columns at all — longest words win, name mirrors the sign',
+ mapRows(readCsv('A,B,C\n1,x,AIRPORT SHUTTLE VIA CITY\n2,y,DEPOT RUN\n')).destinations,
+ [{ name: 'AIRPORT SHUTTLE', text: 'AIRPORT SHUTTLE VIA CITY' }, { name: 'DEPOT RUN', text: 'DEPOT RUN' }]);
+
console.log(fails === 0 ? '\n✅ spreadsheet mapping correct' : `\n❌ ${fails} failed`);
process.exit(fails ? 1 : 0);