Merge upg100/batch-select (P0 batch selection, 2bf2607fc) into upg100/india-states: P0b's India detection builds on P0's selectionPeople. package.json keeps both new gates

This commit is contained in:
Doxa Maps 2026-09-30 21:33:24 +00:00
commit 39ded339fa
21 changed files with 652 additions and 221 deletions

View file

@ -0,0 +1,154 @@
#!/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),
}
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, and the prefetch')
// ── 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.')

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
"mapDataFailed": "تعذّر تحميل بيانات الخريطة",
"infoLead": "قائمة UPG-100 على خريطة واحدة: الشعوب غير المُبشَّرة، مجمّعة في مناطق يمكنك أن تتبنّاها بالصلاة.",
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها."
"infoTakeaway": "لكل منطقة: ملصق للحائط، وحزمة صلاة للتوزيع، ومجموعة من بطاقات الصلاة. المثال أدناه هو المنطقة التي تضم أكبر عدد من الناس في شعوب لا يعمل أحد بعد على غرس كنائس بينها.",
"selectionColourGroup": "لون التحديد",
"selectionColour": "تلوين التحديد {hex}",
"customColour": "لون مخصص (#rrggbb)",
"copiedSelection": "تم النسخ — الشعوب: {count}",
"copiedTerritoriesOnly": "تم نسخ الأقاليم فقط — {reason}",
"csvSaved": "تم حفظ CSV — الشعوب: {count}",
"csvFailed": "فشل تصدير CSV: {reason}",
"copySelection": "نسخ",
"exportCsv": "تصدير CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Plakate gibt es pro Gebiet in der Legende – nutzen Sie Gebetsliste für eine Auswahl auf der Karte",
"mapDataFailed": "Die Kartendaten konnten nicht geladen werden",
"infoLead": "Die UPG-100-Liste auf einer Karte: unerreichte Volksgruppen, zusammengefasst in Zonen, die Sie im Gebet übernehmen können.",
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet."
"infoTakeaway": "für jede Zone ein Wandposter, ein Gebetsheft zum Weitergeben und ein Satz Gebetskarten. Das Beispiel unten ist die Zone mit den meisten Menschen in Volksgruppen, unter denen noch niemand Gemeinden gründet.",
"selectionColourGroup": "Farbe der Auswahl",
"selectionColour": "Auswahl einfärben: {hex}",
"customColour": "Eigene Farbe (#rrggbb)",
"copiedSelection": "Kopiert — Volksgruppen: {count}",
"copiedTerritoriesOnly": "Nur die Gebiete kopiert — {reason}",
"csvSaved": "CSV gespeichert — Volksgruppen: {count}",
"csvFailed": "CSV-Export fehlgeschlagen: {reason}",
"copySelection": "Kopieren",
"exportCsv": "CSV exportieren"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Posters exist per territory on the legend — use Prayer List for a map selection",
"mapDataFailed": "The map data could not be loaded",
"infoLead": "The UPG-100 list on one map: unreached people groups, gathered into zones you can adopt in prayer.",
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet."
"infoTakeaway": "for any zone, a wall poster, a prayer packet to hand out and a deck of prayer cards. The example below is the zone with the most people in groups where no one is planting churches yet.",
"selectionColourGroup": "Selection colour",
"selectionColour": "Colour the selection {hex}",
"customColour": "Custom colour (#rrggbb)",
"copiedSelection": "Copied — people groups: {count}",
"copiedTerritoriesOnly": "Copied the territories only — {reason}",
"csvSaved": "CSV saved — people groups: {count}",
"csvFailed": "CSV failed: {reason}",
"copySelection": "Copy",
"exportCsv": "Export CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Hay carteles por territorio en la leyenda; usa Lista de oración para una selección en el mapa",
"mapDataFailed": "No se pudieron cargar los datos del mapa",
"infoLead": "La lista UPG-100 en un solo mapa: pueblos no alcanzados, agrupados en zonas que usted puede adoptar en oración.",
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias."
"infoTakeaway": "para cada zona, un póster para la pared, un paquete de oración para repartir y un mazo de tarjetas de oración. El ejemplo de abajo es la zona con más personas en pueblos donde todavía nadie está plantando iglesias.",
"selectionColourGroup": "Color de la selección",
"selectionColour": "Colorear la selección {hex}",
"customColour": "Color personalizado (#rrggbb)",
"copiedSelection": "Copiado — pueblos: {count}",
"copiedTerritoriesOnly": "Solo se copiaron los territorios — {reason}",
"csvSaved": "CSV guardado — pueblos: {count}",
"csvFailed": "Error al exportar el CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Les affiches existent par territoire dans la légende — utilisez Liste de prière pour une sélection sur la carte",
"mapDataFailed": "Les données de la carte n’ont pas pu être chargées",
"infoLead": "La liste UPG-100 sur une seule carte : les peuples non atteints, regroupés en zones que vous pouvez adopter dans la prière.",
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises."
"infoTakeaway": "pour chaque zone, une affiche murale, un livret de prière à distribuer et un jeu de cartes de prière. L'exemple ci-dessous est la zone qui compte le plus de personnes dans des peuples où personne n'implante encore d'Églises.",
"selectionColourGroup": "Couleur de la sélection",
"selectionColour": "Colorer la sélection {hex}",
"customColour": "Couleur personnalisée (#rrggbb)",
"copiedSelection": "Copié — peuples : {count}",
"copiedTerritoriesOnly": "Seuls les territoires ont été copiés — {reason}",
"csvSaved": "CSV enregistré — peuples : {count}",
"csvFailed": "Échec de l’export CSV : {reason}",
"copySelection": "Copier",
"exportCsv": "Exporter en CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "पोस्टर लेजेंड में हर क्षेत्र के लिए हैं — मानचित्र पर चयन के लिए प्रार्थना सूची का उपयोग करें",
"mapDataFailed": "मानचित्र का डेटा लोड नहीं हो सका",
"infoLead": "UPG-100 सूची एक ही नक्शे पर: अपहुँचे जन-समूह, क्षेत्रों में बाँटे गए, जिन्हें आप प्रार्थना में अपना सकते हैं।",
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।"
"infoTakeaway": "हर क्षेत्र के लिए दीवार का एक पोस्टर, बाँटने के लिए एक प्रार्थना पुस्तिका और प्रार्थना कार्डों का एक सेट। नीचे का उदाहरण वह क्षेत्र है जहाँ ऐसे जन-समूहों में सबसे अधिक लोग हैं जिनके बीच अभी कोई कलीसिया स्थापित नहीं कर रहा है।",
"selectionColourGroup": "चयन का रंग",
"selectionColour": "चयन को रंगें: {hex}",
"customColour": "अपना रंग (#rrggbb)",
"copiedSelection": "कॉपी हुआ — जनसमूह: {count}",
"copiedTerritoriesOnly": "केवल क्षेत्र कॉपी हुए — {reason}",
"csvSaved": "CSV सहेजा गया — जनसमूह: {count}",
"csvFailed": "CSV निर्यात विफल: {reason}",
"copySelection": "कॉपी करें",
"exportCsv": "CSV निर्यात करें"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "I manifesti esistono per territorio nella legenda: usa Lista di preghiera per una selezione sulla mappa",
"mapDataFailed": "Impossibile caricare i dati della mappa",
"infoLead": "La lista UPG-100 su un'unica mappa: popoli non raggiunti, raggruppati in zone che puoi adottare nella preghiera.",
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese."
"infoTakeaway": "per ogni zona, un poster da parete, un libretto di preghiera da distribuire e un mazzo di carte di preghiera. L'esempio qui sotto è la zona con più persone in popoli dove nessuno sta ancora fondando chiese.",
"selectionColourGroup": "Colore della selezione",
"selectionColour": "Colora la selezione {hex}",
"customColour": "Colore personalizzato (#rrggbb)",
"copiedSelection": "Copiato — popoli: {count}",
"copiedTerritoriesOnly": "Copiati solo i territori — {reason}",
"csvSaved": "CSV salvato — popoli: {count}",
"csvFailed": "Esportazione CSV non riuscita: {reason}",
"copySelection": "Copia",
"exportCsv": "Esporta CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Há cartazes por território na legenda — use Lista de oração para uma seleção no mapa",
"mapDataFailed": "Não foi possível carregar os dados do mapa",
"infoLead": "A lista UPG-100 em um só mapa: povos não alcançados, agrupados em zonas que você pode adotar em oração.",
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas."
"infoTakeaway": "para cada zona, um pôster de parede, um pacote de oração para distribuir e um baralho de cartões de oração. O exemplo abaixo é a zona com mais pessoas em povos onde ninguém ainda está plantando igrejas.",
"selectionColourGroup": "Cor da seleção",
"selectionColour": "Colorir a seleção {hex}",
"customColour": "Cor personalizada (#rrggbb)",
"copiedSelection": "Copiado — povos: {count}",
"copiedTerritoriesOnly": "Apenas os territórios foram copiados — {reason}",
"csvSaved": "CSV salvo — povos: {count}",
"csvFailed": "Falha ao exportar o CSV: {reason}",
"copySelection": "Copiar",
"exportCsv": "Exportar CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Afișele există pe teritoriu în legendă — folosiți Listă de rugăciune pentru o selecție pe hartă",
"mapDataFailed": "Datele hărții nu au putut fi încărcate",
"infoLead": "Lista UPG-100 pe o singură hartă: popoare neatinse, grupate în zone pe care le poți adopta în rugăciune.",
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici."
"infoTakeaway": "pentru fiecare zonă, un poster de perete, un pachet de rugăciune de împărțit și un set de cartonașe de rugăciune. Exemplul de mai jos este zona cu cei mai mulți oameni din popoare în care nimeni nu plantează încă biserici.",
"selectionColourGroup": "Culoarea selecției",
"selectionColour": "Colorează selecția {hex}",
"customColour": "Culoare personalizată (#rrggbb)",
"copiedSelection": "Copiat — popoare: {count}",
"copiedTerritoriesOnly": "Au fost copiate doar teritoriile — {reason}",
"csvSaved": "CSV salvat — popoare: {count}",
"csvFailed": "Exportul CSV a eșuat: {reason}",
"copySelection": "Copiază",
"exportCsv": "Exportă CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "Плакаты есть для каждой территории в легенде — для выбора на карте используйте «Молитвенный список»",
"mapDataFailed": "Не удалось загрузить данные карты",
"infoLead": "Список UPG-100 на одной карте: недостигнутые народы, объединённые в зоны, которые вы можете взять на молитвенное попечение.",
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви."
"infoTakeaway": "для каждой зоны — настенный плакат, молитвенный буклет для раздачи и набор молитвенных карточек. Ниже показана зона, где больше всего людей в народах, среди которых пока никто не основывает церкви.",
"selectionColourGroup": "Цвет выделения",
"selectionColour": "Окрасить выделение: {hex}",
"customColour": "Свой цвет (#rrggbb)",
"copiedSelection": "Скопировано — народы: {count}",
"copiedTerritoriesOnly": "Скопированы только территории — {reason}",
"csvSaved": "CSV сохранён — народы: {count}",
"csvFailed": "Не удалось экспортировать CSV: {reason}",
"copySelection": "Копировать",
"exportCsv": "Экспорт в CSV"
}

View file

@ -5,5 +5,14 @@
"selectionPosterNeedsZone": "海报按图例中的区域提供——地图上的选择请使用“祷告名单”",
"mapDataFailed": "无法加载地图数据",
"infoLead": "UPG-100 名单尽在一张地图上:未得之民按区域分组,你可以在祷告中认领一个区域。",
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。"
"infoTakeaway": "每个区域都有一张墙面海报、一本可分发的祷告手册和一套祷告卡。下面的示例是这样一个区域:在尚无人植堂的族群中,这里的人数最多。",
"selectionColourGroup": "所选区域的颜色",
"selectionColour": "将所选区域着色为 {hex}",
"customColour": "自定义颜色(#rrggbb)",
"copiedSelection": "已复制 — 族群:{count}",
"copiedTerritoriesOnly": "仅复制了区域 — {reason}",
"csvSaved": "CSV 已保存 — 族群:{count}",
"csvFailed": "CSV 导出失败:{reason}",
"copySelection": "复制",
"exportCsv": "导出 CSV"
}

View file

@ -223,6 +223,7 @@
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
"check:packet-a1-structure": "node internal/check-packet-a1-structure.mjs",
"check:india-states": "node internal/check-india-states.mjs",
"check:batch-selection-export": "node internal/check-batch-selection-export.mjs",
"check:zone-title-tier-a": "node internal/check-zone-title-tier-a.mjs",
"check:evangelical-share": "node internal/check-evangelical-share.mjs",
"check:map-labels": "node internal/check-map-labels.mjs",
@ -343,7 +344,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-new-driver-references check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:dxlife check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -551,6 +552,7 @@
"check:host-locale-fetch": "node internal/check-host-locale-fetch.mjs",
"check:packet-a1-structure": "the prayer packet is built to its specified structure, read page by page from each sheet's CONTENT: full detail = map, triangle, life calendar, % evangelical, profiles, bounded set, centered set, wheel, points; lightweight = map, triangle, profiles, wheel, points. Page 1 places a map-only vertical sheet on the packet's own margin and says it is one of the UPG 100 prayer lists only when a count is supplied; page 2 is one inverted triangle whose packets tier and \"- N\" steps appear only where true; the wheel carries the Metacamp Module 1 PDF's colours and opens at nine o'clock; all 28 legend zones resolve, Deaf included.",
"check:india-states": "India's states-and-union-territories table (data/people-groups/india-states.json) stays true to the stamping it is derived from: all 36 ISO 3166-2:IN divisions (28 state + 8 union_territory, Nagaland + Mizoram listed even when empty), every India people group of admin-membership.json in exactly ONE division, every shapeName a feature of the served geometry, NO DRIFT (re-derived from today's inputs it is byte-identical and meta's input sha384s are the files on disk), and pin == table == served copy under a size ceiling. Mutation-tested. Headless; in gates:pre. By the UPG-100 owner.",
"check:batch-selection-export": "UPG-100's batch selection EXPORTS its selection (restored 2026-09-30): Copy's first line parses in data/apply-grouping.mjs (names, UUPG total, colour) with the people-group CSV after it; the CSV header, quoting and formula-neutralising hold under a real CSV parse; the HUD wires the colour swatches, hex input, Copy, Export CSV and the records prefetch; the 9 new strings are in all 11 locales, translated. Mutation-tested (CSV first, no formula guard, Copy unwired: each FAILS). Headless; in gates:pre. By the UPG-100 owner.",
"check:tree-relative": "the committed tree names no host of its own. With embedBaseUrl empty in the build config, the manifest must say embedBase \".\" and every scriptUrl, iframeUrl and embed snippet src must be relative. check:drop cannot see this: it looks for root-relative \"/...\" references and skips https:// ones, so a tree built with EMBED_BASE_URL (a deploy build) would pass it and pin every snippet to one host, the way the retired CDN route broke. Data addresses (runtime.apiBaseUrl) are not checked. SKIPs when the config sets embedBaseUrl. Static; takes an optional manifest path for poison tests.",
"check:universal-props": "every plugin reads the props the Plugin Studio header sets for all of them through the library: map engine, locale, Mapbox token and token URL, and the people-group list. It reads each bundle's source AND its shipped bundle and fails on a bypass (a mapbox:// style literal, a private token global, a list fetched around the library); a by-design exception is ALLOWed by name with its reason. --report also prints the matrix. Headless.",
"check:zone-title-tier-a": "generated zone titles ship for ONE-country zones only: the 3 such zones produce a localized, non-code title in en/es/ar that differs across languages, AND every other zone declines so its hand-written label survives unchanged (a multi-country zone has a name that is not a list of its members). Two-sided, and NOT MEASURED when the country-name supplier cannot resolve a known code.",

View file

@ -0,0 +1,70 @@
// selection-export.js — what the batch selection's Copy and Export CSV buttons produce. PURE: no DOM, no
// fetch, so internal/check-batch-selection-export.mjs gates it headless.
//
// ★ WHY THE SELECTION IS EXPORTED AT ALL: when the people-group data changes, a partner redraws the zone
// boundaries on the map, exports, and hands the export to an agent, which regroups the zones and regenerates
// the posters. Without the export the maps cannot be reproduced.
//
// THE CLIPBOARD TEXT, in this order:
// line 1 "NPL, BTN, … — {total} UUPG · #hex" formatClipboard's line in MACHINE names (payloadName):
// data/apply-grouping.mjs parses AND resolves it, so it MUST stay first
// line 2 "Nepal 34, Bhutan 13, …" the per-territory breakdown, in the reader's names
// blank line, then the SAME CSV that Export CSV downloads (when the people groups could be detected).
//
// THE CSV: one row per people group, header first, CRLF line ends. The cells follow the library's rules
// (useCsvExport's esc): quote a cell holding , " CR or LF and double its quotes; prefix a leading = + - @
// with a single quote, so a spreadsheet never runs a name as a formula. Also a leading TAB or CR (OWASP's
// full list of formula triggers; the library's esc stops at = + - @).
import { formatClipboard } from './territory-counts.js';
export const SELECTION_CSV_COLUMNS = ['slug', 'name', 'country_code', 'population', 'territories', 'color'];
export function csvCell(v) {
let s = v === null || v === undefined ? '' : String(v);
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"';
return s;
}
/**
* @param {Array<{slug,name,country_code,population}>} records the detected people groups
* @param {Map<string,string[]>} territoriesBySlug slug → the selected territories that contain it
* @param {{color?: string}} opts the picked selection colour ('#rrggbb' or '')
*/
export function selectionCsv(records, territoriesBySlug = new Map(), opts = {}) {
const color = opts && typeof opts.color === 'string' ? opts.color : '';
const rows = (records || []).map((r) => [
r.slug, r.name, r.country_code, r.population,
(territoriesBySlug.get(r.slug) || []).join('; '), color,
].map(csvCell).join(','));
return [SELECTION_CSV_COLUMNS.join(','), ...rows].join('\r\n');
}
// ★ LINE 1 IS A MACHINE CONTRACT: data/apply-grouping.mjs resolves every name in it, so it carries the names that
// tool resolves in EVERY UI language, never the reader's translation (an Arabic "نيبال, بوتان" was rejected:
// "unresolved territory name(s)"). A country is its ISO3 code, the only country form apply-grouping resolves for
// every country (its friendly-name table has ~15); a state or province is its shape name, already canonical; a
// zone keeps its name. The reader's own names stay on line 2, in the HUD and in the CSV.
export function payloadName(t) {
if (t && t.kind === 'adm0') return String(t.key || '').replace(/^adm0:/, '') || t.name;
return t ? t.name : '';
}
/** The clipboard text: apply-grouping's line (machine names), the reader's breakdown, then the CSV after a blank line. */
export function selectionClipboard(selection, opts = {}, csv = '') {
const color = opts && opts.color ? opts.color : '';
const sel = Array.isArray(selection) ? selection : [];
const line1 = formatClipboard(sel.map((t) => ({ ...t, name: payloadName(t) })), { color }).split('\n')[0];
const line2 = formatClipboard(sel, { color }).split('\n')[1];
const head = line1 + '\n' + line2;
return csv ? head + '\n\n' + csv : head;
}
/** A file name a partner can recognise: the selection's label, reduced to safe characters. */
export function selectionCsvFileName(label) {
const base = String(label || 'selection').normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase().slice(0, 60) || 'selection';
return `upg-100-${base}.csv`;
}
export default { SELECTION_CSV_COLUMNS, csvCell, payloadName, selectionCsv, selectionClipboard, selectionCsvFileName };

View file

@ -128,6 +128,7 @@ import { colorForZone } from './data/zone-colors.js'
// clicked tapestry feature carries shapeName (ADM1) OR iso3 (ADM0) and this maps
// either to { key, name, count } from admin-counts/membership (NEVER edits them).
import { resolveTerritory, buildCountryNameMap, formatClipboard, ADM1_COUNTS, ISO3_NAME } from './data/territory-counts.js'
import { selectionCsv, selectionClipboard, selectionCsvFileName } from './data/selection-export.js'
// 09b-search-bar §1(a-c): unified search index (PG + zone + territory) + the
// tokenized contains matcher. Pure data + pure fns; this profile builds the rows
// from the live records (PG), the legend leafZones (zone), and admin-counts +
@ -258,6 +259,8 @@ function setBatchMode(on) {
if (next === batchMode.value) return next // idempotent: repeat sets don't churn
batchMode.value = next
if (map) map.getCanvas().style.cursor = '' // reset hover cursor on mode flip
// Warm the people-group records so Copy / Export CSV run from cache inside the tap (see selectionRecordsLoad).
if (next) selectionRecordsLoad().catch(() => {})
// R1.4: show/hide ALL batch chrome (map highlight + legend markers) in the SAME
// pass as the mode flip — no frame where the mode and its chrome disagree.
refreshSelectionPaint()
@ -1814,65 +1817,65 @@ function selectedAdm1Names() {
.map((t) => t.name || String(t.key).replace(/^adm1:/, ''));
}
async function selectionAsList() {
// ── THE SELECTION'S PEOPLE, detected ONCE for every action (packet, cards, Copy, Export CSV) ─────────
// The records load once per language and are CACHED: batch mode prefetches them when it turns on, so a
// Copy tap builds its text from cache and writes it inside the tap's user activation (a clipboard write
// after a network wait is refused, on phones above all). A failed load is dropped so the next try refetches.
let _selRecords = null; // { lang, promise }
function selectionRecordsLoad() {
const lang = packetLang();
if (_selRecords && _selRecords.lang === lang) return _selRecords.promise;
if (!API_BASE) return Promise.reject(keyedError('wall.hud.noApi', 'no people-groups service is configured'));
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
const promise = (async () => {
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${lang}`, { cache: 'no-store' });
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
return normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
})();
_selRecords = { lang, promise };
promise.catch(() => { if (_selRecords && _selRecords.promise === promise) _selRecords = null; });
return promise;
}
// { records, from: Map(slug → the selected territories that contain it), missing: adm1 names } in the
// SELECTION'S order: a zone's groups, then a country's, each record once. Territory names are the HUD's own
// (selectedTerritories[].name), the same names the clipboard's first line carries.
async function selectionPeople() {
const lists = selectedLists();
const codes = selectedCountryCodes();
const adm1 = selectedAdm1Names();
if (!lists.length && !codes.length) {
// Name WHY when the only thing selected is a province — "nothing selected" would be a lie the
// reader can see is false, with their selection visibly highlighted on the map.
// reader can see is false.
if (adm1.length) throw keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names} — select the country instead', { names: formatList(adm1, { locale: packetLang() }) });
throw keyedError('upgMap.nothingSelected', 'nothing is selected');
}
if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
// ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (brief A1.5 col 2,
// 2026-09-28). describeGroup builds a localized sentence — who, where, what language, how reached —
// and GSEC is the only reachedness field that is a NUMBER: EvangelicalLevel is English prose from the
// API ('2% or Greater but Less than 5%'), so interpolating it would leave every non-English packet
// half-English in its widest column (sr measured this across 12,379 rows). primary_language is the
// same field poster-wall already fetches (:171), so this adds no new API surface.
// ★ wagf_region + wagf_block ARE SELECTOR INPUTS, not display fields. SIX of the 27 printable
// zones select on them — west-africa / east-africa / central-africa (wagf_block), europe
// (wagf_region), southern-africa-islands (union of two wagf_block) and oceania-indonesia (union of
// wagf_region + country_code). Without them here, evaluate() matched NOTHING on packet rows and
// those packets printed EMPTY, while the same selectors resolved correctly on the map — whose
// fetch (:1988) has always carried both. Measured: a wagf_block selector returns 0 members on
// packet rows and 1 on map rows. oceania-indonesia was the worst of the six because its union
// still matched IDN, so it printed a PLAUSIBLE packet that was silently missing all of Oceania.
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js)
const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' });
if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status });
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const records = await selectionRecordsLoad();
const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set();
for (const l of lists) {
if (l.selector && l.selector.type === 'pg_slugs') {
for (const s of (l.selector.values || [])) slugs.add(s);
} else {
for (const s of evaluate(l.selector, records)) slugs.add(s);
const bySlug = new Map(records.map((r) => [r.slug, r]));
const byId = new Map(printableZones().map((l) => [l.id, l]));
const from = new Map();
const add = (slug, name) => {
const at = from.get(slug);
if (!at) from.set(slug, [name]);
else if (!at.includes(name)) at.push(name);
};
for (const t of selectedTerritories.value) {
if (t.kind === 'zone') {
const l = byId.get(String(t.key).replace(/^zone:/, ''));
if (!l) continue;
const slugs = l.selector && l.selector.type === 'pg_slugs' ? (l.selector.values || []) : evaluate(l.selector, records);
for (const s of slugs) add(s, t.name);
} else if (t.kind === 'adm0') {
for (const s of evaluate({ type: 'country_codes', values: [String(t.key).replace(/^adm0:/, '')] }, records)) add(s, t.name);
}
}
// The tapped countries, through the SAME evaluator and into the SAME slug set, so a mixed
// selection (a legend zone AND a country) unions correctly and de-duplicates for free.
if (codes.length) {
for (const s of evaluate({ type: 'country_codes', values: codes }, records)) slugs.add(s);
}
// The label is what the reader sees on the cover and in the print dialog's title. Name the
// territories they actually chose rather than inventing a title for their selection.
// ⚠ THE JOINERS WERE HARDCODED ENGLISH on a cover an Arabic reader sees: ' + ' and ' +N more', printed
// straight onto the packet's first page and into the print dialog's title. Found while checking whether
// batch selection was already done (dx-shepherd END-DXS-U100B) — the SELECTION path was, but its label
// was not localized, so a reader choosing three territories in Arabic got their names in Arabic joined
// by an English tail.
// zone_join / zone_join_more already exist in all eleven packet-strings blocks (landed with the zone
// title generator), translated once for exactly this shape, so this is a reuse rather than new strings.
// ⚠ NAME THE COUNTRIES TOO. Built from `lists` alone this was EMPTY for a country-only selection,
// and names.reduce(join2) on an empty array THROWS ("Reduce of empty array with no initial value")
// — so even after the selector fix the packet would have died on its cover. The names come from
// selectedTerritories in the reader's own selection order, which is the order the cover promises.
// Each chosen zone named as its own packet would title it — generated in the reader's language where
// it can be (one-country zones), the hand-written label otherwise.
return { records: [...from.keys()].map((s) => bySlug.get(s)).filter(Boolean), from, missing: adm1 };
}
async function selectionAsList() {
const lists = selectedLists();
const { from } = await selectionPeople();
const zoneNames = (await Promise.all(lists.map(localizedZone))).map((l) => l.label || l.id);
const countryNames = selectedTerritories.value.filter((t) => t.kind === 'adm0').map((t) => t.name);
const names = [...zoneNames, ...countryNames];
@ -1882,8 +1885,84 @@ async function selectionAsList() {
? names.reduce(join2)
: fillJoin(S.zone_join_more, { a: names.slice(0, 2).reduce(join2), n: names.length - 2 })
?? `${names.slice(0, 2).join(' + ')} +${names.length - 2} more`;
return { id: 'selection', label, count: slugs.size,
selector: { type: 'pg_slugs', values: [...slugs] } };
return { id: 'selection', label, count: from.size,
selector: { type: 'pg_slugs', values: [...from.keys()] } };
}
// ── COPY + EXPORT CSV (restored 2026-09-30; see data/selection-export.js for WHY and the formats) ────────
// COPY starts SYNCHRONOUSLY: the text is a promise handed to ClipboardItem, which Safari and Chrome accept
// inside the tap even when the people groups are still loading. Where that is refused it falls back to
// writeText, then to the textarea path. A selection whose people groups cannot be detected (a province)
// still copies the territory lines that data/apply-grouping.mjs needs, and the flash says what was left out.
function selectionNote(missing) {
return missing && missing.length
? t('upgMap.selectionAdm1NoData', { names: formatList(missing, { locale: packetLang() }) }) : '';
}
function copySelection() {
const sel = selectedTerritories.value;
if (!sel.length) return;
const color = selectedColor.value;
let count = null, note = '';
const text = selectionPeople().then((p) => {
count = p.records.length; note = selectionNote(p.missing);
return selectionClipboard(sel, { color }, selectionCsv(p.records, p.from, { color }));
}, (e) => { note = errorText(e, t); return selectionClipboard(sel, { color }); });
text.then((v) => { try { window.__UPG_LAST_CLIPBOARD__ = v } catch (_) {} });
writeClipboard(text).then((ok) => {
const done = count == null ? t('upgMap.copiedTerritoriesOnly', { reason: note.slice(0, 90) })
: t('upgMap.copiedSelection', { count }) + (note ? ' · ' + note.slice(0, 90) : '');
flashRef(copyFlash, ok ? done : t('messages.copyFailed'), ok && note ? 6000 : 3000);
});
}
async function writeClipboard(textPromise) {
const nav = typeof navigator !== 'undefined' ? navigator : null;
if (nav && nav.clipboard && nav.clipboard.write && typeof ClipboardItem !== 'undefined') {
try {
await nav.clipboard.write([new ClipboardItem({ 'text/plain': textPromise.then((v) => new Blob([v], { type: 'text/plain' })) })]);
return true;
} catch (_) { /* refused (e.g. no promise support): the next paths */ }
}
const text = await textPromise;
try {
if (nav && nav.clipboard && nav.clipboard.writeText) { await nav.clipboard.writeText(text); return true; }
} catch (_) { /* no permission or no activation left: the textarea path */ }
return legacyCopy(text);
}
// textarea + execCommand('copy'): a non-secure context has no navigator.clipboard at all.
function legacyCopy(text) {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.style.top = '0';
document.body.appendChild(ta);
ta.focus(); ta.select();
const ok = document.execCommand && document.execCommand('copy');
document.body.removeChild(ta);
return !!ok;
} catch (_) { return false; }
}
// EXPORT CSV downloads the same CSV Copy carries. A UTF-8 byte-order mark leads the file so a spreadsheet
// reads the diacritics and non-Latin names as UTF-8 rather than guessing a legacy code page.
async function exportSelectionCsv() {
if (batchBusy.value) return;
batchBusy.value = true;
busyAction.value = 'csv';
try {
const p = await selectionPeople();
const color = selectedColor.value;
const csv = selectionCsv(p.records, p.from, { color });
try { window.__UPG_LAST_CSV__ = csv } catch (_) {}
const url = URL.createObjectURL(new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = selectionCsvFileName(selectedTerritories.value.map((x) => x.name).join(' '));
a.style.display = 'none';
document.body.appendChild(a); a.click();
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove(); } catch (_) {} }, 400);
const note = selectionNote(p.missing);
flashRef(copyFlash, t('upgMap.csvSaved', { count: p.records.length }) + (note ? ' · ' + note.slice(0, 90) : ''), note ? 6000 : 3000);
} catch (e) {
flashRef(copyFlash, t('upgMap.csvFailed', { reason: errorText(e, t).slice(0, 90) }), 5000);
} finally { batchBusy.value = false; busyAction.value = ''; }
}
async function makeSelectionPacket() {
@ -3009,6 +3088,9 @@ onBeforeUnmount(() => {
if (window.__UPG_SEARCH__ === _mySearchHandle) delete window.__UPG_SEARCH__
if (window.__UPG_MAP__ === _myMapHandle) delete window.__UPG_MAP__
if (window.__UPG_SELECT__ === _mySelectHandle) delete window.__UPG_SELECT__
// Copy / Export CSV's last text (the gates read them back); released with the rest of this map's hooks.
delete window.__UPG_LAST_CLIPBOARD__
delete window.__UPG_LAST_CSV__
} catch (_) { /* a frozen or cross-origin window must not break teardown */ }
}
})
@ -3232,12 +3314,22 @@ onBeforeUnmount(() => {
</span>
</div>
<div v-if="selCount" class="upg-hud-names">{{ formatList(selectedTerritories.map(t => t.name), { locale: packetLang() }) }}</div>
<!-- ★ THE SELECTION MAKES SOMETHING. The batch tool was reported very buggy, and letting
a reader change the colour of regions is NOT what is wanted — the batch selection tool
should let them create their own poster or prayer packet from their own selection.
So the colour swatches and the hex input are GONE, and the actions that remain BUILD
the two artefacts this app already knows how to make, from exactly what was picked.
These reuse printPrayerPacket / printPoster rather than growing a third pipeline. -->
<!-- ★ RECOLOUR IS BACK (2026-09-30), beside the actions that build from the selection. The batch tool
exists so that when the people-group data changes, partners can redraw the zones and re-derive
their people groups: the colour previews on the map (the upg-sel-fill layer repaints; the base map
stays clean) and travels in Copy's first line as " · #hex", which data/apply-grouping.mjs applies
to the regrouped zone. 8 Okabe–Ito swatches (colour-blind aware) + a custom hex. -->
<div v-if="selCount" class="upg-hud-swatches" role="group" :aria-label="t('upgMap.selectionColourGroup')">
<button v-for="c in SWATCHES" :key="c" type="button"
class="upg-swatch" :class="{ active: selectedColor === c }" :aria-pressed="selectedColor === c ? 'true' : 'false'"
:data-color="c" :style="{ background: c }"
:title="t('upgMap.selectionColour', { hex: c })" :aria-label="t('upgMap.selectionColour', { hex: c })"
@click="setSelectionColor(c)"></button>
<input v-model="hexDraft" class="upg-hex" placeholder="#hex" spellcheck="false"
:aria-label="t('upgMap.customColour')" @change="onHexInput" @keyup.enter="onHexInput" />
</div>
<!-- The make-row BUILDS what this app already makes from exactly what was picked (printPrayerPacket /
printPoster / the card deck); the data-row below EXPORTS the selection itself. -->
<!-- ★ THE BUTTON SAYS WHAT IT IS DOING. Reported from mobile 2026-09-28: pressing these gave
no sign of progress. batchBusy only DISABLED them, and a disabled button is visually
indistinguishable from an unresponsive one — so a reader on a phone taps, sees the label
@ -3252,6 +3344,13 @@ onBeforeUnmount(() => {
@click="makeSelectionPoster">{{ busyAction === 'poster' ? t('buttons.working') : t('buttons.posterMap') }}</button>
<button class="upg-hud-btn make" :class="{ 'is-busy': busyAction === 'cards' }" :disabled="batchBusy"
@click="makeSelectionCards">{{ busyAction === 'cards' ? t('buttons.working') : t('buttons.cardDeck') }}</button>
</div>
<!-- COPY + EXPORT CSV: the selection's territories (apply-grouping's line) and its people groups
(data/selection-export.js). A second row, so six buttons never squeeze into one on a phone. -->
<div v-if="selCount" class="upg-hud-actions">
<button class="upg-hud-btn copy" @click="copySelection">{{ t('upgMap.copySelection') }}</button>
<button class="upg-hud-btn csv" :class="{ 'is-busy': busyAction === 'csv' }" :disabled="batchBusy"
@click="exportSelectionCsv">{{ busyAction === 'csv' ? t('buttons.working') : t('upgMap.exportCsv') }}</button>
<button class="upg-hud-btn clear" @click="clearSelection">{{ t('buttons.clearSelection') }}</button>
</div>
<div v-if="selCount" class="upg-hud-flash">{{ copyFlash }}</div>

View file

@ -881,7 +881,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
"elements": [
"my-upg-100-list-map"
],
@ -904,7 +904,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -920,8 +920,8 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -937,8 +937,8 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

View file

@ -881,7 +881,7 @@
"bundle": "my-upg-100-list",
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"output": "dx-maps/my-upg-100-list/my-upg-100-list.js",
"integrity": "sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1",
"integrity": "sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB",
"elements": [
"my-upg-100-list-map"
],
@ -904,7 +904,7 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/poster-wall/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"poster-wall\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -920,8 +920,8 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-list\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
},
{
@ -937,8 +937,8 @@
"scriptUrl": "./my-upg-100-list/my-upg-100-list.js",
"embed": {
"iframe": "<iframe src=\"./my-upg-100-list/upg-100-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
"script": "<!-- MapLibre engine (the default), fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.js\" integrity=\"sha384-5+cfbwT0iiub6VsQAdn6yz16nr6sDiQoHx6tm4O8OVYXHYOxcffFmCJBL0dgdvGp\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://cdn.jsdelivr.net/npm/maplibre-gl@5.24.0/dist/maplibre-gl.css\" integrity=\"sha384-uTttxo/aOKbdE5RlD/SPzSDoDmNvGlUYPjONi2MN/b7c9HPSvW07OIuyP7uL6jxK\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- MapLibre draws this map and needs no token. For Mapbox, paste this map's embed.scriptMapbox from manifest.json instead: it sets \"provider\":\"mapbox\" and preloads the Mapbox engine; fill \"tk\" with your token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>",
"scriptMapbox": "<!-- Mapbox engine, fetched early -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\" integrity=\"sha384-tO0clamXONKwbiz7O6xJpUeyJbTSPugC+xM9clvfFvKTH3i8SmCawHGdf5quMWmZ\" crossorigin=\"anonymous\">\n<link rel=\"preload\" as=\"style\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.css\" integrity=\"sha384-rp1X/jJ9MpWPef9OimfXhOaOc4Gzs87yQvF+ajwY6X2H4PTeHNycNlLWiWvj7ehV\" crossorigin=\"anonymous\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./my-upg-100-list/my-upg-100-list.js\" integrity=\"sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB\" crossorigin=\"anonymous\"></script>\n<!-- Mapbox draws this map: fill \"tk\" with your Mapbox token. -->\n<my-upg-100-list-map profile-config='{\"profile\":\"upg-100-mfe\",\"provider\":\"mapbox\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></my-upg-100-list-map>"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -76,7 +76,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -81,7 +81,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <my-upg-100-list-map> custom element. -->
<script src="../my-upg-100-list.js" integrity="sha384-2/wkzxIJOGkpDtDLOne0xQu9XIZozyGKYgm7ZZQrqefIh92wM6coVQujNBbQxZW1" crossorigin="anonymous"></script>
<script src="../my-upg-100-list.js" integrity="sha384-FSloUQyV3AhmpUsDx+GhnLsYZONh5N0w/8iCm8w6gMK6z3MQCCZXnVPi8yY0UgwB" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: