Speak the reader's language on the UPG-100 map, its wall and the print HUD

The legend's region sections, its Deaf row and its one-country zones, the
wall's card names, the print progress and error messages, the map's status
badge and the Population column all rendered English in every locale. None
of them was visible to check:hardcoded-english: they were single words, .js
modules, Error() arguments, library components or number formatting.

- Legend: region rows through one REGION_KEY table shared with the wall
  (poster.region_*, new region_deaf); zones through zoneTitle (tier A by
  country, the Deaf row by its bloc name); node ids stay English-derived.
- Wall: card labels through zoneTitle, so the Show-on-map name, alt text,
  tooltip, HUD title and printed title agree with the legend and packet.
- Errors a reader sees are keyedError(key, English, params);
  errorText(e, t) shows them translated, and an unkeyed error still shows
  its own message. Developer detail for a failed data load goes to the
  console; the badge says upgMap.mapDataFailed.
- Population: Intl compact notation in the app's locale, same precision.
- Country Posters: the unsupported card's reason is
  messages.countryPosterTooMany, not the data file's English note.
- Strings only the UPG-100 map shows move to a new upgMap area, so other
  bundles do not carry them (country-posters 1,490,907 bytes).
- check:hardcoded-english judges keyedError's English as a fallback
  (quiet when the key resolves everywhere, a finding when it does not);
  check:label-key-coverage follows REGION_KEY to legend-tree.js and covers
  the legend's table and its regionFor producer. Both self-tests two-sided.
This commit is contained in:
Doxa Maps 2026-09-29 08:09:06 +00:00
parent 3233df2bc1
commit 03839179b7
56 changed files with 711 additions and 127 deletions

View file

@ -132,7 +132,12 @@ function localizedNames(code) {
// tStatic is the library's own translator for code outside a component's setup (imported from // tStatic is the library's own translator for code outside a component's setup (imported from
// @map/i18n), and leaving it out meant `tStatic('grid.search') || 'English'` — correct, guarded, // @map/i18n), and leaving it out meant `tStatic('grid.search') || 'English'` — correct, guarded,
// fully translated code — read as a leak wherever it was used directly rather than behind a wrapper. // fully translated code — read as a leak wherever it was used directly rather than behind a wrapper.
const names = new Set(['t', 'tf', '$t', 'tStatic']) // keyedError(key, 'English', params) (library/i18n/keyedError.js) is a translator call too: its English is
// the FALLBACK of a catalogue key that errorText() shows in the reader's language, so it is judged like
// T(key, 'English') — quiet when the key resolves in every locale, a finding when it does not. As a plain
// call it would have counted every keyed error as a leak, and exempting it like new Error() would have
// hidden a typo'd key.
const names = new Set(['t', 'tf', '$t', 'tStatic', 'keyedError'])
for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1]) for (const m of code.matchAll(/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*(t|tf)\b\s*(?![(.])/g)) names.add(m[1])
// WRAPPERS ARE TRANSLATORS TOO. `const T = (key, en) => tStatic(key) || en` is the shape most of // WRAPPERS ARE TRANSLATORS TOO. `const T = (key, en) => tStatic(key) || en` is the shape most of
// these components use, and matching only `const X = t` missed every one of them — the wrapper's // these components use, and matching only `const X = t` missed every one of them — the wrapper's
@ -700,6 +705,7 @@ const PLANTED_BAD = `<template>
import { a } from './a.js' import { a } from './a.js'
import { b } from '../b/c.js' import { b } from '../b/c.js'
const msg = 'No results were found' const msg = 'No results were found'
const kerr = keyedError('dashboard.no_such_key_at_all', 'this sentence has no catalogue key')
const split = \`\${a} polys · \${b} zones · \` const split = \`\${a} polys · \${b} zones · \`
const ctx = \`\${n} countries\` const ctx = \`\${n} countries\`
</script>` </script>`
@ -725,6 +731,7 @@ const lone = 'Other'
const proxyMiss = t.search || 'Search names, country, religion…' const proxyMiss = t.search || 'Search names, country, religion…'
const keyMiss = msg !== 'messages.engineMissing' ? msg : 'The map engine did not load.' const keyMiss = msg !== 'messages.engineMissing' ? msg : 'The map engine did not load.'
const devThrow = () => { throw new Error('table not found for this request') } const devThrow = () => { throw new Error('table not found for this request') }
const keyed = () => { throw keyedError('dashboard.map_loading', 'Loading the map for you') }
const devWarn = () => console.warn('boundaries failed to load for this map') const devWarn = () => console.warn('boundaries failed to load for this map')
const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map') const host = document.querySelector('gospel-resources-dashboard, gospel-resources-map')
const one = el.closest('regions-dashboard, doxa-simple-map') const one = el.closest('regions-dashboard, doxa-simple-map')
@ -951,6 +958,8 @@ const a = greeting + 'Welcome to the prayer map'
ok(!good.some((f) => f.kind === 'bind:interpolation'), 'QUIET on interpolations that are translator calls or bare values') ok(!good.some((f) => f.kind === 'bind:interpolation'), 'QUIET on interpolations that are translator calls or bare values')
ok(!good.some((f) => /map engine did not load/.test(f.text)), 'QUIET on the "did t() return the key?" miss idiom (msg !== \'a.dotted.key\')') ok(!good.some((f) => /map engine did not load/.test(f.text)), 'QUIET on the "did t() return the key?" miss idiom (msg !== \'a.dotted.key\')')
ok(!good.some((f) => /table not found/.test(f.text)), 'QUIET on a throw new Error(…) argument — exempt by CALL SITE') ok(!good.some((f) => /table not found/.test(f.text)), 'QUIET on a throw new Error(…) argument — exempt by CALL SITE')
ok(!good.some((f) => /Loading the map for you/.test(f.text)), 'QUIET on keyedError(key, English) whose key resolves in every locale')
ok(bad.some((f) => /this sentence has no catalogue key/.test(f.text)), 'SEES keyedError(key, English) whose key does NOT resolve — its English is what the reader gets')
ok(!good.some((f) => /boundaries failed/.test(f.text)), 'QUIET on a console.warn(…) argument') ok(!good.some((f) => /boundaries failed/.test(f.text)), 'QUIET on a console.warn(…) argument')
ok(!good.some((f) => /gospel-resources-dashboard|regions-dashboard, doxa/.test(f.text)), ok(!good.some((f) => /gospel-resources-dashboard|regions-dashboard, doxa/.test(f.text)),
'QUIET on a DOM query selector, including a comma-separated list of custom-element tags') 'QUIET on a DOM query selector, including a comma-separated list of custom-element tags')

View file

@ -45,20 +45,47 @@ const ok = (cond, msg) => { if (!cond) fails.push(msg) }
// would be guessing about code it has never seen, and a wrong guess here reports a defect that is // would be guessing about code it has never seen, and a wrong guess here reports a defect that is
// not there. Each entry names the file, the table that PRODUCES labels, the table that maps a label // not there. Each entry names the file, the table that PRODUCES labels, the table that maps a label
// to a key, the helper that reads them, and the area those keys live in. // to a key, the helper that reads them, and the area those keys live in.
// `mapsFile` names the file the key table lives in when it is not `file` (the wall and the legend share
// one REGION_KEY in legend-tree.js). `producerFn` names a function whose ternary arms return labels
// directly (legend-tree's regionFor returns 'India' / 'China + Mongolia' / 'Other' without a helper call).
const TABLES = [ const TABLES = [
{ {
file: 'plugin-designs/my-upg-100-list/poster-wall.vue', file: 'plugin-designs/my-upg-100-list/poster-wall.vue',
produces: 'REGION_OF', produces: 'REGION_OF',
mapsFile: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
maps: 'REGION_KEY', maps: 'REGION_KEY',
via: 'regionLabel', via: 'regionLabel',
area: 'poster', area: 'poster',
}, },
{
// the legend's region rows: the same REGION_KEY, through buildLegendNodes' opts.regionTitle
file: 'plugin-designs/my-upg-100-list/data/legend-tree.js',
produces: 'REGION_OF',
maps: 'REGION_KEY',
via: 'regionTitleOf',
producerFn: 'regionFor',
area: 'poster',
},
] ]
/** The body of `const NAME = { ... }`, matched to its first closing brace at column 0. */ /** The body of `const NAME = { ... }`, to its MATCHING closing brace. Brace-balanced rather than "the first
* `}` at column 0": a table declared inside a function (legend-tree's REGION_OF) is indented, and a
* column-0 match would run on to the end of the function and read every later `: '…'` as a label. */
function objectBody(src, name) { function objectBody(src, name) {
const m = new RegExp(`const ${name}\\s*=\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src) const m = new RegExp(`const ${name}\\s*=\\s*\\{`).exec(src)
return m ? m[1] : null if (!m) return null
let depth = 1
const start = m.index + m[0].length
for (let i = start; i < src.length; i++) {
if (src[i] === '{') depth++
else if (src[i] === '}' && --depth === 0) return src.slice(start, i)
}
return null
}
/** The labels a producer function returns from its ternary arms: `? 'India'` / `: 'Other'`. */
function producerLabels(src, fn) {
const m = new RegExp(`const ${fn}\\s*=\\s*\\([^)]*\\)\\s*=>([\\s\\S]*?);\\n`).exec(src)
return m ? [...m[1].matchAll(/[?:]\s*'([^']+)'/g)].map((x) => x[1]) : null
} }
/** Quoted values on the right of a colon — the English labels a table produces. */ /** Quoted values on the right of a colon — the English labels a table produces. */
const valuesOf = (body) => new Set([...body.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1])) const valuesOf = (body) => new Set([...body.matchAll(/:\s*'([^']+)'/g)].map((m) => m[1]))
@ -71,8 +98,12 @@ for (const t of TABLES) {
continue continue
} }
const src = read(t.file) const src = read(t.file)
if (t.mapsFile && !existsSync(resolve(ROOT, t.mapsFile))) {
ok(false, `${t.mapsFile} is missing — the key table this gate watches moved or was renamed`)
continue
}
const produces = objectBody(src, t.produces) const produces = objectBody(src, t.produces)
const maps = objectBody(src, t.maps) const maps = objectBody(t.mapsFile ? read(t.mapsFile) : src, t.maps)
// ★ A MISSING TABLE IS A BLIND GATE, NOT A PASS. If either object is renamed away, every set below // ★ A MISSING TABLE IS A BLIND GATE, NOT A PASS. If either object is renamed away, every set below
// is empty and the coverage check passes while measuring nothing — the failure mode a gate must // is empty and the coverage check passes while measuring nothing — the failure mode a gate must
// never have. So absence is the loud case. // never have. So absence is the loud case.
@ -81,7 +112,14 @@ for (const t of TABLES) {
const fromTable = valuesOf(produces) const fromTable = valuesOf(produces)
const fromCalls = new Set([...src.matchAll(new RegExp(`${t.via}\\('([^']+)'\\)`, 'g'))].map((m) => m[1])) const fromCalls = new Set([...src.matchAll(new RegExp(`${t.via}\\('([^']+)'\\)`, 'g'))].map((m) => m[1]))
const produced = new Set([...fromTable, ...fromCalls]) let fromFn = []
if (t.producerFn) {
const got = producerLabels(src, t.producerFn)
// the same blind-gate rule as the tables: a renamed producer must fail, not contribute nothing
if (!got || !got.length) { ok(false, `${t.file}: no \`const ${t.producerFn} = (…) =>\` returning labels — this gate has gone blind`); continue }
fromFn = got
}
const produced = new Set([...fromTable, ...fromCalls, ...fromFn])
const covered = keysOf(maps) const covered = keysOf(maps)
ok(produced.size > 0, `${t.file}: ${t.produces} yielded no labels at all — the shape it is parsed with no longer matches`) ok(produced.size > 0, `${t.file}: ${t.produces} yielded no labels at all — the shape it is parsed with no longer matches`)
@ -132,6 +170,13 @@ for (const t of TABLES) {
t.push([JSON.stringify(probe(VIA_CALL, 'label')) === '["Other"]', 'a label passed as a literal argument is reported']) t.push([JSON.stringify(probe(VIA_CALL, 'label')) === '["Other"]', 'a label passed as a literal argument is reported'])
// and the blind-gate case: a renamed table must not read as agreement // and the blind-gate case: a renamed table must not read as agreement
t.push([objectBody(`const OTHER = {\n 'a': 'b',\n}`, 'OF') === null, 'a renamed table is detected, not silently skipped']) t.push([objectBody(`const OTHER = {\n 'a': 'b',\n}`, 'OF') === null, 'a renamed table is detected, not silently skipped'])
// an INDENTED table inside a function ends at its own brace, not at the function's
const NESTED = `function f() {\n const OF = {\n 'a': 'Africa',\n };\n const x = { label: 'Not a region' };\n}\n`
t.push([JSON.stringify([...valuesOf(objectBody(NESTED, 'OF'))]) === '["Africa"]', 'an indented table is read to its own closing brace'])
// a producer's ternary arms are labels; its startsWith() arguments are not
const FN = `const regionFor = (id) =>\n OF[id] || (id.startsWith('asia-in-') ? 'India'\n : 'Other');\n`
t.push([JSON.stringify(producerLabels(FN, 'regionFor')) === '["India","Other"]', 'a producer function yields its returned labels only'])
t.push([producerLabels(FN, 'renamedFor') === null, 'a renamed producer is detected, not silently skipped'])
const bad = t.filter(([pass]) => !pass) const bad = t.filter(([pass]) => !pass)
for (const [, msg] of bad) fails.push(`SELF-TEST: ${msg}`) for (const [, msg] of bad) fails.push(`SELF-TEST: ${msg}`)
} }

View file

@ -48,7 +48,7 @@ const humanizeKey = (k) => String(k).split('.').pop()
// C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the // C4: the guard is library/composables/useOptionalI18n now. humanizeKey stays the fallback -- NOT the
// catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false // catalogue -- so a bare host still reads 'Name' for legend.columns.name, and `te` still answers false
// so defaultTab and columnLabel keep falling through to their own defaults. // so defaultTab and columnLabel keep falling through to their own defaults.
const { t, te } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) }) const { t, te, locale: i18nLocale } = useOptionalI18n('', { fallback: (k) => humanizeKey(k) })
// Translate a tab's display name from the `tabs.<key>` catalog, keyed by the // Translate a tab's display name from the `tabs.<key>` catalog, keyed by the
// tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded // tab's stable `i18nKey` (preferred) or `id`. Falls back to the hardcoded
@ -634,13 +634,24 @@ watch(selectedId, async id => {
}) })
// ── Formatters ───────────────────────────────────────────────────────────────── // ── Formatters ─────────────────────────────────────────────────────────────────
// The app's locale (a ref or a string, depending on how the host installed i18n); undefined lets Intl
// use the browser's, which is what toLocaleString() did before.
function numLocale() {
const l = i18nLocale && (i18nLocale.value ?? i18nLocale)
return typeof l === 'string' && l ? l : undefined
}
// ★ COMPACT IN THE READER'S NOTATION. This was `toFixed(1) + 'M'` / `+ 'k'`: English abbreviations with
// an English decimal point on every Population cell in every locale. Intl's compact notation writes each
// language's own form (ru "12,3 млн", de "12,3 Mio.", ar "12.3 مليون", zh "1234.6万") at the same
// precision as before: one decimal for millions, whole thousands.
function fmtPop(n) { function fmtPop(n) {
if (!n) return '—' if (!n) return '—'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M' if (n >= 1e3) {
if (n >= 1e3) return Math.round(n / 1e3) + 'k' return new Intl.NumberFormat(numLocale(), { notation: 'compact', maximumFractionDigits: n >= 1e6 ? 1 : 0 }).format(n)
return n.toLocaleString() }
return n.toLocaleString(numLocale())
} }
function fmtCount(n) { return n != null ? n.toLocaleString() : '—' } function fmtCount(n) { return n != null ? n.toLocaleString(numLocale()) : '—' }
function rowClasses(row) { function rowClasses(row) {
return { return {

View file

@ -48,6 +48,7 @@ const T = (key, en) => tStatic(key) || en
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { fetchPosterRaster, fetchPosterSource } from '@map/packet/poster-fetch.js' import { fetchPosterRaster, fetchPosterSource } from '@map/packet/poster-fetch.js'
import { usePosterPrints } from '@map/composables/usePosterPrints.js' import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { keyedError, errorText } from '@map/i18n/keyedError.js'
import { useOptionalI18n, useOptionalT } from '@map/composables/useOptionalT.js' import { useOptionalI18n, useOptionalT } from '@map/composables/useOptionalT.js'
// Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell // Export-modal geometry. A fixed;inset:0 scrim covers only the CONTENT PANE in the cap-5 shell
// (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable // (contain:layout on .vtl-content). Measured 1024x793 @ (256,107) at 1280x900 — rail clickable
@ -257,6 +258,13 @@ const plural = computed(() => props.itemNounPlural || props.itemNoun)
// wrong for a count — so counts use a catalogue translator of their own (tStatic when no i18n). // wrong for a count — so counts use a catalogue translator of their own (tStatic when no i18n).
const tCount = useOptionalT('') const tCount = useOptionalT('')
const countOf = (key, n) => tCount(key, { n: Number(n || 0).toLocaleString() }, Number(n || 0)) const countOf = (key, n) => tCount(key, { n: Number(n || 0).toLocaleString() }, Number(n || 0))
// The HUD's kinds and progress messages, and the poster image's alt text (what a screen reader announces
// for a card), in the page's language; English only where a key is absent. The same translator goes to
// usePosterPrints (opts.t) so the messages IT sets are localized too.
const hud = (key, en, params = null, n = undefined) => {
const v = tCount(key, params || {}, n)
return v && v !== key ? v : String(en).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m))
}
const itemCount = (n) => (props.itemCountKey ? countOf(props.itemCountKey, n) : `${n} ${n === 1 ? props.itemNoun : plural.value}`) const itemCount = (n) => (props.itemCountKey ? countOf(props.itemCountKey, n) : `${n} ${n === 1 ? props.itemNoun : plural.value}`)
const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, '')) const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, ''))
@ -373,11 +381,11 @@ async function exportPoster(item) {
if (exportState.value) return // one at a time if (exportState.value) return // one at a time
const reason = props.unsupported(item) const reason = props.unsupported(item)
if (reason) { if (reason) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'soon', message: reason, pct: 0 } exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'soon', message: reason, pct: 0 }
return return
} }
exportState.value = { label: item.label, kind: 'Poster', phase: 'fetch', exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'fetch',
message: 'fetching poster', pct: 20 } message: hud('wall.hud.fetchingPoster', 'fetching the poster'), pct: 20 }
try { try {
// ★ GENERATED FIRST (the print standard): a host that can draw the sheet now (the `sheet` prop, // ★ GENERATED FIRST (the print standard): a host that can draw the sheet now (the `sheet` prop,
// packet/posterSheets.js) prints or downloads THAT — no stored file involved, so a country, which has // packet/posterSheets.js) prints or downloads THAT — no stored file involved, so a country, which has
@ -385,7 +393,7 @@ async function exportPoster(item) {
// for a home printer), the item's own poster size for a download. A generation that fails falls through // for a home printer), the item's own poster size for a download. A generation that fails falls through
// to the stored image and HTML below rather than to an error. // to the stored image and HTML below rather than to an error.
if (props.sheet) { if (props.sheet) {
exportState.value = { ...exportState.value, phase: 'render', message: 'generating the poster', pct: 35 } exportState.value = { ...exportState.value, phase: 'render', message: hud('wall.hud.generatingPoster', 'generating the poster'), pct: 35 }
const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4') const format = props.posterAction === 'print' ? 'letter' : (item.sheet === '3x3' ? '3x3' : '3x4')
const html = await Promise.resolve().then(() => props.sheet(item, format)).catch(() => '') const html = await Promise.resolve().then(() => props.sheet(item, format)).catch(() => '')
if (html) { if (html) {
@ -415,22 +423,22 @@ async function exportPoster(item) {
// This is the same move the prayer packet already makes: generate on click, // This is the same move the prayer packet already makes: generate on click,
// ship nothing. A 404 used to end here as "poster not found". // ship nothing. A 404 used to end here as "poster not found".
exportState.value = { ...exportState.value, phase: 'render', exportState.value = { ...exportState.value, phase: 'render',
message: 'generating the poster', pct: 45 } message: hud('wall.hud.generatingPoster', 'generating the poster'), pct: 45 }
const source = await fetchPosterSource(`${base.value}/posters/${item.id}.html`) const source = await fetchPosterSource(`${base.value}/posters/${item.id}.html`)
if (!source.html) throw new Error(`poster not found (${source.status})`) if (!source.html) throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: source.status })
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js') const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(source.html, { scale: 2 }) const blob = await rasterPoster(source.html, { scale: 2 })
// Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is // Fail LOUDLY rather than hand over a black rectangle: an SVG in an <img> is
// an isolated document, so a missing stylesheet makes every fill fall back to // an isolated document, so a missing stylesheet makes every fill fall back to
// black with no error at all. // black with no error at all.
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank') if (!(await looksRendered(blob))) throw keyedError('wall.hud.posterBlank', 'the generated poster came out blank')
await deliverPoster(blob, item) await deliverPoster(blob, item)
return return
} }
await deliverPoster(raster, item) await deliverPoster(raster, item)
} catch (e) { } catch (e) {
exportState.value = { label: item.label, kind: 'Poster', phase: 'error', exportState.value = { label: item.label, kind: hud('wall.hud.kindPoster', 'Poster'), phase: 'error',
message: String(e?.message || e).slice(0, 140), pct: 0 } message: errorText(e, (k, p) => hud(k, k, p)).slice(0, 140), pct: 0 }
} }
} }
@ -506,6 +514,7 @@ if (props.cardStocks) {
} }
const prints = usePosterPrints({ const prints = usePosterPrints({
t: (key, params, n) => tCount(key, params, n),
state: exportState, state: exportState,
records: props.records ? (item) => props.records(item) : null, records: props.records ? (item) => props.records(item) : null,
// useI18n()'s locale can be a ref or a plain string depending on how the host // useI18n()'s locale can be a ref or a plain string depending on how the host
@ -572,7 +581,7 @@ function downloadPacket(item) {
// error — unbuilt, not broken. phase:'error' reads to a user as "something went // error — unbuilt, not broken. phase:'error' reads to a user as "something went
// wrong" rather than "not yet". // wrong" rather than "not yet".
if (!canPrintRows.value) { if (!canPrintRows.value) {
exportState.value = { label: item.label, kind: 'Prayer List', phase: 'soon', exportState.value = { label: item.label, kind: hud('wall.hud.kindPrayerList', 'Prayer List'), phase: 'soon',
message: props.prayerListSoonMessage, pct: 0 } message: props.prayerListSoonMessage, pct: 0 }
return return
} }
@ -617,7 +626,7 @@ function printPacketAs(item, variantId) {
// browser-rasterised). Both callers in exportPoster() route through here. // browser-rasterised). Both callers in exportPoster() route through here.
async function deliverPoster(blob, item) { async function deliverPoster(blob, item) {
if (props.posterAction === 'print') { if (props.posterAction === 'print') {
exportState.value = { ...exportState.value, phase: 'save', message: 'opening the print dialog', pct: 80 } exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.openingPrint', 'opening the print dialog'), pct: 80 }
const { openPrintFrame } = await import('@map/composables/printInPlace.js') const { openPrintFrame } = await import('@map/composables/printInPlace.js')
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
posterObjectUrls.push(url) posterObjectUrls.push(url)
@ -637,11 +646,11 @@ async function deliverPoster(blob, item) {
{ autoprint: true }, { autoprint: true },
) )
} else { } else {
exportState.value = { ...exportState.value, phase: 'save', message: 'saving', pct: 80 } exportState.value = { ...exportState.value, phase: 'save', message: hud('wall.hud.saving', 'saving'), pct: 80 }
download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`) download(blob, `${props.downloadPrefix}-${item.id}-poster.webp`)
} }
exportState.value = { ...exportState.value, phase: 'done', exportState.value = { ...exportState.value, phase: 'done',
message: props.posterAction === 'print' ? 'print dialog opened' : 'saved', pct: 100 } message: props.posterAction === 'print' ? hud('wall.hud.printOpened', 'print dialog opened') : hud('wall.hud.saved', 'saved'), pct: 100 }
setTimeout(() => { exportState.value = null }, 900) setTimeout(() => { exportState.value = null }, 900)
} }
@ -1174,8 +1183,8 @@ useShadowStyles(
<img v-else-if="!missing[item.id]" class="img" <img v-else-if="!missing[item.id]" class="img"
:class="{ 'is-clickable': previewIsControl }" :src="previewUrl(item.id)" :class="{ 'is-clickable': previewIsControl }" :src="previewUrl(item.id)"
:alt="previewIsControl :alt="previewIsControl
? `${item.label} — prayer poster (click to preview and print)` ? hud('wall.posterAltAction', '{label} — prayer poster (click to preview and print)', { label: item.label })
: `${item.label} — prayer poster`" : hud('wall.posterAlt', '{label} — prayer poster', { label: item.label })"
loading="lazy" decoding="async" loading="lazy" decoding="async"
:role="previewIsControl ? 'button' : null" :role="previewIsControl ? 'button' : null"
:tabindex="previewIsControl ? 0 : null" :tabindex="previewIsControl ? 0 : null"

View file

@ -27,6 +27,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { openPrintFrame } from './printInPlace.js'; import { openPrintFrame } from './printInPlace.js';
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js'; import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
import { keyedError, errorText } from '../i18n/keyedError.js';
const esc = (s) => String(s ?? '') const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
@ -72,16 +73,28 @@ export function usePosterPrints(opts = {}) {
const label = opts.label || ((item) => item?.label || item?.id || ''); const label = opts.label || ((item) => item?.label || item?.id || '');
const lang = opts.lang || (() => 'en'); const lang = opts.lang || (() => 'en');
const stock = opts.stock || (() => 'poker'); const stock = opts.stock || (() => 'poker');
// ★ THE HUD SPEAKS THE READER'S LANGUAGE. Every kind and progress message this composable sets is shown
// in the host's HUD (a dialog a screen reader announces) and was English in every locale. A host that
// passes `t` (key, params, count) — PosterWall passes its plural-aware translator — gets them
// localized at the source; without one they stay English, exactly as before. Counts pass their number
// so each locale picks its own plural form.
const tr = (key, en, params = null, n = undefined) => {
const fillEn = () => String(en).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m));
if (typeof opts.t !== 'function') return fillEn();
const v = opts.t(key, params || {}, n);
return v && v !== key ? v : fillEn();
};
const set = (item, kind, phase, message, pct) => { const set = (item, kind, phase, message, pct) => {
exportState.value = { label: label(item), kind, phase, message, pct }; exportState.value = { label: label(item), kind, phase, message, pct };
}; };
const done = (item, kind) => { const done = (item, kind) => {
set(item, kind, 'done', 'print dialog opened', 100); set(item, kind, 'done', tr('wall.hud.printOpened', 'print dialog opened'), 100);
setTimeout(() => { exportState.value = null; }, 900); setTimeout(() => { exportState.value = null; }, 900);
}; };
const fail = (item, kind, e) => { const fail = (item, kind, e) => {
set(item, kind, 'error', String(e?.message || e).slice(0, 140), 0); // a keyed error (keyedError.js) in the reader's language; anything else as its own message
set(item, kind, 'error', errorText(e, (k, p) => tr(k, k, p)).slice(0, 140), 0);
}; };
function dismiss() { exportState.value = null; } function dismiss() { exportState.value = null; }
@ -90,7 +103,7 @@ export function usePosterPrints(opts = {}) {
throw new Error('this wall supplies no records provider'); throw new Error('this wall supplies no records provider');
} }
const r = await opts.records(item); const r = await opts.records(item);
if (!r || !r.length) throw new Error('that list resolved to no records'); if (!r || !r.length) throw keyedError('wall.hud.noRecords', 'that list has no people groups to print');
return r; return r;
} }
@ -117,20 +130,20 @@ export function usePosterPrints(opts = {}) {
// stays ignorant of where a bundle serves its posters from. // stays ignorant of where a bundle serves its posters from.
async function printPoster(item) { async function printPoster(item) {
if (busy()) return; if (busy()) return;
const kind = 'Poster'; const kind = tr('wall.hud.kindPoster', 'Poster');
const base = opts.assetsBase ? opts.assetsBase() : null; const base = opts.assetsBase ? opts.assetsBase() : null;
const id = item?.id ?? item; const id = item?.id ?? item;
set(item, kind, 'fetch', 'fetching the poster', 25); set(item, kind, 'fetch', tr('wall.hud.fetchingPoster', 'fetching the poster'), 25);
try { try {
// ★ GENERATED FIRST (the print standard: generate at the click, store only previews). A host // ★ GENERATED FIRST (the print standard: generate at the click, store only previews). A host
// that can draw the sheet now (opts.sheet -> packet/posterSheets.js) prints it as a VECTOR page on // that can draw the sheet now (opts.sheet -> packet/posterSheets.js) prints it as a VECTOR page on
// letter paper — no stored file involved. The stored image and HTML remain the fallback for a host // letter paper — no stored file involved. The stored image and HTML remain the fallback for a host
// that cannot generate, and a generation that fails falls through to them rather than to nothing. // that cannot generate, and a generation that fails falls through to them rather than to nothing.
if (opts.sheet) { if (opts.sheet) {
set(item, kind, 'render', 'generating the poster', 35); set(item, kind, 'render', tr('wall.hud.generatingPoster', 'generating the poster'), 35);
const html = await Promise.resolve().then(() => opts.sheet(item, 'letter')).catch(() => ''); const html = await Promise.resolve().then(() => opts.sheet(item, 'letter')).catch(() => '');
if (html) { if (html) {
set(item, kind, 'save', 'opening the print dialog', 80); set(item, kind, 'save', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
openPrintFrame(html, { autoprint: true }); openPrintFrame(html, { autoprint: true });
done(item, kind); done(item, kind);
return; return;
@ -143,15 +156,15 @@ export function usePosterPrints(opts = {}) {
// Only a real image counts: a host's 200 fallback page is a miss (poster-fetch.js). // Only a real image counts: a host's 200 fallback page is a miss (poster-fetch.js).
const raster = webp ? await fetchPosterRaster(webp) : null; const raster = webp ? await fetchPosterRaster(webp) : null;
if (raster) { await deliverPoster(raster, item, kind); return; } if (raster) { await deliverPoster(raster, item, kind); return; }
if (!base) throw new Error('no poster available for this item'); if (!base) throw keyedError('wall.hud.noPoster', 'no poster is available for this list');
// NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are // NO PRE-RENDERED IMAGE? GENERATE IT. The .webp posters are ~41MB across a tree and are
// not shipped by default; the HTML is the SOURCE and is smaller than the image it makes. // not shipped by default; the HTML is the SOURCE and is smaller than the image it makes.
set(item, kind, 'render', 'generating the poster', 45); set(item, kind, 'render', tr('wall.hud.generatingPoster', 'generating the poster'), 45);
const source = await fetchPosterSource(`${base}/posters/${id}.html`); const source = await fetchPosterSource(`${base}/posters/${id}.html`);
if (!source.html) throw new Error(`poster not found (${source.status})`); if (!source.html) throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: source.status });
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js'); const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js');
const blob = await rasterPoster(source.html, { scale: 2 }); const blob = await rasterPoster(source.html, { scale: 2 });
if (!(await looksRendered(blob))) throw new Error('the generated poster rendered blank'); if (!(await looksRendered(blob))) throw keyedError('wall.hud.posterBlank', 'the generated poster came out blank');
await deliverPoster(blob, item, kind); await deliverPoster(blob, item, kind);
} catch (e) { fail(item, kind, e); } } catch (e) { fail(item, kind, e); }
} }
@ -161,7 +174,7 @@ export function usePosterPrints(opts = {}) {
// Constrain both axes and let object-fit letterbox it. // Constrain both axes and let object-fit letterbox it.
const posterObjectUrls = []; const posterObjectUrls = [];
async function deliverPoster(blob, item, kind) { async function deliverPoster(blob, item, kind) {
set(item, kind, 'save', 'opening the print dialog', 80); set(item, kind, 'save', tr('wall.hud.openingPrint', 'opening the print dialog'), 80);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
posterObjectUrls.push(url); posterObjectUrls.push(url);
const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`; const title = `${opts.downloadPrefix || 'doxa'} — ${label(item)}`;
@ -184,8 +197,8 @@ export function usePosterPrints(opts = {}) {
// print-paginated table with a prayer checkbox per row. // print-paginated table with a prayer checkbox per row.
async function printList(item, { columns, title } = {}) { async function printList(item, { columns, title } = {}) {
if (busy()) return; if (busy()) return;
const kind = 'List'; const kind = tr('wall.hud.kindList', 'List');
set(item, kind, 'fetch', 'fetching records', 30); set(item, kind, 'fetch', tr('wall.hud.fetchingRecords', 'fetching records'), 30);
try { try {
const data = await rows(item); const data = await rows(item);
const cols = columns || [ const cols = columns || [
@ -202,7 +215,7 @@ export function usePosterPrints(opts = {}) {
return `<td${c.cls ? ` class="${c.cls}"` : ''}>${esc(v)}</td>`; return `<td${c.cls ? ` class="${c.cls}"` : ''}>${esc(v)}</td>`;
}).join('') + '<td class="c-pray">□</td></tr>').join(''); }).join('') + '<td class="c-pray">□</td></tr>').join('');
set(item, kind, 'render', `laying out ${data.length} rows`, 70); set(item, kind, 'render', tr('wall.hud.layingOutRows', 'laying out {n} rows', { n: data.length }, data.length), 70);
const docTitle = title || `${label(item)} — ${data.length}`; const docTitle = title || `${label(item)} — ${data.length}`;
openPrintFrame( openPrintFrame(
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(docTitle)}</title>` `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${esc(docTitle)}</title>`
@ -235,15 +248,15 @@ export function usePosterPrints(opts = {}) {
// than letter paper. The geometry lives in the shared card renderer. // than letter paper. The geometry lives in the shared card renderer.
async function printCardDeck(item) { async function printCardDeck(item) {
if (busy()) return; if (busy()) return;
const kind = 'Card Deck'; const kind = tr('wall.hud.kindCardDeck', 'Card Deck');
set(item, kind, 'fetch', 'fetching records', 30); set(item, kind, 'fetch', tr('wall.hud.fetchingRecords', 'fetching records'), 30);
try { try {
const data = await rows(item); const data = await rows(item);
const [{ createCardRenderer }, strings] = await Promise.all([ const [{ createCardRenderer }, strings] = await Promise.all([
import('../packet/card-render.js'), import('../packet/card-render.js'),
opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m), opts.strings ? opts.strings() : import('../scripts/posters/packet-strings.json').then((m) => m.default || m),
]); ]);
set(item, kind, 'render', `laying out ${data.length} cards`, 70); set(item, kind, 'render', tr('wall.hud.layingOutCards', 'laying out {n} cards', { n: data.length }, data.length), 70);
const R = createCardRenderer({ strings, lang: lang(), stock: stock() }); const R = createCardRenderer({ strings, lang: lang(), stock: stock() });
openPrintFrame(R.buildDeck(item, data), { autoprint: true }); openPrintFrame(R.buildDeck(item, data), { autoprint: true });
done(item, kind); done(item, kind);
@ -307,8 +320,8 @@ export function usePosterPrints(opts = {}) {
async function printPacket(item, { mapSvg = '', funnel = null, variant = 'full' } = {}) { async function printPacket(item, { mapSvg = '', funnel = null, variant = 'full' } = {}) {
if (busy()) return; if (busy()) return;
const kind = 'Prayer Packet'; const kind = tr('wall.hud.kindPacket', 'Prayer Packet');
set(item, kind, 'fetch', 'fetching people groups', 30); set(item, kind, 'fetch', tr('wall.hud.fetchingGroups', 'fetching people groups'), 30);
try { try {
const data = await rows(item); const data = await rows(item);
// `christian` is the evangelical share behind the packet's %-evangelical page — of THIS packet's // `christian` is the evangelical share behind the packet's %-evangelical page — of THIS packet's
@ -352,7 +365,7 @@ export function usePosterPrints(opts = {}) {
const photos = new Map( const photos = new Map(
data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]), data.filter((g) => g.image_url).map((g) => [g.slug, g.image_url]),
); );
set(item, kind, 'render', `printing ${data.length} people groups`, 70); set(item, kind, 'render', tr('wall.hud.printingGroups', 'printing {n} people groups', { n: data.length }, data.length), 70);
// The join runs over the SAME rows the packet prints, so the sets page cannot describe a // The join runs over the SAME rows the packet prints, so the sets page cannot describe a
// different population than the profile table beside it. // different population than the profile table beside it.
// ── DEFAULT SUPPLIERS for hosts that pass nothing — the wall printed a packet MISSING two // ── DEFAULT SUPPLIERS for hosts that pass nothing — the wall printed a packet MISSING two

View file

@ -25,3 +25,10 @@ is the one translated line that says so.
When a human-reviewed translation of that prose exists, it gets its own area file and that When a human-reviewed translation of that prose exists, it gets its own area file and that
line comes out. line comes out.
## upgMap.json holds strings only the UPG-100 map shows
A bundle carries an area when its code names it (`internal/i18n-areas.mjs`), so strings that only one
plugin shows belong in an area of their own rather than in `messages`, which every bundle carries.
`upgMap` is that area for the UPG-100 map's selection and data-failure messages: 11 locales × those keys
stay out of country-posters, which sits just under the bundle size ceiling.

View file

@ -0,0 +1,32 @@
/**
* keyedError — an Error a READER may see, carrying the catalogue key that says it in their language.
*
* Code that throws does not know the reader's locale, and the UI that catches does not know what went
* wrong, so the message used to cross that gap as English: "poster not found (404)" in an Arabic HUD. The
* error now carries both halves: `message` stays English (logs, consoles, developers), and `i18nKey` +
* `i18nParams` let whoever DISPLAYS it translate it.
*
* throw keyedError('wall.hud.posterNotFound', 'poster not found ({status})', { status: 404 })
* status.value = errorText(e, t) // the reader's language; English for an unkeyed error
*
* An error without a key (a TypeError, a browser's own message) is shown as before: errorText never hides
* a failure because it has no translation for it.
*/
const fill = (s, params) => String(s).replace(/\{(\w+)\}/g, (m, k) => (params && params[k] != null ? String(params[k]) : m))
export function keyedError(key, en, params = {}) {
const e = new Error(fill(en, params))
e.i18nKey = key
e.i18nParams = params
return e
}
/** The text to show a reader for `e`: its catalogue entry through `t` when keyed and present, else its message. */
export function errorText(e, t) {
if (e && e.i18nKey && typeof t === 'function') {
const v = t(e.i18nKey, e.i18nParams || {})
if (typeof v === 'string' && v && v !== e.i18nKey) return v
}
return String((e && e.message) || e)
}

View file

@ -40,5 +40,6 @@
"countryWallHeading": "ملصقات الدول — قوائم صلاة وملصقات", "countryWallHeading": "ملصقات الدول — قوائم صلاة وملصقات",
"largerScreenNotice": "هذه الخريطة التجريبية مُعدّة للشاشات الكبيرة. تُفتح هنا، لكن بعض أجزائها قد لا يتّسع أو لا يعمل جيدًا على الهاتف.", "largerScreenNotice": "هذه الخريطة التجريبية مُعدّة للشاشات الكبيرة. تُفتح هنا، لكن بعض أجزائها قد لا يتّسع أو لا يعمل جيدًا على الهاتف.",
"desktopOnlyNotice": "{label} يعمل على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامه.", "desktopOnlyNotice": "{label} يعمل على الحاسوب فقط — افتح هذه الصفحة على حاسوب لاستخدامه.",
"noticeDismiss": "إغلاق" "noticeDismiss": "إغلاق",
"countryPosterTooMany": "عدد الشعوب أكبر من أن يتّسع له ملصق واحد بوضوح — ونعمل على حلّ لذلك."
} }

View file

@ -27,5 +27,7 @@
"region_india": "الهند", "region_india": "الهند",
"region_other": "أخرى", "region_other": "أخرى",
"region_americas": "الأمريكتان", "region_americas": "الأمريكتان",
"region_europe": "أوروبا" "region_europe": "أوروبا",
"region_deaf": "الصمّ",
"zone_deaf": "مجموعات الصمّ"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "لا توجد بيانات شعوب لـ {names} — اختر الدولة بدلًا من ذلك",
"nothingSelected": "لم يُحدَّد شيء",
"selectionCardsFailed": "فشل إنشاء بطاقات التحديد: {reason}",
"selectionPosterNeedsZone": "الملصقات متاحة لكل منطقة في مفتاح الخريطة — استخدم «قائمة الصلاة» لتحديد على الخريطة",
"mapDataFailed": "تعذّر تحميل بيانات الخريطة"
}

View file

@ -68,5 +68,31 @@
"listCount": "لا قوائم | قائمة واحدة | قائمتان | {n} قوائم | {n} قائمة | {n} قائمة", "listCount": "لا قوائم | قائمة واحدة | قائمتان | {n} قوائم | {n} قائمة | {n} قائمة",
"stockCard": "بطاقة", "stockCard": "بطاقة",
"stockPostcard": "بطاقة بريدية", "stockPostcard": "بطاقة بريدية",
"stockLandscape": "أفقي" "stockLandscape": "أفقي",
"hud": {
"kindPoster": "ملصق",
"kindList": "قائمة",
"kindPacket": "حزمة صلاة",
"fetchingPoster": "جارٍ جلب الملصق",
"generatingPoster": "جارٍ إنشاء الملصق",
"openingPrint": "جارٍ فتح نافذة الطباعة",
"printOpened": "فُتحت نافذة الطباعة",
"saving": "جارٍ الحفظ",
"fetchingRecords": "جارٍ جلب البيانات",
"fetchingGroups": "جارٍ جلب الشعوب",
"layingOutRows": "لا صفوف | صف واحد قيد الترتيب | صفّان قيد الترتيب | {n} صفوف قيد الترتيب | {n} صفًا قيد الترتيب | {n} صف قيد الترتيب",
"layingOutCards": "لا بطاقات | بطاقة واحدة قيد الترتيب | بطاقتان قيد الترتيب | {n} بطاقات قيد الترتيب | {n} بطاقةً قيد الترتيب | {n} بطاقة قيد الترتيب",
"printingGroups": "لا شعوب | طباعة شعب واحد | طباعة شعبين | طباعة {n} شعوب | طباعة {n} شعبًا | طباعة {n} شعب",
"kindCardDeck": "مجموعة البطاقات",
"kindPrayerList": "قائمة الصلاة",
"saved": "تم الحفظ",
"noRecords": "لا تحتوي هذه القائمة على شعوب للطباعة",
"noPoster": "لا يتوفر ملصق لهذه القائمة",
"posterNotFound": "لم يُعثر على الملصق ({status})",
"posterBlank": "خرج الملصق المُنشأ فارغًا",
"noApi": "لم تُهيَّأ خدمة الشعوب",
"fetchFailed": "تعذّر تحميل الشعوب ({status})"
},
"posterAlt": "{label} — ملصق صلاة",
"posterAltAction": "{label} — ملصق صلاة (انقر للمعاينة والطباعة)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Länderposter — Gebetslisten und Poster", "countryWallHeading": "Länderposter — Gebetslisten und Poster",
"largerScreenNotice": "Diese experimentelle Karte ist für große Bildschirme gebaut. Sie öffnet sich hier, aber Teile passen oder funktionieren auf einem Telefon womöglich nicht gut.", "largerScreenNotice": "Diese experimentelle Karte ist für große Bildschirme gebaut. Sie öffnet sich hier, aber Teile passen oder funktionieren auf einem Telefon womöglich nicht gut.",
"desktopOnlyNotice": "{label} funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer.", "desktopOnlyNotice": "{label} funktioniert nur am Desktop – öffnen Sie diese Seite an einem Computer.",
"noticeDismiss": "Schließen" "noticeDismiss": "Schließen",
"countryPosterTooMany": "Zu viele Volksgruppen, um lesbar auf ein Plakat zu passen – an einer Lösung wird gearbeitet."
} }

View file

@ -27,5 +27,7 @@
"region_india": "Indien", "region_india": "Indien",
"region_other": "Sonstige", "region_other": "Sonstige",
"region_americas": "Amerika", "region_americas": "Amerika",
"region_europe": "Europa" "region_europe": "Europa",
"region_deaf": "Gehörlose",
"zone_deaf": "Gruppen gehörloser Menschen"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Keine Volksgruppen-Daten für {names} – wählen Sie stattdessen das Land",
"nothingSelected": "Nichts ausgewählt",
"selectionCardsFailed": "Karten der Auswahl fehlgeschlagen: {reason}",
"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"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} Liste | {n} Listen", "listCount": "{n} Liste | {n} Listen",
"stockCard": "Karte", "stockCard": "Karte",
"stockPostcard": "Postkarte", "stockPostcard": "Postkarte",
"stockLandscape": "Querformat" "stockLandscape": "Querformat",
"hud": {
"kindPoster": "Poster",
"kindList": "Liste",
"kindPacket": "Gebetsheft",
"fetchingPoster": "Poster wird geladen",
"generatingPoster": "Poster wird erstellt",
"openingPrint": "Druckdialog wird geöffnet",
"printOpened": "Druckdialog geöffnet",
"saving": "wird gespeichert",
"fetchingRecords": "Daten werden geladen",
"fetchingGroups": "Volksgruppen werden geladen",
"layingOutRows": "{n} Zeile wird gesetzt | {n} Zeilen werden gesetzt",
"layingOutCards": "{n} Karte wird gesetzt | {n} Karten werden gesetzt",
"printingGroups": "{n} Volksgruppe wird gedruckt | {n} Volksgruppen werden gedruckt",
"kindCardDeck": "Kartenset",
"kindPrayerList": "Gebetsliste",
"saved": "gespeichert",
"noRecords": "diese Liste enthält keine Volksgruppen zum Drucken",
"noPoster": "für diese Liste ist kein Plakat verfügbar",
"posterNotFound": "Plakat nicht gefunden ({status})",
"posterBlank": "das erzeugte Plakat ist leer",
"noApi": "kein Volksgruppen-Dienst konfiguriert",
"fetchFailed": "Volksgruppen konnten nicht geladen werden ({status})"
},
"posterAlt": "{label} — Gebetsposter",
"posterAltAction": "{label} — Gebetsposter (zum Ansehen und Drucken klicken)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Country Posters — Prayer Lists & Posters", "countryWallHeading": "Country Posters — Prayer Lists & Posters",
"largerScreenNotice": "This experimental map is built for larger screens. It opens here, but parts of it may not fit or work well on a phone.", "largerScreenNotice": "This experimental map is built for larger screens. It opens here, but parts of it may not fit or work well on a phone.",
"desktopOnlyNotice": "{label} only supports desktop — open this page on a desktop to use it.", "desktopOnlyNotice": "{label} only supports desktop — open this page on a desktop to use it.",
"noticeDismiss": "Dismiss" "noticeDismiss": "Dismiss",
"countryPosterTooMany": "Too many people groups to fit on one poster legibly — a solution is in progress."
} }

View file

@ -27,5 +27,7 @@
"region_india": "India", "region_india": "India",
"region_other": "Other", "region_other": "Other",
"region_americas": "Americas", "region_americas": "Americas",
"region_europe": "Europe" "region_europe": "Europe",
"region_deaf": "Deaf",
"zone_deaf": "Deaf people groups"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "No people-group data for {names} — select the country instead",
"nothingSelected": "Nothing is selected",
"selectionCardsFailed": "Selection card deck failed: {reason}",
"selectionPosterNeedsZone": "Posters exist per territory on the legend — use Prayer List for a map selection",
"mapDataFailed": "The map data could not be loaded"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} list | {n} lists", "listCount": "{n} list | {n} lists",
"stockCard": "Card", "stockCard": "Card",
"stockPostcard": "Postcard", "stockPostcard": "Postcard",
"stockLandscape": "landscape" "stockLandscape": "landscape",
"hud": {
"kindPoster": "Poster",
"kindList": "List",
"kindPacket": "Prayer Packet",
"fetchingPoster": "fetching the poster",
"generatingPoster": "generating the poster",
"openingPrint": "opening the print dialog",
"printOpened": "print dialog opened",
"saving": "saving",
"fetchingRecords": "fetching records",
"fetchingGroups": "fetching people groups",
"layingOutRows": "laying out {n} row | laying out {n} rows",
"layingOutCards": "laying out {n} card | laying out {n} cards",
"printingGroups": "printing {n} people group | printing {n} people groups",
"kindCardDeck": "Card Deck",
"kindPrayerList": "Prayer List",
"saved": "saved",
"noRecords": "that list has no people groups to print",
"noPoster": "no poster is available for this list",
"posterNotFound": "poster not found ({status})",
"posterBlank": "the generated poster came out blank",
"noApi": "no people-groups service is configured",
"fetchFailed": "could not load the people groups ({status})"
},
"posterAlt": "{label} — prayer poster",
"posterAltAction": "{label} — prayer poster (click to preview and print)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Carteles por país — Listas de oración y carteles", "countryWallHeading": "Carteles por país — Listas de oración y carteles",
"largerScreenNotice": "Este mapa experimental está pensado para pantallas grandes. Se abre aquí, pero algunas partes pueden no caber o no funcionar bien en un teléfono.", "largerScreenNotice": "Este mapa experimental está pensado para pantallas grandes. Se abre aquí, pero algunas partes pueden no caber o no funcionar bien en un teléfono.",
"desktopOnlyNotice": "{label} solo funciona en escritorio: abra esta página en un ordenador para usarlo.", "desktopOnlyNotice": "{label} solo funciona en escritorio: abra esta página en un ordenador para usarlo.",
"noticeDismiss": "Cerrar" "noticeDismiss": "Cerrar",
"countryPosterTooMany": "Demasiados pueblos para que quepan legiblemente en un solo cartel; estamos trabajando en una solución."
} }

View file

@ -27,5 +27,7 @@
"region_india": "India", "region_india": "India",
"region_other": "Otros", "region_other": "Otros",
"region_americas": "América", "region_americas": "América",
"region_europe": "Europa" "region_europe": "Europa",
"region_deaf": "Sordos",
"zone_deaf": "Grupos de personas sordas"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "No hay datos de pueblos para {names}: selecciona el país",
"nothingSelected": "No hay nada seleccionado",
"selectionCardsFailed": "Error al generar las tarjetas de la selección: {reason}",
"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"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} lista | {n} de listas | {n} listas", "listCount": "{n} lista | {n} de listas | {n} listas",
"stockCard": "Tarjeta", "stockCard": "Tarjeta",
"stockPostcard": "Postal", "stockPostcard": "Postal",
"stockLandscape": "horizontal" "stockLandscape": "horizontal",
"hud": {
"kindPoster": "Cartel",
"kindList": "Lista",
"kindPacket": "Paquete de oración",
"fetchingPoster": "obteniendo el cartel",
"generatingPoster": "generando el cartel",
"openingPrint": "abriendo el cuadro de impresión",
"printOpened": "cuadro de impresión abierto",
"saving": "guardando",
"fetchingRecords": "obteniendo registros",
"fetchingGroups": "obteniendo pueblos",
"layingOutRows": "maquetando {n} fila | maquetando {n} de filas | maquetando {n} filas",
"layingOutCards": "maquetando {n} tarjeta | maquetando {n} de tarjetas | maquetando {n} tarjetas",
"printingGroups": "imprimiendo {n} pueblo | imprimiendo {n} de pueblos | imprimiendo {n} pueblos",
"kindCardDeck": "Baraja de tarjetas",
"kindPrayerList": "Lista de oración",
"saved": "guardado",
"noRecords": "esa lista no tiene pueblos para imprimir",
"noPoster": "no hay un cartel disponible para esta lista",
"posterNotFound": "cartel no encontrado ({status})",
"posterBlank": "el cartel generado salió en blanco",
"noApi": "no hay un servicio de pueblos configurado",
"fetchFailed": "no se pudieron cargar los pueblos ({status})"
},
"posterAlt": "{label} — cartel de oración",
"posterAltAction": "{label} — cartel de oración (haz clic para ver e imprimir)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Affiches par pays — Listes de prière et affiches", "countryWallHeading": "Affiches par pays — Listes de prière et affiches",
"largerScreenNotice": "Cette carte expérimentale est conçue pour les grands écrans. Elle s’ouvre ici, mais certaines parties peuvent mal s’afficher ou mal fonctionner sur un téléphone.", "largerScreenNotice": "Cette carte expérimentale est conçue pour les grands écrans. Elle s’ouvre ici, mais certaines parties peuvent mal s’afficher ou mal fonctionner sur un téléphone.",
"desktopOnlyNotice": "{label} ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l’utiliser.", "desktopOnlyNotice": "{label} ne fonctionne que sur ordinateur : ouvrez cette page sur un ordinateur pour l’utiliser.",
"noticeDismiss": "Fermer" "noticeDismiss": "Fermer",
"countryPosterTooMany": "Trop de peuples pour tenir lisiblement sur une seule affiche — une solution est en préparation."
} }

View file

@ -27,5 +27,7 @@
"region_india": "Inde", "region_india": "Inde",
"region_other": "Autres", "region_other": "Autres",
"region_americas": "Amériques", "region_americas": "Amériques",
"region_europe": "Europe" "region_europe": "Europe",
"region_deaf": "Sourds",
"zone_deaf": "Groupes de personnes sourdes"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Aucune donnée de peuples pour {names} — sélectionnez plutôt le pays",
"nothingSelected": "Rien n’est sélectionné",
"selectionCardsFailed": "Échec des cartes de la sélection : {reason}",
"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"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} liste | {n} de listes | {n} listes", "listCount": "{n} liste | {n} de listes | {n} listes",
"stockCard": "Carte", "stockCard": "Carte",
"stockPostcard": "Carte postale", "stockPostcard": "Carte postale",
"stockLandscape": "paysage" "stockLandscape": "paysage",
"hud": {
"kindPoster": "Affiche",
"kindList": "Liste",
"kindPacket": "Dossier de prière",
"fetchingPoster": "récupération de l’affiche",
"generatingPoster": "génération de l’affiche",
"openingPrint": "ouverture de la boîte d’impression",
"printOpened": "boîte d’impression ouverte",
"saving": "enregistrement",
"fetchingRecords": "récupération des données",
"fetchingGroups": "récupération des peuples",
"layingOutRows": "mise en page de {n} ligne | mise en page de {n} de lignes | mise en page de {n} lignes",
"layingOutCards": "mise en page de {n} carte | mise en page de {n} de cartes | mise en page de {n} cartes",
"printingGroups": "impression de {n} peuple | impression de {n} de peuples | impression de {n} peuples",
"kindCardDeck": "Jeu de cartes",
"kindPrayerList": "Liste de prière",
"saved": "enregistré",
"noRecords": "cette liste ne contient aucun peuple à imprimer",
"noPoster": "aucune affiche n’est disponible pour cette liste",
"posterNotFound": "affiche introuvable ({status})",
"posterBlank": "l’affiche générée est sortie vide",
"noApi": "aucun service de peuples n’est configuré",
"fetchFailed": "impossible de charger les peuples ({status})"
},
"posterAlt": "{label} — affiche de prière",
"posterAltAction": "{label} — affiche de prière (cliquez pour prévisualiser et imprimer)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "देश पोस्टर — प्रार्थना सूचियाँ और पोस्टर", "countryWallHeading": "देश पोस्टर — प्रार्थना सूचियाँ और पोस्टर",
"largerScreenNotice": "यह प्रायोगिक नक्शा बड़ी स्क्रीन के लिए बना है। यह यहाँ खुलता है, पर फ़ोन पर इसके कुछ हिस्से ठीक से न दिखें या न चलें।", "largerScreenNotice": "यह प्रायोगिक नक्शा बड़ी स्क्रीन के लिए बना है। यह यहाँ खुलता है, पर फ़ोन पर इसके कुछ हिस्से ठीक से न दिखें या न चलें।",
"desktopOnlyNotice": "{label} केवल डेस्कटॉप पर चलता है — इसे इस्तेमाल करने के लिए यह पेज कंप्यूटर पर खोलें।", "desktopOnlyNotice": "{label} केवल डेस्कटॉप पर चलता है — इसे इस्तेमाल करने के लिए यह पेज कंप्यूटर पर खोलें।",
"noticeDismiss": "बंद करें" "noticeDismiss": "बंद करें",
"countryPosterTooMany": "एक पोस्टर पर साफ़-साफ़ आने के लिए जनसमूह बहुत अधिक हैं — समाधान पर काम चल रहा है।"
} }

View file

@ -27,5 +27,7 @@
"region_india": "भारत", "region_india": "भारत",
"region_other": "अन्य", "region_other": "अन्य",
"region_americas": "अमेरिका महाद्वीप", "region_americas": "अमेरिका महाद्वीप",
"region_europe": "यूरोप" "region_europe": "यूरोप",
"region_deaf": "बधिर",
"zone_deaf": "बधिर लोगों के समूह"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "{names} के लिए कोई जनसमूह डेटा नहीं है — इसके बजाय देश चुनें",
"nothingSelected": "कुछ भी चुना नहीं गया है",
"selectionCardsFailed": "चयन के कार्ड विफल: {reason}",
"selectionPosterNeedsZone": "पोस्टर लेजेंड में हर क्षेत्र के लिए हैं — मानचित्र पर चयन के लिए प्रार्थना सूची का उपयोग करें",
"mapDataFailed": "मानचित्र का डेटा लोड नहीं हो सका"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} सूची | {n} सूचियाँ", "listCount": "{n} सूची | {n} सूचियाँ",
"stockCard": "कार्ड", "stockCard": "कार्ड",
"stockPostcard": "पोस्टकार्ड", "stockPostcard": "पोस्टकार्ड",
"stockLandscape": "लैंडस्केप" "stockLandscape": "लैंडस्केप",
"hud": {
"kindPoster": "पोस्टर",
"kindList": "सूची",
"kindPacket": "प्रार्थना पैकेट",
"fetchingPoster": "पोस्टर लाया जा रहा है",
"generatingPoster": "पोस्टर बनाया जा रहा है",
"openingPrint": "प्रिंट विंडो खुल रही है",
"printOpened": "प्रिंट विंडो खुल गई",
"saving": "सहेजा जा रहा है",
"fetchingRecords": "डेटा लाया जा रहा है",
"fetchingGroups": "जनसमूह लाए जा रहे हैं",
"layingOutRows": "{n} पंक्ति सजाई जा रही है | {n} पंक्तियाँ सजाई जा रही हैं",
"layingOutCards": "{n} कार्ड सजाया जा रहा है | {n} कार्ड सजाए जा रहे हैं",
"printingGroups": "{n} जनसमूह छापा जा रहा है | {n} जनसमूह छापे जा रहे हैं",
"kindCardDeck": "कार्ड सेट",
"kindPrayerList": "प्रार्थना सूची",
"saved": "सहेजा गया",
"noRecords": "इस सूची में छापने के लिए कोई जनसमूह नहीं है",
"noPoster": "इस सूची के लिए कोई पोस्टर उपलब्ध नहीं है",
"posterNotFound": "पोस्टर नहीं मिला ({status})",
"posterBlank": "बनाया गया पोस्टर खाली निकला",
"noApi": "कोई जनसमूह सेवा कॉन्फ़िगर नहीं है",
"fetchFailed": "जनसमूह लोड नहीं हो सके ({status})"
},
"posterAlt": "{label} — प्रार्थना पोस्टर",
"posterAltAction": "{label} — प्रार्थना पोस्टर (देखने और छापने के लिए क्लिक करें)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Poster per paese — Liste di preghiera e manifesti", "countryWallHeading": "Poster per paese — Liste di preghiera e manifesti",
"largerScreenNotice": "Questa mappa sperimentale è pensata per schermi grandi. Si apre qui, ma alcune parti potrebbero non entrare o non funzionare bene su un telefono.", "largerScreenNotice": "Questa mappa sperimentale è pensata per schermi grandi. Si apre qui, ma alcune parti potrebbero non entrare o non funzionare bene su un telefono.",
"desktopOnlyNotice": "{label} funziona solo su desktop: apri questa pagina da un computer per usarlo.", "desktopOnlyNotice": "{label} funziona solo su desktop: apri questa pagina da un computer per usarlo.",
"noticeDismiss": "Chiudi" "noticeDismiss": "Chiudi",
"countryPosterTooMany": "Troppi popoli per stare in modo leggibile su un solo manifesto: una soluzione è in arrivo."
} }

View file

@ -27,5 +27,7 @@
"region_india": "India", "region_india": "India",
"region_other": "Altro", "region_other": "Altro",
"region_americas": "Americhe", "region_americas": "Americhe",
"region_europe": "Europa" "region_europe": "Europa",
"region_deaf": "Sordi",
"zone_deaf": "Gruppi di persone sorde"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Nessun dato sui popoli per {names}: seleziona invece il paese",
"nothingSelected": "Nessuna selezione",
"selectionCardsFailed": "Carte della selezione non riuscite: {reason}",
"selectionPosterNeedsZone": "I manifesti esistono per territorio nella legenda: usa Lista di preghiera per una selezione sulla mappa",
"mapDataFailed": "Impossibile caricare i dati della mappa"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} lista | {n} di liste | {n} liste", "listCount": "{n} lista | {n} di liste | {n} liste",
"stockCard": "Carta", "stockCard": "Carta",
"stockPostcard": "Cartolina", "stockPostcard": "Cartolina",
"stockLandscape": "orizzontale" "stockLandscape": "orizzontale",
"hud": {
"kindPoster": "Poster",
"kindList": "Lista",
"kindPacket": "Pacchetto di preghiera",
"fetchingPoster": "recupero del poster",
"generatingPoster": "generazione del poster",
"openingPrint": "apertura della finestra di stampa",
"printOpened": "finestra di stampa aperta",
"saving": "salvataggio",
"fetchingRecords": "recupero dei dati",
"fetchingGroups": "recupero dei popoli",
"layingOutRows": "impaginazione di {n} riga | impaginazione di {n} di righe | impaginazione di {n} righe",
"layingOutCards": "impaginazione di {n} carta | impaginazione di {n} di carte | impaginazione di {n} carte",
"printingGroups": "stampa di {n} popolo | stampa di {n} di popoli | stampa di {n} popoli",
"kindCardDeck": "Mazzo di carte",
"kindPrayerList": "Lista di preghiera",
"saved": "salvato",
"noRecords": "quell’elenco non ha popoli da stampare",
"noPoster": "nessun manifesto disponibile per questo elenco",
"posterNotFound": "manifesto non trovato ({status})",
"posterBlank": "il manifesto generato è risultato vuoto",
"noApi": "nessun servizio dei popoli è configurato",
"fetchFailed": "impossibile caricare i popoli ({status})"
},
"posterAlt": "{label} — poster di preghiera",
"posterAltAction": "{label} — poster di preghiera (fai clic per vedere e stampare)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Cartazes por país — Listas de oração e cartazes", "countryWallHeading": "Cartazes por país — Listas de oração e cartazes",
"largerScreenNotice": "Este mapa experimental foi feito para telas maiores. Ele abre aqui, mas algumas partes podem não caber ou não funcionar bem no celular.", "largerScreenNotice": "Este mapa experimental foi feito para telas maiores. Ele abre aqui, mas algumas partes podem não caber ou não funcionar bem no celular.",
"desktopOnlyNotice": "{label} só funciona no computador — abra esta página num computador para usá-lo.", "desktopOnlyNotice": "{label} só funciona no computador — abra esta página num computador para usá-lo.",
"noticeDismiss": "Fechar" "noticeDismiss": "Fechar",
"countryPosterTooMany": "Povos demais para caber de forma legível em um só cartaz — uma solução está a caminho."
} }

View file

@ -27,5 +27,7 @@
"region_india": "Índia", "region_india": "Índia",
"region_other": "Outros", "region_other": "Outros",
"region_americas": "Américas", "region_americas": "Américas",
"region_europe": "Europa" "region_europe": "Europa",
"region_deaf": "Surdos",
"zone_deaf": "Grupos de pessoas surdas"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Não há dados de povos para {names} — selecione o país",
"nothingSelected": "Nada está selecionado",
"selectionCardsFailed": "Falha ao gerar os cartões da seleção: {reason}",
"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"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} lista | {n} de listas | {n} listas", "listCount": "{n} lista | {n} de listas | {n} listas",
"stockCard": "Cartão", "stockCard": "Cartão",
"stockPostcard": "Cartão-postal", "stockPostcard": "Cartão-postal",
"stockLandscape": "paisagem" "stockLandscape": "paisagem",
"hud": {
"kindPoster": "Cartaz",
"kindList": "Lista",
"kindPacket": "Pacote de oração",
"fetchingPoster": "obtendo o cartaz",
"generatingPoster": "gerando o cartaz",
"openingPrint": "abrindo a caixa de impressão",
"printOpened": "caixa de impressão aberta",
"saving": "salvando",
"fetchingRecords": "obtendo registros",
"fetchingGroups": "obtendo povos",
"layingOutRows": "diagramando {n} linha | diagramando {n} de linhas | diagramando {n} linhas",
"layingOutCards": "diagramando {n} cartão | diagramando {n} de cartões | diagramando {n} cartões",
"printingGroups": "imprimindo {n} povo | imprimindo {n} de povos | imprimindo {n} povos",
"kindCardDeck": "Baralho de cartões",
"kindPrayerList": "Lista de oração",
"saved": "salvo",
"noRecords": "essa lista não tem povos para imprimir",
"noPoster": "não há cartaz disponível para esta lista",
"posterNotFound": "cartaz não encontrado ({status})",
"posterBlank": "o cartaz gerado saiu em branco",
"noApi": "nenhum serviço de povos está configurado",
"fetchFailed": "não foi possível carregar os povos ({status})"
},
"posterAlt": "{label} — cartaz de oração",
"posterAltAction": "{label} — cartaz de oração (clique para visualizar e imprimir)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Afișe pe țări — Liste de rugăciune și afișe", "countryWallHeading": "Afișe pe țări — Liste de rugăciune și afișe",
"largerScreenNotice": "Această hartă experimentală este făcută pentru ecrane mari. Se deschide aici, dar unele părți s-ar putea să nu încapă sau să nu funcționeze bine pe telefon.", "largerScreenNotice": "Această hartă experimentală este făcută pentru ecrane mari. Se deschide aici, dar unele părți s-ar putea să nu încapă sau să nu funcționeze bine pe telefon.",
"desktopOnlyNotice": "{label} funcționează doar pe desktop — deschideți pagina pe un computer pentru a-l folosi.", "desktopOnlyNotice": "{label} funcționează doar pe desktop — deschideți pagina pe un computer pentru a-l folosi.",
"noticeDismiss": "Închide" "noticeDismiss": "Închide",
"countryPosterTooMany": "Prea multe popoare pentru a încăpea lizibil pe un singur afiș — lucrăm la o soluție."
} }

View file

@ -27,5 +27,7 @@
"region_india": "India", "region_india": "India",
"region_other": "Altele", "region_other": "Altele",
"region_americas": "Americi", "region_americas": "Americi",
"region_europe": "Europa" "region_europe": "Europa",
"region_deaf": "Surzi",
"zone_deaf": "Grupuri de persoane surde"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Nu există date despre popoare pentru {names} — selectați țara",
"nothingSelected": "Nimic nu este selectat",
"selectionCardsFailed": "Cărțile selecției au eșuat: {reason}",
"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"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} listă | {n} liste | {n} de liste", "listCount": "{n} listă | {n} liste | {n} de liste",
"stockCard": "Card", "stockCard": "Card",
"stockPostcard": "Carte poștală", "stockPostcard": "Carte poștală",
"stockLandscape": "orizontal" "stockLandscape": "orizontal",
"hud": {
"kindPoster": "Afiș",
"kindList": "Listă",
"kindPacket": "Pachet de rugăciune",
"fetchingPoster": "se preia afișul",
"generatingPoster": "se generează afișul",
"openingPrint": "se deschide fereastra de tipărire",
"printOpened": "fereastra de tipărire s-a deschis",
"saving": "se salvează",
"fetchingRecords": "se preiau datele",
"fetchingGroups": "se preiau popoarele",
"layingOutRows": "se așază {n} rând | se așază {n} rânduri | se așază {n} de rânduri",
"layingOutCards": "se așază {n} carte | se așază {n} cărți | se așază {n} de cărți",
"printingGroups": "se tipărește {n} popor | se tipăresc {n} popoare | se tipăresc {n} de popoare",
"kindCardDeck": "Pachet de cartonașe",
"kindPrayerList": "Listă de rugăciune",
"saved": "salvat",
"noRecords": "lista nu conține popoare de tipărit",
"noPoster": "nu există un afiș disponibil pentru această listă",
"posterNotFound": "afișul nu a fost găsit ({status})",
"posterBlank": "afișul generat a ieșit gol",
"noApi": "niciun serviciu de popoare nu este configurat",
"fetchFailed": "popoarele nu au putut fi încărcate ({status})"
},
"posterAlt": "{label} — afiș de rugăciune",
"posterAltAction": "{label} — afiș de rugăciune (clic pentru previzualizare și tipărire)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "Плакаты по странам — молитвенные списки и плакаты", "countryWallHeading": "Плакаты по странам — молитвенные списки и плакаты",
"largerScreenNotice": "Эта экспериментальная карта рассчитана на большие экраны. Она откроется, но на телефоне часть элементов может не поместиться или работать плохо.", "largerScreenNotice": "Эта экспериментальная карта рассчитана на большие экраны. Она откроется, но на телефоне часть элементов может не поместиться или работать плохо.",
"desktopOnlyNotice": "{label} работает только на компьютере — откройте эту страницу на компьютере.", "desktopOnlyNotice": "{label} работает только на компьютере — откройте эту страницу на компьютере.",
"noticeDismiss": "Закрыть" "noticeDismiss": "Закрыть",
"countryPosterTooMany": "Слишком много народов, чтобы разборчиво уместить их на одном плакате, — решение готовится."
} }

View file

@ -27,5 +27,7 @@
"region_india": "Индия", "region_india": "Индия",
"region_other": "Другие", "region_other": "Другие",
"region_americas": "Америка", "region_americas": "Америка",
"region_europe": "Европа" "region_europe": "Европа",
"region_deaf": "Глухие",
"zone_deaf": "Группы глухих людей"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "Нет данных о народах для {names} — выберите страну",
"nothingSelected": "Ничего не выбрано",
"selectionCardsFailed": "Не удалось создать карточки выбора: {reason}",
"selectionPosterNeedsZone": "Плакаты есть для каждой территории в легенде — для выбора на карте используйте «Молитвенный список»",
"mapDataFailed": "Не удалось загрузить данные карты"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} список | {n} списка | {n} списков | {n} списка", "listCount": "{n} список | {n} списка | {n} списков | {n} списка",
"stockCard": "Карточка", "stockCard": "Карточка",
"stockPostcard": "Открытка", "stockPostcard": "Открытка",
"stockLandscape": "альбомная" "stockLandscape": "альбомная",
"hud": {
"kindPoster": "Плакат",
"kindList": "Список",
"kindPacket": "Молитвенный пакет",
"fetchingPoster": "загрузка плаката",
"generatingPoster": "создание плаката",
"openingPrint": "открытие окна печати",
"printOpened": "окно печати открыто",
"saving": "сохранение",
"fetchingRecords": "загрузка данных",
"fetchingGroups": "загрузка народов",
"layingOutRows": "раскладка {n} строки | раскладка {n} строк | раскладка {n} строк | раскладка {n} строки",
"layingOutCards": "раскладка {n} карточки | раскладка {n} карточек | раскладка {n} карточек | раскладка {n} карточки",
"printingGroups": "печать {n} народа | печать {n} народов | печать {n} народов | печать {n} народа",
"kindCardDeck": "Набор карточек",
"kindPrayerList": "Молитвенный список",
"saved": "сохранено",
"noRecords": "в этом списке нет народов для печати",
"noPoster": "для этого списка нет плаката",
"posterNotFound": "плакат не найден ({status})",
"posterBlank": "созданный плакат получился пустым",
"noApi": "сервис народов не настроен",
"fetchFailed": "не удалось загрузить народы ({status})"
},
"posterAlt": "{label} — молитвенный плакат",
"posterAltAction": "{label} — молитвенный плакат (нажмите, чтобы просмотреть и распечатать)"
} }

View file

@ -40,5 +40,6 @@
"countryWallHeading": "国家海报 — 祷告名单与海报", "countryWallHeading": "国家海报 — 祷告名单与海报",
"largerScreenNotice": "这张实验性地图为大屏幕设计。它可以在这里打开,但部分内容在手机上可能显示不全或无法正常使用。", "largerScreenNotice": "这张实验性地图为大屏幕设计。它可以在这里打开,但部分内容在手机上可能显示不全或无法正常使用。",
"desktopOnlyNotice": "{label} 仅支持桌面端——请在电脑上打开此页面使用。", "desktopOnlyNotice": "{label} 仅支持桌面端——请在电脑上打开此页面使用。",
"noticeDismiss": "关闭" "noticeDismiss": "关闭",
"countryPosterTooMany": "族群数量过多,无法清晰地放在一张海报上——我们正在制定解决方案。"
} }

View file

@ -27,5 +27,7 @@
"region_india": "印度", "region_india": "印度",
"region_other": "其他", "region_other": "其他",
"region_americas": "美洲", "region_americas": "美洲",
"region_europe": "欧洲" "region_europe": "欧洲",
"region_deaf": "聋人",
"zone_deaf": "聋人群体"
} }

View file

@ -0,0 +1,7 @@
{
"selectionAdm1NoData": "{names} 没有族群数据——请改为选择国家",
"nothingSelected": "未选择任何内容",
"selectionCardsFailed": "所选内容的卡片生成失败:{reason}",
"selectionPosterNeedsZone": "海报按图例中的区域提供——地图上的选择请使用“祷告名单”",
"mapDataFailed": "无法加载地图数据"
}

View file

@ -68,5 +68,31 @@
"listCount": "{n} 份名单", "listCount": "{n} 份名单",
"stockCard": "卡片", "stockCard": "卡片",
"stockPostcard": "明信片", "stockPostcard": "明信片",
"stockLandscape": "横向" "stockLandscape": "横向",
"hud": {
"kindPoster": "海报",
"kindList": "名单",
"kindPacket": "祷告手册",
"fetchingPoster": "正在获取海报",
"generatingPoster": "正在生成海报",
"openingPrint": "正在打开打印窗口",
"printOpened": "打印窗口已打开",
"saving": "正在保存",
"fetchingRecords": "正在获取数据",
"fetchingGroups": "正在获取族群",
"layingOutRows": "正在排版 {n} 行",
"layingOutCards": "正在排版 {n} 张卡片",
"printingGroups": "正在打印 {n} 个族群",
"kindCardDeck": "祷告卡组",
"kindPrayerList": "祷告名单",
"saved": "已保存",
"noRecords": "该名单中没有可打印的族群",
"noPoster": "此名单没有可用的海报",
"posterNotFound": "未找到海报({status})",
"posterBlank": "生成的海报为空白",
"noApi": "未配置族群服务",
"fetchFailed": "无法加载族群({status})"
},
"posterAlt": "{label} — 祷告海报",
"posterAltAction": "{label} — 祷告海报(点击预览并打印)"
} }

View file

@ -20,6 +20,7 @@
* countries costs one request, not 175. A failed fetch is not cached, so the next click retries. * countries costs one request, not 175. A failed fetch is not cached, so the next click retries.
*/ */
import { applyPeopleGroupList, resolvePeopleGroupList } from './peopleGroupList.js' import { applyPeopleGroupList, resolvePeopleGroupList } from './peopleGroupList.js'
import { keyedError } from '../i18n/keyedError.js'
// What the prints read: the card's face and language, the profile's description (imb_gsec feeds its // What the prints read: the card's face and language, the profile's description (imb_gsec feeds its
// reachedness sentence), the sets page's joins (slug), and the country for the page-1 map and captions. // reachedness sentence), the sets page's joins (slug), and the country for the page-1 map and captions.
@ -102,9 +103,9 @@ export function createPeopleGroupRecords({ apiBase, lang = () => 'en', profileCo
if (!cache.has(key)) { if (!cache.has(key)) {
const p = (async () => { const p = (async () => {
const base = typeof apiBase === 'function' ? apiBase() : apiBase const base = typeof apiBase === 'function' ? apiBase() : apiBase
if (!base) throw new Error('no people-groups API configured') if (!base) throw keyedError('wall.hud.noApi', 'no people-groups service is configured')
const resp = await doFetch(`${base}/api/people-groups/list?fields=${PRINT_FIELDS}&lang=${encodeURIComponent(lc)}`, { cache: 'no-store' }) const resp = await doFetch(`${base}/api/people-groups/list?fields=${PRINT_FIELDS}&lang=${encodeURIComponent(lc)}`, { cache: 'no-store' })
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`) if (!resp.ok) throw keyedError('wall.hud.fetchFailed', 'could not load the people groups ({status})', { status: resp.status })
const rows = extractRecords(await resp.json()) const rows = extractRecords(await resp.json())
const member = profileConfig ? await applyPeopleGroupList(rows, profileConfig) : rows const member = profileConfig ? await applyPeopleGroupList(rows, profileConfig) : rows
if (rows.length && !member.length) cache.delete(key) // a failed membership: retry next time if (rows.length && !member.length) cache.delete(key) // a failed membership: retry next time

View file

@ -112,7 +112,10 @@ const collapsedBy = (sec) => sec.total < 60
// The not-yet reason travels WITH the record, so the exact number the wall prints // The not-yet reason travels WITH the record, so the exact number the wall prints
// can never drift from the data. Brief: "it should say the exact number in our data." // can never drift from the data. Brief: "it should say the exact number in our data."
const unsupported = (c) => c.unsupportedReason || null // The reason is shown on the card and in the print HUD, so it speaks the reader's language: the data's
// unsupportedReason is an English note (and a flag), not display text. The card already states the
// count above it, so the sentence needs no number and no plural agreement.
const unsupported = (c) => (c.unsupportedReason ? t('messages.countryPosterTooMany') : null)
// ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read // ★ THROUGH THE CATALOGUE, like the UPG-100 wall: the heading, the tag, the intro and the counts read
// in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B) // in the page's language. The intro keeps its one emphasis as three plain keys (A, the emphasis, B)

View file

@ -89,6 +89,23 @@ function leafPopulation(row, records) {
// the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently. // the Deaf packet could not be printed from the UI: the zone lookup found nothing and returned silently.
export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' }; export const DEAF_SELECTOR = { type: 'affinity_bloc', rop1: 'A017' };
// The catalogue key (poster.<key>) that names each REGION_OF grouping in the reader's language. The
// English values stay the grouping identity (ids are derived from them), so the heading is translated
// at render: here for the legend via opts.regionTitle, and by poster-wall.vue for the wall's sections.
// One table for both surfaces, so a region added here cannot be translated on one and English on the other.
export const REGION_KEY = {
'Africa': 'region_africa',
'Middle East': 'region_middle_east',
'Central Asia': 'region_central_asia',
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
'China + Mongolia': 'region_china_mongolia',
'India': 'region_india',
'Other': 'region_other',
'Americas': 'region_americas',
'Europe': 'region_europe',
'Deaf': 'region_deaf',
};
export function buildLegendNodes(clusters, opts = {}) { export function buildLegendNodes(clusters, opts = {}) {
const deafCount = opts.deafCount ?? 123; const deafCount = opts.deafCount ?? 123;
const records = Array.isArray(opts.records) ? opts.records : null; const records = Array.isArray(opts.records) ? opts.records : null;
@ -102,6 +119,11 @@ export function buildLegendNodes(clusters, opts = {}) {
const titleOf = typeof opts.title === 'function' const titleOf = typeof opts.title === 'function'
? (zone, fallback) => opts.title(zone) || fallback ? (zone, fallback) => opts.title(zone) || fallback
: (_zone, fallback) => fallback; : (_zone, fallback) => fallback;
// `opts.regionTitle(english)` names a region section in the reader's language (see REGION_KEY); the
// node id stays derived from the English grouping key, so selection and ids never depend on a locale.
const regionTitleOf = typeof opts.regionTitle === 'function'
? (english) => opts.regionTitle(english) || english
: (english) => english;
const lists = clusters.lists || []; const lists = clusters.lists || [];
const asia = clusters.asia_lists || []; const asia = clusters.asia_lists || [];
// slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal, // slice 09b-6 §1(a): wire the 7 uncovered "Frontier Asia" zones (Nepal,
@ -172,7 +194,9 @@ export function buildLegendNodes(clusters, opts = {}) {
{ {
id: 'deaf', id: 'deaf',
zoneKey: 'deaf', zoneKey: 'deaf',
label: 'Deaf (affinity bloc A017)', // Named like every other leaf: opts.title reads the bloc's name from the strings it was given
// (zone-title.js, zone_affinity_A017); the English label is only the no-title fallback.
label: titleOf({ id: 'deaf', selector: DEAF_SELECTOR }, 'Deaf (affinity bloc A017)'),
color: colorOf('deaf'), color: colorOf('deaf'),
count: deafCount, count: deafCount,
info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).', info: 'Unified deaf row — affinity bloc rop1=A017 (collapses the two deaf_lists; zero-delta vs old deaf_predicate). Population double-counts vs geographic rows (design-approved, slice 08).',
@ -327,7 +351,7 @@ export function buildLegendNodes(clusters, opts = {}) {
// whole zones, while every surviving zone still looked populated. Fix the class, not the six // whole zones, while every surviving zone still looked populated. Fix the class, not the six
// spellings we happen to know. // spellings we happen to know.
id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'), id: 'region-' + label.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
label, label: regionTitleOf(label),
count: children.reduce((n, c) => n + (c.count || 0), 0), count: children.reduce((n, c) => n + (c.count || 0), 0),
children, children,
}; };

View file

@ -52,6 +52,12 @@ import { zonesForList } from './data/zones-for-list.js'
// /people-groups/list payload + a list's `selector` into the exact rows that belong to it. // /people-groups/list payload + a list's `selector` into the exact rows that belong to it.
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js' import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
import { makeZoneSheet } from './data/zone-sheet-print.js' import { makeZoneSheet } from './data/zone-sheet-print.js'
import { REGION_KEY } from './data/legend-tree.js'
import { keyedError } from '@map/i18n/keyedError.js'
// A zone's title in the reader's language (tier A: its country; others keep their label) — the same
// generator the map's legend and the packet use, so a card, its alt text and its print say one name.
import { zoneTitle } from './data/zone-title.js'
import { countryName } from '@map/utils/countryName.js'
import { evaluate as evaluateSelector } from './data/selector-evaluator.js' import { evaluate as evaluateSelector } from './data/selector-evaluator.js'
// The table the zone map sheets were generated from (generate.mjs labels each country with it), so a // The table the zone map sheets were generated from (generate.mjs labels each country with it), so a
@ -116,27 +122,9 @@ const REGION_OF = {
// 0 cards, with nothing wrong at the source layer. // 0 cards, with nothing wrong at the source layer.
// ★ THE ENGLISH VALUES ABOVE ARE GROUPING KEYS; THIS TRANSLATES AT RENDER (studio's split). // ★ THE ENGLISH VALUES ABOVE ARE GROUPING KEYS; THIS TRANSLATES AT RENDER (studio's split).
// REGION_OF keeps its English values as stable identity — changing them would re-key the // REGION_OF keeps its English values as stable identity — changing them would re-key the
// grouping — and the heading a reader sees is resolved here, per locale. // grouping — and the heading a reader sees is resolved here, per locale, through REGION_KEY
// // (data/legend-tree.js), the one table the legend's region rows translate through as well. Every
// ⚠ TWO OF THE SEVEN HEADINGS HAVE NO KEY YET: 'India' and 'Other'. check:hardcoded-english // heading has a poster.region_* key in all 11 locales, India and Other included.
// reported five, not seven, and it was honest about why — its header states it CANNOT SEE
// SINGLE-WORD STRINGS, since a bare word carries too many non-prose meanings to flag without
// drowning the signal. So the spec was built from five and these two are the blind spot
// materialising. They fall through to their English text until poster.region_india /
// poster.region_other exist; raised with studio rather than coined here.
const REGION_KEY = {
'Africa': 'region_africa',
'Middle East': 'region_middle_east',
'Central Asia': 'region_central_asia',
'Southeast Asia + Oceania': 'region_southeast_asia_oceania',
'China + Mongolia': 'region_china_mongolia',
'India': 'region_india',
'Other': 'region_other',
// Two regions the zone map uses had no key, so their headings printed in English in every locale
// (single words, which the hardcoded-English gate cannot see by design).
'Americas': 'region_americas',
'Europe': 'region_europe',
}
const regionLabel = (english) => { const regionLabel = (english) => {
const key = REGION_KEY[english] const key = REGION_KEY[english]
return key ? t(`poster.${key}`) : english // no key yet -> the English heading, not a raw key return key ? t(`poster.${key}`) : english // no key yet -> the English heading, not a raw key
@ -165,11 +153,17 @@ const regionFor = (id) => {
// this list IS, and the old comment was describing the wrong thing. // this list IS, and the old comment was describing the wrong thing.
const items = computed(() => { const items = computed(() => {
const z = zonesForList(resolvePeopleGroupList(profileConfig)) const z = zonesForList(resolvePeopleGroupList(profileConfig))
const locale = getRuntimeLocale() || detectLocale()
return [ return [
...(z.lists || []), ...(z.lists || []),
...(z.asia_lists || []), ...(z.asia_lists || []),
...(z.uncovered_lists || []), ...(z.uncovered_lists || []),
].filter(l => l && l.id) ].filter(l => l && l.id)
// ★ THE CARD'S NAME IN THE READER'S LANGUAGE. item.label is the Show-on-map button's accessible
// name, every poster's alt text and tooltip, the print HUD's title and the printed documents' title
// — all five were English in every locale. zoneTitle declines (null) for a zone it cannot name, and
// that zone keeps its hand-written label, exactly as on the legend and the packet.
.map(l => { const title = zoneTitle(l, null, { countryName, locale }); return title ? { ...l, label: title } : l })
}) })
// ─── THE RECORDS PROVIDER — now the library's (lists/peopleGroupRecords.js) ───────── // ─── THE RECORDS PROVIDER — now the library's (lists/peopleGroupRecords.js) ─────────
@ -185,7 +179,7 @@ const recordsFor = createPeopleGroupRecords({
evaluate: (selector, records) => evaluateSelector(selector, records), evaluate: (selector, records) => evaluateSelector(selector, records),
}) })
async function fetchListRecords(list) { async function fetchListRecords(list) {
if (!API_BASE) throw new Error('no API configured') if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured')
return recordsFor(list.selector) return recordsFor(list.selector)
} }

View file

@ -104,7 +104,14 @@ import { useI18n } from 'vue-i18n'
// is blockable and drops the user out of the map; the iframe opens the print dialog // is blockable and drops the user out of the map; the iframe opens the print dialog
// right here. Shared so the packet, the poster and the card deck all print the same way. // right here. Shared so the packet, the poster and the card deck all print the same way.
import { usePosterPrints } from '@map/composables/usePosterPrints.js' import { usePosterPrints } from '@map/composables/usePosterPrints.js'
import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR } from './data/legend-tree.js' import { buildLegendNodes, leafZones, zonesWithSelectors, DEAF_SELECTOR, REGION_KEY } from './data/legend-tree.js'
// Legend rows named in the reader's language: tier-A zones by their country (zone-title.js), the Deaf
// row by its bloc name. Both modules are already in this bundle for the packet's localizedZone().
import { zoneTitle, countryLabelIndex } from './data/zone-title.js'
import { countryName } from '@map/utils/countryName.js'
// Errors a reader sees carry their catalogue key; errorText() shows them in the reader's language.
// Strings only this map shows live in their own area, upgMap.json, so no other bundle carries them.
import { keyedError, errorText } from '@map/i18n/keyedError.js'
import { resolveAllZones } from './data/zone-geometry.js' import { resolveAllZones } from './data/zone-geometry.js'
// slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json // slice 09b-9 §3 (L18/L29 FIX): the per-zone color OVERRIDE map. zone-colors.json
// holds the region-inherited tapestry (purple ME, red Africa, green // holds the region-inherited tapestry (purple ME, red Africa, green
@ -190,7 +197,9 @@ const GEOCODER_SOURCE = 'upg-search'
const uiStore = inject('uiStore', null) const uiStore = inject('uiStore', null)
const mapEl = ref(null) const mapEl = ref(null)
const status = ref('loading…') // Set to the translated "Loading data…" as soon as t() exists (below useI18n) — before first render,
// so no reader ever sees an English placeholder while the map engine downloads.
const status = ref('')
// P3-1: the MAP CELL's own error. Distinct from `status` (the small bottom-right // P3-1: the MAP CELL's own error. Distinct from `status` (the small bottom-right
// telemetry badge): this is the user-facing "the map is unavailable" notice drawn // telemetry badge): this is the user-facing "the map is unavailable" notice drawn
// INSIDE the map cell, so a tile/token/WebGL failure degrades to a missing map // INSIDE the map cell, so a tile/token/WebGL failure degrades to a missing map
@ -403,6 +412,25 @@ const API_BASE = (typeof window !== 'undefined' && window.MAP_APP_API_URL)
// so the dialog opens in this tab rather than a popup a browser may block. The // so the dialog opens in this tab rather than a popup a browser may block. The
// packet's locale follows the app's, so a reader gets it in their own language. // packet's locale follows the app's, so a reader gets it in their own language.
const { locale: packetLocale, t } = useI18n() const { locale: packetLocale, t } = useI18n()
status.value = t('legend.loadingData')
// ★ THE LEGEND SPEAKS THE READER'S LANGUAGE. Its rows were clusters.json's English labels and its
// section rows the English grouping keys, while the packet printed from the same row was localized
// (localizedZone): السودان on paper, 'Sudan' on screen. Region sections go through REGION_KEY (the
// table the wall uses too); tier-A zones through zoneTitle, preferring the records' own localized
// country_code_label once they are loaded; the Deaf row through its bloc name. A zone the generator
// declines (tier B/C) keeps its hand-written label, as on every other surface.
// A function, not a const: DEAF_COUNT and packetLang are declared further down and are read per call.
function legendOpts(records) {
const strings = { zone_affinity_A017: t('poster.zone_deaf') }
const byCode = records ? countryLabelIndex(records) : undefined
return {
deafCount: DEAF_COUNT,
...(records ? { records } : {}),
title: (zone) => zoneTitle(zone, strings, { byCode, countryName, locale: packetLang() }),
regionTitle: (english) => (REGION_KEY[english] ? t(`poster.${REGION_KEY[english]}`) : english),
}
}
// ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW — // ⚠ IT WAS A HARDCODED ENGLISH LITERAL, and SemanticTreeLegend passes `title` through RAW —
// the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend // the caller owns localizing it. MEASURED 2026-09-25 on the live Chinese render: the legend
// header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel, // header read "My UPG 100 List — Legend" in full English inside an otherwise localized panel,
@ -1371,6 +1399,8 @@ async function onLegendExport(kind) {
// (the people-groups API, the selector evaluator, and the pg_slugs special case // (the people-groups API, the selector evaluator, and the pg_slugs special case
// that 12 of the 27 lists need), and where this bundle's posters are served from. // that 12 of the 27 lists need), and where this bundle's posters are served from.
const packetPrints = usePosterPrints({ const packetPrints = usePosterPrints({
// The print HUD's messages in the reader's language (vue-i18n's t takes the count for plurals).
t: (key, params, n) => (typeof n === 'number' ? t(key, params, n) : t(key, params)),
records: (zone) => packetRecords(zone), records: (zone) => packetRecords(zone),
lang: () => packetLang(), lang: () => packetLang(),
label: (zone) => zone?.label || zone?.id || '', label: (zone) => zone?.label || zone?.id || '',
@ -1399,13 +1429,20 @@ const packetPrints = usePosterPrints({
// that and displays `status` instead. Without this watcher the packet would print // that and displays `status` instead. Without this watcher the packet would print
// SILENTLY — no "building…", and a failure would vanish with nothing said — which // SILENTLY — no "building…", and a failure would vanish with nothing said — which
// is the mechanism working while the feedback does not. // is the mechanism working while the feedback does not.
// ⚠ CLEARED BY IDENTITY, NOT BY READING THE PROSE. This used to clear the status when it matched /packet/i —
// true of the English message only. With the messages now translated (the print composable localizes them
// through `t`), that test would never match in ten languages and the status would stick. So the line this
// watcher wrote is remembered and cleared exactly — the same rule flashRef follows above.
let printStatus = ''
watch(packetPrints.exportState, (st) => { watch(packetPrints.exportState, (st) => {
if (!st) { if (/packet/i.test(status.value)) status.value = ''; return; } if (!st) { if (printStatus && status.value === printStatus) status.value = ''; printStatus = ''; return; }
if (st.phase === 'error') { if (st.phase === 'error') {
printStatus = '';
flashStatus(t('messages.packetFailed', { reason: st.message }), 4000); flashStatus(t('messages.packetFailed', { reason: st.message }), 4000);
return; return;
} }
status.value = st.phase === 'done' ? '' : `${st.message}…`; printStatus = st.phase === 'done' ? '' : `${st.message}…`;
status.value = printStatus;
}); });
// BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator — // BUNDLE-SPECIFIC: who is in this list. ⚠ pg_slugs BYPASSES the shared evaluator —
@ -1424,7 +1461,7 @@ const recordsFor = createPeopleGroupRecords({
evaluate: (selector, records) => evaluateSelector(selector, records), evaluate: (selector, records) => evaluateSelector(selector, records),
}); });
async function packetRecords(zone) { async function packetRecords(zone) {
if (!API_BASE) throw new Error('no API configured'); if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
return recordsFor(zone.selector); return recordsFor(zone.selector);
} }
// The zone's sheet, generated at the click, for the legend's Poster and the packet's page 1. // The zone's sheet, generated at the click, for the legend's Poster and the packet's page 1.
@ -1701,10 +1738,10 @@ async function selectionAsList() {
if (!lists.length && !codes.length) { if (!lists.length && !codes.length) {
// Name WHY when the only thing selected is a province — "nothing selected" would be a lie the // 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, with their selection visibly highlighted on the map.
if (adm1.length) throw new Error(`no people-group data for ${adm1.join(', ')} — select the country instead`); if (adm1.length) throw keyedError('upgMap.selectionAdm1NoData', 'no people-group data for {names} — select the country instead', { names: adm1.join(', ') });
throw new Error('nothing selected'); throw keyedError('upgMap.nothingSelected', 'nothing is selected');
} }
if (!API_BASE) throw new Error('no API configured'); if (!API_BASE) throw keyedError('wall.hud.noApi', 'no people-groups service is configured');
// ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (Driver brief A1.5 col 2, // ★ primary_language + imb_gsec FEED THE GENERATED PROFILE DESCRIPTION (Driver brief A1.5 col 2,
// 2026-09-28). describeGroup builds a localized sentence — who, where, what language, how reached — // 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 // and GSEC is the only reachedness field that is a NUMBER: EvangelicalLevel is English prose from the
@ -1722,7 +1759,7 @@ async function selectionAsList() {
const fields = PRINT_FIELDS; // the print standard's one field list (lists/peopleGroupRecords.js) 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()}`, const resp = await fetch(`${API_BASE}/api/people-groups/list?fields=${fields}&lang=${packetLang()}`,
{ cache: 'no-store' }); { cache: 'no-store' });
if (!resp.ok) throw new Error(`people-groups fetch failed (${resp.status})`); 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 = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig));
const { evaluate } = await import('./data/selector-evaluator.js'); const { evaluate } = await import('./data/selector-evaluator.js');
const slugs = new Set(); const slugs = new Set();
@ -1788,7 +1825,7 @@ async function makeSelectionPacket() {
funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null), funnel: funnel && lists.length === 1 ? funnel : (funnel ? { ...funnel, listCount: 0 } : null),
}); });
} catch (e) { } catch (e) {
flashStatus(t('messages.selectionPacketFailed', { reason: String(e?.message || e).slice(0, 90) }), 4000); flashStatus(t('messages.selectionPacketFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
} finally { batchBusy.value = false; busyAction.value = ''; } } finally { batchBusy.value = false; busyAction.value = ''; }
} }
@ -1801,7 +1838,7 @@ async function makeSelectionCards() {
try { try {
await packetPrints.printCardDeck(await selectionAsList()); await packetPrints.printCardDeck(await selectionAsList());
} catch (e) { } catch (e) {
flashStatus(e?.message || String(e), 4000); flashStatus(t('upgMap.selectionCardsFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
} finally { } finally {
batchBusy.value = false; batchBusy.value = false;
busyAction.value = ''; busyAction.value = '';
@ -1820,9 +1857,9 @@ async function makeSelectionPoster() {
// selection on the map. The packet is the thing that works for a country selection, so point at it. // selection on the map. The packet is the thing that works for a country selection, so point at it.
if (!lists.length) { if (!lists.length) {
if (selectedCountryCodes().length || selectedAdm1Names().length) { if (selectedCountryCodes().length || selectedAdm1Names().length) {
throw new Error('posters exist per territory on the legend — use Prayer list for a map selection'); throw keyedError('upgMap.selectionPosterNeedsZone', 'posters exist per territory on the legend — use Prayer list for a map selection');
} }
throw new Error('nothing selected'); throw keyedError('upgMap.nothingSelected', 'nothing is selected');
} }
// ⚠ A POSTER IS PRE-RENDERED PER ZONE — there is no artwork for an arbitrary union, and // ⚠ A POSTER IS PRE-RENDERED PER ZONE — there is no artwork for an arbitrary union, and
// generating one would be the new pipeline the brief rules out. With one zone selected this // generating one would be the new pipeline the brief rules out. With one zone selected this
@ -1834,7 +1871,7 @@ async function makeSelectionPoster() {
} }
await packetPrints.printPoster(lists[0]); await packetPrints.printPoster(lists[0]);
} catch (e) { } catch (e) {
flashStatus(t('messages.selectionPosterFailed', { reason: String(e?.message || e).slice(0, 90) }), 4000); flashStatus(t('messages.selectionPosterFailed', { reason: errorText(e, t).slice(0, 90) }), 4000);
} finally { batchBusy.value = false; busyAction.value = ''; } } finally { batchBusy.value = false; busyAction.value = ''; }
} }
@ -2120,7 +2157,7 @@ function bootstrapLegend() {
// the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by // the two would disagree with nothing reporting it. Found by grepping for `clusters.` rather than by
// trusting the convention comment. // trusting the convention comment.
const z = listZones() const z = listZones()
const nodes = buildLegendNodes(z, { deafCount: DEAF_COUNT }) const nodes = buildLegendNodes(z, legendOpts(null))
legendNodes.value = nodes legendNodes.value = nodes
leafZoneList = leafZones(nodes) leafZoneList = leafZones(nodes)
const byId = {} const byId = {}
@ -2151,7 +2188,7 @@ async function enrichLegendFromRecords() {
if (!resp.ok) throw new Error(`list ${resp.status}`) if (!resp.ok) throw new Error(`list ${resp.status}`)
const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig)) const records = normalizeRecords(await applyPeopleGroupList(extractRecords(await resp.json()), profileConfig))
countryNameMap = buildCountryNameMap(records) countryNameMap = buildCountryNameMap(records)
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records }) legendNodes.value = buildLegendNodes(listZones(), legendOpts(records))
} catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ } } catch (_) { /* counts-only legend is the honest fallback; keep it rendered */ }
} }
@ -2373,7 +2410,7 @@ onMounted(async () => {
toggleLegendRow: (label) => { toggleLegendRow: (label) => {
const find = (nodes) => { const find = (nodes) => {
for (const n of nodes) { for (const n of nodes) {
if (n.label === label) return n if (n.label === label || n.id === label) return n
if (n.children && n.children.length) { if (n.children && n.children.length) {
const hit = find(n.children); if (hit) return hit const hit = find(n.children); if (hit) return hit
} }
@ -2416,7 +2453,7 @@ onMounted(async () => {
zoneKeysForRow: (label) => { zoneKeysForRow: (label) => {
const find = (nodes) => { const find = (nodes) => {
for (const n of nodes) { for (const n of nodes) {
if (n.label === label) return n if (n.label === label || n.id === label) return n
if (n.children && n.children.length) { const h = find(n.children); if (h) return h } if (n.children && n.children.length) { const h = find(n.children); if (h) return h }
} }
return null return null
@ -2514,7 +2551,7 @@ onMounted(async () => {
// Re-shape the legend tree WITH records so every node carries `pop` (slice // Re-shape the legend tree WITH records so every node carries `pop` (slice
// 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added. // 04u §1b). Selectors/colors/counts are unchanged; only `pop` is added.
legendNodes.value = buildLegendNodes(listZones(), { deafCount: DEAF_COUNT, records }) legendNodes.value = buildLegendNodes(listZones(), legendOpts(records))
// ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ── // ── pins layer (kept from 04b, now stamped with zoneKey for isolate dim) ──
@ -2738,7 +2775,10 @@ onMounted(async () => {
const resolved = paintable.filter(([, n]) => n >= 1).length const resolved = paintable.filter(([, n]) => n >= 1).length
status.value = t('messages.mapStatusCounts', { polys: tapestry.features.length, zones: resolved, total: paintable.length, pins: pinFeatures.length }) status.value = t('messages.mapStatusCounts', { polys: tapestry.features.length, zones: resolved, total: paintable.length, pins: pinFeatures.length })
} catch (e) { } catch (e) {
status.value = '⚠ ' + (e && e.message ? e.message : String(e)) // The reader gets a sentence in their language; the cause (a boundary 404, a missing API base —
// developer configuration text) goes to the console, where the person who can fix it looks.
console.warn('[upg-100-list] map data failed:', e)
status.value = '⚠ ' + t('upgMap.mapDataFailed')
} }
// R1.3 (j): once the legend tree paints its rows, start the MutationObserver // R1.3 (j): once the legend tree paints its rows, start the MutationObserver