#!/usr/bin/env node // check-batch-selection-export — UPG-100's batch selection EXPORTS its selection (restored 2026-09-30). // // WHY IT MATTERS: when the people-group data changes, partners redraw the zones on the map, Copy or Export the // selection, and hand it to an agent that regroups the zones (data/apply-grouping.mjs) and regenerates the // posters. The copy/export path was removed once and the maps stopped being reproducible, so this pins it. // // Headless, no browser, no network. Asserts: // A. Copy's FIRST line is exactly what data/apply-grouping.mjs parses: fed through its own parsePayload, the // text returns the selected territory names, the UUPG total and the colour. The CSV after it must not // change that. // B. The CSV: the header is the declared columns; a cell with a comma, a quote or a line break survives a // real CSV parse; a leading = + - @ is neutralised (a spreadsheet never runs a name as a formula); one row // per people group, carrying the territories that contain it and the picked colour. // C. The HUD wires it: the colour swatches + hex input, Copy and Export CSV buttons with their handlers, and // batch mode prefetching the records so a tap copies inside its user activation. // D. The nine new strings exist in all 11 locales, non-empty, and translated (not English) outside en. import { readFileSync } from 'node:fs' import { resolve, dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..') const P = (...a) => join(REPO, ...a) const fails = [] const notes = [] const ok = (cond, msg, note) => { if (!cond) fails.push(msg); else if (note) notes.push(` ok ${note}`) } let X, parsePayload, runApply, formatClipboard try { X = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/selection-export.js')).href) ;({ parsePayload, runApply } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/apply-grouping.mjs')).href)) ;({ formatClipboard } = await import(pathToFileURL(P('plugin-designs/my-upg-100-list/data/territory-counts.js')).href)) } catch (e) { console.error(`BATCH-SELECTION-EXPORT: NOT MEASURED (exit 75) — an input did not load: ${e.message}`) process.exit(75) } // ── A. the clipboard's first line round-trips through apply-grouping ───────────────────────────── const selection = [ { key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }, { key: 'adm0:NPL', name: 'Nepal', count: 34, kind: 'adm0' }, ] const records = [ { slug: 'agaria', name: 'Agaria', country_code: 'IND', population: 12000 }, { slug: 'q-comma', name: 'Bhil, Tadvi "Hill"', country_code: 'IND', population: 3100 }, { slug: 'q-formula', name: '=HYPERLINK("x")', country_code: 'NPL', population: 450 }, { slug: 'q-newline', name: 'Line one\nLine two', country_code: 'NPL', population: 7 }, { slug: 'q-tab', name: '\t=cmd', country_code: 'NPL', population: 1 }, ] const from = new Map([['agaria', ['Mahārāshtra', 'Goa']], ['q-comma', ['Goa']], ['q-formula', ['Nepal']], ['q-newline', ['Nepal']], ['q-tab', ['Nepal']]]) const color = '#e69f00' const csv = X.selectionCsv(records, from, { color }) const text = X.selectionClipboard(selection, { color }, csv) let parsed = null try { parsed = parsePayload(text) } catch (e) { fails.push(`apply-grouping's parsePayload REFUSED the Copy text: ${e.message}`) } if (parsed) { const machine = ['Mahārāshtra', 'Goa', 'NPL'] ok(JSON.stringify(parsed.names) === JSON.stringify(machine), `apply-grouping reads the names as ${JSON.stringify(parsed.names)}, expected ${JSON.stringify(machine)}`, 'Copy\'s first line parses in apply-grouping: the 3 territories in machine names (a country as its ISO3), in order') ok(parsed.total === 93, `apply-grouping reads the total as ${parsed.total}, expected 93`, 'the UUPG total (93) and the colour survive the CSV after them') ok(parsed.hex === color, `apply-grouping reads the colour as ${parsed.hex}, expected ${color}`) } ok(text.split('\n')[0] === 'Mahārāshtra, Goa, NPL — 93 UUPG · #e69f00', `Copy's first line is "${text.split('\n')[0]}"`) ok(text.split('\n')[1] === 'Mahārāshtra 54, Goa 5, Nepal 34', `Copy's second line (the reader's names) is "${text.split('\n')[1]}"`) ok(text.includes('\n\n' + csv), 'Copy does not carry the CSV after a blank line') ok(X.selectionClipboard(selection, {}, '').split('\n').length === 2, 'Copy without people groups is not exactly the two territory lines') // ── A2. apply-grouping RESOLVES line 1 in ANY UI language ─────────────────────────────────────────────── // parsePayload only splits the names; resolution happens later (upsertPayloadZone), so this runs the tool's own // dry run. In Arabic the HUD shows "نيبال", and a line 1 carrying it was rejected ("unresolved territory name(s)"). // The NEGATIVE CONTROL proves this check can fail: the same selection formatted with the reader's names must be // refused by the same dry run. const arabic = [ { key: 'adm0:NPL', name: 'نيبال', count: 34, kind: 'adm0' }, { key: 'adm0:BTN', name: 'بوتان', count: 13, kind: 'adm0' }, ] const dry = (payloadText) => { try { const r = runApply({ payloadText, dry: true, quiet: true }); return r && r.ok !== false ? 'ok' : 'not ok' } catch (e) { return 'threw: ' + String(e.message || e).slice(0, 120) } } const arText = X.selectionClipboard(arabic, { color: '#0072b2' }) ok(dry(arText) === 'ok', `apply-grouping's dry run REFUSES the Arabic Copy: ${dry(arText)} (line 1 "${arText.split('\n')[0]}")`, 'an ARABIC selection\'s Copy resolves in apply-grouping\'s own dry run (line 1 "NPL, BTN — 47 UUPG · #0072b2")') ok(arText.split('\n')[1] === 'نيبال 34, بوتان 13', `the Arabic Copy's second line is "${arText.split('\n')[1]}", expected the reader's names`) ok(dry(formatClipboard(arabic, { color: '#0072b2' })) !== 'ok', 'NEGATIVE CONTROL: a line 1 in the reader\'s Arabic names should be refused by the dry run, and was not: this check cannot fail', 'negative control: the same selection in the reader\'s names IS refused (the check can fail)') const indiaText = X.selectionClipboard([{ key: 'adm1:Mahārāshtra', name: 'Mahārāshtra', count: 54, kind: 'adm1' }, { key: 'adm1:Goa', name: 'Goa', count: 5, kind: 'adm1' }], {}) ok(dry(indiaText) === 'ok', `apply-grouping's dry run refuses an India state selection: ${dry(indiaText)}`, 'an India state selection resolves too (shape names are canonical)') // ── B. the CSV ─────────────────────────────────────────────────────────────────────────────────── function parseCsv(s) { // RFC 4180: quoted fields, doubled quotes, CRLF records const rows = []; let row = [], cell = '', q = false for (let i = 0; i < s.length; i++) { const c = s[i] if (q) { if (c === '"') { if (s[i + 1] === '"') { cell += '"'; i++ } else q = false } else cell += c } else if (c === '"') q = true else if (c === ',') { row.push(cell); cell = '' } else if (c === '\r' && s[i + 1] === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; i++ } else cell += c } row.push(cell); rows.push(row) return rows } const rows = parseCsv(csv) ok(JSON.stringify(rows[0]) === JSON.stringify(X.SELECTION_CSV_COLUMNS), `the CSV header is ${JSON.stringify(rows[0])}`, `the CSV header: ${X.SELECTION_CSV_COLUMNS.join(',')}`) ok(rows.length === 1 + records.length, `the CSV has ${rows.length - 1} data rows for ${records.length} people groups`) const col = (r, k) => r[X.SELECTION_CSV_COLUMNS.indexOf(k)] ok(col(rows[2], 'name') === 'Bhil, Tadvi "Hill"', `a name with a comma and quotes reads back as ${JSON.stringify(col(rows[2], 'name'))}`) ok(col(rows[4], 'name') === 'Line one\nLine two', `a name with a line break reads back as ${JSON.stringify(col(rows[4], 'name'))}`, 'commas, quotes and line breaks survive a real CSV parse') ok(col(rows[3], 'name') === '\'=HYPERLINK("x")', `a leading = reads back as ${JSON.stringify(col(rows[3], 'name'))}: a spreadsheet would run it`, 'a leading = + - @ is neutralised') ok(col(rows[5], 'name') === "'\t=cmd", `a leading TAB reads back as ${JSON.stringify(col(rows[5], 'name'))}: OWASP lists it as a formula trigger`, 'a leading TAB or CR is neutralised too (OWASP)') ok(col(rows[1], 'territories') === 'Mahārāshtra; Goa' && col(rows[1], 'color') === color && col(rows[1], 'slug') === 'agaria', `row 1 is ${JSON.stringify(rows[1])}`, 'each row carries its slug, the territories that contain it, and the colour') // ── C. the HUD wires it ────────────────────────────────────────────────────────────────────────── const vue = readFileSync(P('plugin-designs/my-upg-100-list/upg-100-list.vue'), 'utf8') const wired = { 'the colour swatches': /class="upg-swatch"[\s\S]{0,400}@click="setSelectionColor\(c\)"/.test(vue), 'the custom hex input': /class="upg-hex"[\s\S]{0,200}@change="onHexInput"/.test(vue), 'the Copy button': /@click="copySelection">\{\{ t\('upgMap\.copySelection'\) \}\}/.test(vue), 'the Export CSV button': /@click="exportSelectionCsv">/.test(vue) && vue.includes("t('upgMap.exportCsv')"), 'the prefetch on batch mode': /if \(next\) selectionRecordsLoad\(\)/.test(vue), 'one detection for every action': (vue.match(/await selectionPeople\(\)/g) || []).length >= 2 && /selectionPeople\(\)\.then/.test(vue), // India's states resolve through the served table (library/data/indiaStates.js), fetched only once a state is selected 'India states detected from the served table': /import \{ loadIndiaStates \} from '@map\/data\/indiaStates\.js'/.test(vue) && /india\.byShapeName\.get\(t\.name\)/.test(vue) && /t\.kind === 'adm1'\) indiaStates\(\)\.catch/.test(vue), } const missingWires = Object.entries(wired).filter(([, v]) => !v).map(([k]) => k) ok(!missingWires.length, `the HUD is missing: ${missingWires.join(', ')}`, 'the HUD wires the swatches, hex input, Copy, Export CSV, the prefetch, and India\'s states from the served table') // ── D. the strings, ×11 ────────────────────────────────────────────────────────────────────────── const LANGS = ['en', 'es', 'fr', 'pt', 'de', 'it', 'ro', 'ru', 'ar', 'hi', 'zh'] const KEYS = { upgMap: ['copySelection', 'exportCsv', 'selectionColourGroup', 'selectionColour', 'customColour', 'copiedSelection', 'copiedTerritoriesOnly', 'csvSaved', 'csvFailed'] } const cat = (l, f) => JSON.parse(readFileSync(P('library/i18n/locales', l, `${f}.json`), 'utf8')) const bad = [] for (const [f, keys] of Object.entries(KEYS)) { const en = cat('en', f) for (const l of LANGS) { const c = cat(l, f) for (const k of keys) { if (typeof c[k] !== 'string' || !c[k].trim()) bad.push(`${l} ${f}.${k} missing`) else if (l !== 'en' && c[k] === en[k]) bad.push(`${l} ${f}.${k} is English`) } } } ok(!bad.length, `strings: ${bad.slice(0, 8).join('; ')}${bad.length > 8 ? ` (+${bad.length - 8})` : ''}`, `the ${KEYS.upgMap.length} new strings in all ${LANGS.length} locales, translated`) for (const n of notes) console.log(n) if (fails.length) { console.error('BATCH-SELECTION-EXPORT: FAIL') for (const f of fails) console.error(` ✗ ${f}`) process.exit(1) } console.log('BATCH-SELECTION-EXPORT: PASS — Copy parses in apply-grouping, the CSV is well-formed and safe, the HUD wires both, and the strings are in all 11 locales.')