doxa-maps/library/composables/useCsvExport.js
Doxa Maps 9b30128c68
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 1b397dc3
2026-09-27 19:24:03 +00:00

310 lines
20 KiB
JavaScript

/**
* useCsvExport.js — ★ SHARED LIBRARY CODE (dashboard-kit landing 3). Two parameters make it
* generic: `exportLabel` (what the file calls itself) and `sources` (who to credit), both
* defaulting to what research-dashboard shipped. Everything else is unchanged.
*
* useCsvExport.js — RDASH phase 4: export-any-selection to CSV (native Blob, 0-dep).
*
* Priority model (plan §INTERACTION): selectionSet (explicit picks) > filterSet result (in scope) >
* full dataset. Serializes the FULL stored record per row (flattening {value,label} -> label),
* with a '#' provenance header line of the active chips, RFC-ish quoting, and CSV-injection
* neutralization (leading = + - @ prefixed with a single quote). Shadow-DOM is a non-issue —
* export touches no rendered DOM/styles.
*
* ── ALL-FIELDS EXPORT (card BUILD-CSV-EXPORT-MOVE-TO-TABLE-HEADER-ALL-FIELDS) ──
* The export must carry EVERY field the API holds, not just the 8 table columns. The limit was
* never in this file — flatten() already walks Object.keys(record). It was UPSTREAM: the store
* loads `?fields=<sources.json defaultFields>` (24 keys, ~2.3MB) because that is all the map /
* table / charts render. The detail endpoint exposes 67.
*
* So the wide record is fetched LAZILY, here, on the first export click — not at mount:
* - widening sources.json defaultFields would triple the payload (2.3MB -> 6.8MB) for EVERY
* bundle that shares that file, to serve one button;
* - the list endpoint honors an explicit `fields=` list, so one request returns all 67 keys
* for all rows (verified live: 2,107 rows x 67 keys).
* The result is cached for the session; if the fetch fails we still export the loaded record
* (24 fields) rather than producing nothing — degraded, never broken.
*/
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { inject } from 'vue'
import sourcesConfig from '@map/api/sources.json'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
/**
* The full field set for the export, in a stable, human-readable order: identity first, then
* place, people/language/religion, then the engagement + IMB research columns. Sourced from the
* live /detail record shape (67 keys); `exportFields` in sources.json overrides it per-source.
*/
export const EXPORT_FIELDS = [
// identity
'slug', 'name', 'imb_alternate_name', 'doxa_wagf_uid', 'imb_uid', 'imb_peid', 'imb_pgid',
'joshua_project_id', 'imb_affinity_code',
// place
'latitude', 'longitude', 'country_code', 'region', 'wagf_region', 'wagf_block', 'wagf_member',
'imb_subregion', 'location_description',
// people / language
'rop1', 'imb_reg_of_people_2', 'imb_reg_of_people_25', 'imb_reg_of_people_3',
'primary_language', 'imb_language_family', 'imb_language_class', 'imb_language_speakers',
'work_in_local_language', 'imb_is_indigenous',
// religion
'religion', 'imb_reg_of_religion_3', 'imb_reg_of_religion_4',
// population / believers
'population', 'imb_population_class', 'believers_count', 'evangelical_pct',
'imb_evangelical_level', 'imb_congregation_existing',
// prayer + engagement
'people_praying', 'people_committed', 'committed_duration', 'adopted_by_churches',
'adopted_by_count', 'adopted_by_names', 'engagement_status', 'status', 'reason_engaged',
'reason_unlisted', 'global_start_date', 'workers_long_term', 'imb_church_planting',
'disciple_and_church_multiplication',
// scripture + media resources
'imb_bible_available', 'imb_bible_translation_level', 'imb_bible_year_published',
'imb_bible_stories_available', 'imb_audio_scripture_available', 'imb_gospel_recordings_available',
'imb_jesus_film_available', 'imb_radio_broadcast_available', 'imb_total_resources_available',
// priority indices
'imb_gsec', 'imb_lostness_priority_index', 'imb_strategic_priority_index',
// media / description
'has_photo', 'image_url', 'picture_credit', 'imb_people_description',
]
/**
* @param {(facetKey:string, value:*) => *} [labelFor] display-only projection for the provenance
* stamp. The People facets key by CODE (dashboard.config.js:54-57), so without it the header
* reads `affinity_bloc=A012` — a code on a user-facing surface, the same bug the filter chip had.
* Optional: omitted, the stamp falls back to the raw value, so existing callers keep working.
* It is applied to the COMMENT LINE ONLY — never to a data column (see buildCsv).
*/
export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_ACC, dataOpts = {}, labelFor = null,
// ★ THE TYPED SEARCH NARROWS THE EXPORT TOO. Optional rows->rows transform, so a caller that does not
// pass it behaves exactly as before. Without it the export ignored the search box completely: with no
// facet filter and no selection, pickRows() fell through to EVERY row, so a reader who typed "Bhutan"
// and saw 13 rows downloaded all 2,107. Not applied to an explicit selection — picking rows by hand
// outranks a text filter, which is the documented priority model, and the reader can see what they picked.
searchFilter = null,
// What this export calls itself, and who to credit for the data. Defaults keep every existing
// caller's file identical to the byte.
exportLabel = 'Research Dashboard', sources = '',
// ★ FOUR OPTIONS ADDED BY THE KIT'S FIRST REAL CONSUMER (go-int, 2026-09-27). Each one is a
// pray-tools assumption this file was making silently; each default is exactly what
// research-dashboard already did, so Big Picture's file is unchanged to the byte. A second
// consumer is the only thing that finds these — I could not have told you they were assumptions
// while there was one caller.
//
// columns: the EXACT column set and order to export. Default = today's behaviour (the canonical
// EXPORT_FIELDS order, then any other key the API returned). go-int's spec asks for the VISIBLE
// columns, and projecting rows before handing them over — its workaround — loses the ordering
// this file exists to impose.
columns = null,
// wide: fetch the ALL-FIELDS record before writing. Default true = today's behaviour. A
// static-table dashboard has nothing to widen FROM, so the fetch is a wasted network call that
// would also pollute its rows with pray-tools fields.
wide = true,
// filename / hookPrefix: what the downloaded file is called, and the window hooks a browser gate
// reads. Defaults are the existing literals, and that matters — __rdash* names are read
// elsewhere in this tree, so changing the default would break checks that have nothing to do
// with this landing.
filename = 'research-dashboard.csv', hookPrefix = '__rdash',
// keyOf: how a row identifies itself, for matching a narrow row to its wide twin and for reading
// the selection. Default = `slug || id`, which is what this file assumed. config.data.primaryKey
// is the config-level spelling of the same thing; go-int flagged the pair, and they should agree.
keyOf = (r) => r && (r.slug || r.id),
// Defaulted so a caller that predates D1 keeps working exactly as before rather than throwing.
// ★ THE DEFAULT IS ARRAY-AWARE, because it is the LIVE path, not a legacy shim. A filterSet value is
// an array once two values are selected (spec D1), and `a(r) === ['Hinduism','Islam']` is false for
// every row — so the export silently produced a file matching NOTHING the moment a reader picked a
// second value. Measured: DashboardDataTable and DashboardDetailDrawer both rely on this default
// (only useDashboardShell passes its own), so two of the three live callers exported an empty set.
// OR within a key, the same rule bus.rowMatchesFilters applies, so the CSV agrees with the screen.
// Single-value behaviour is unchanged: one value takes exactly the path it always did.
rowMatches = (r, ACC) => Object.keys(filterSet).every((k) => {
const a = ACC[k]
if (!a) return true
const want = filterSet[k]
return Array.isArray(want) ? want.some((v) => a(r) === v) : a(r) === want
}) }) {
// slug -> wide API record, filled by the lazy all-fields fetch below.
let wideStore = null
let widePromise = null
// The map's people-group LIST (library/lists/). Every caller runs in setup, where inject works.
let profileConfig = dataOpts.profileConfig || null
if (!profileConfig) { try { profileConfig = inject('profileConfig', null) } catch (_) { /* outside setup */ } }
/**
* Fetch the FULL field set for every row, once per session. Same endpoint + base-URL
* resolution the row store uses (shared sources.json / getApiBaseUrl), only with an explicit
* `fields=` covering all of EXPORT_FIELDS instead of the render-sized defaultFields.
*/
function fetchWideRows() {
// Nothing to widen FROM without a source, and nothing asked for when wide is off. Returning a
// resolved null rather than throwing keeps exportCsvAsync's single call site unchanged.
if (!wide || !(dataOpts && dataOpts.source)) return Promise.resolve(null)
if (widePromise) return widePromise
widePromise = (async () => {
const sourceId = dataOpts.source || sourcesConfig.activeSource
const src = sourcesConfig?.sources?.[sourceId] || {}
const fields = (Array.isArray(src.exportFields) && src.exportFields.length) ? src.exportFields : EXPORT_FIELDS
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
const lang = src.queryParams?.lang || 'en'
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
const res = await fetch(url, { cache: 'no-store' })
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
const json = await res.json()
// Only the groups of this page's people-group LIST (library/lists/) are exported.
const raw = await applyPeopleGroupList(Array.isArray(json) ? json : (json.posts || json.data || json.results || []), profileConfig)
const map = new Map()
for (const r of raw) { const k = keyOf(r); if (k) map.set(k, r) }
wideStore = map
return map
})().catch((e) => { widePromise = null; throw e }) // let a later click retry
return widePromise
}
// Swap a loaded (narrow) row for its wide twin when the all-fields fetch has landed.
function widen(r) {
if (!wideStore) return r
const w = wideStore.get(keyOf(r))
return w ? { ...r, ...w } : r
}
function pickRows() {
const narrow = (list) => (typeof searchFilter === 'function' ? searchFilter(list) : list)
if (selectionSet && selectionSet.size) {
return [...selectionSet].map((s) => rowStore.value.get(s)).filter(Boolean)
}
const keys = Object.keys(filterSet)
if (keys.length) {
// The bus's own predicate, handed in by the shell — the exporter must select the same rows
// the reader is looking at, and a private copy of the rule is how an export starts
// disagreeing with the screen it was taken from.
return narrow((rows.value || []).filter((r) => rowMatches(r, FACET_ACC)))
}
return narrow(rows.value || [])
}
function scopeLabel() {
if (selectionSet && selectionSet.size) return `selection (${selectionSet.size})`
return Object.keys(filterSet).length ? 'filter result' : 'all'
}
// {value,label} -> label; arrays -> ' | '-joined cell (picture_credit / adopted_by_names arrive as
// arrays of objects, so a bare JSON.stringify would dump unreadable braces into the sheet).
function cell(v) {
if (v === null || v === undefined) return v
if (Array.isArray(v)) return v.map((x) => cell(x)).filter((x) => x !== '' && x !== null && x !== undefined).join(' | ')
// `link` last: a picture_credit entry can be {link,text} with a null text — fall through to the
// URL rather than dumping raw JSON braces into the cell.
if (typeof v === 'object') return v.label ?? v.value ?? v.text ?? v.name ?? v.link ?? JSON.stringify(v)
return v
}
function flatten(r) {
const o = {}
for (const k of Object.keys(r)) o[k] = cell(r[k])
return o
}
// Quote if the field has , " CR LF; double internal quotes; neutralize leading = + - @ (CSV injection).
function esc(v) {
let s = v === null || v === undefined ? '' : String(v)
if (/^[=+\-@]/.test(s)) s = "'" + s
if (/[",\n\r]/.test(s)) s = '"' + s.replace(/"/g, '""') + '"'
return s
}
function buildCsv() {
const picked = pickRows().map(widen).map(flatten)
// Column order: the canonical EXPORT_FIELDS order first (so the sheet reads identity → place →
// people → engagement → resources), then any key the API returned that the list doesn't name.
// Union across rows, because a null-valued field can be absent on an individual record.
// An explicit `columns` is taken AS GIVEN — order included, and without filtering to keys that
// happen to be present. A column the reader can see must appear in the file even when every row
// in this particular scope has it empty; dropping it would make the export's shape depend on the
// filter, which is the opposite of what a column picker promises.
const present = picked.reduce((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set }, new Set())
const cols = Array.isArray(columns) && columns.length
? columns.slice()
: [...EXPORT_FIELDS.filter((k) => present.has(k)), ...[...present].filter((k) => !EXPORT_FIELDS.includes(k))]
// Provenance stamp — a HUMAN-READABLE COMMENT line, so People codes are resolved to names
// here exactly as the filter chips do. This is the ONLY place labelFor() is applied: the data
// columns below are built from the API record via flatten()/cell() and are untouched, because
// a consumer's formulas and downstream joins key on whatever those columns already emit.
const chips = Object.entries(filterSet)
.map(([k, v]) => `${k}=${labelFor ? labelFor(k, v) : v}`)
.join(' | ') || 'none'
// ★ THE PROVENANCE LINE IS PARAMETERISED (dashboard-kit landing 3), and it is the one piece of
// this exporter that was never generic. "Research Dashboard" is this dashboard's name, and the
// line said nothing about WHERE the data came from — which is the thing a provenance line is for.
// A partner opening a CSV can see the scope and the filters and still not know who to cite.
// `sources` is appended only when a caller supplies it, so today's output is byte-for-byte
// unchanged; go-int's spec asks for exactly this (its tables carry their own meta.sources).
const provenance = `# ${exportLabel} export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
+ (sources ? `; sources: ${sources}` : '')
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
// the agent-reported 2026-09-22: "this is me exporting the CSV directly without making any edits...
// it does look a little bit glitched out."
//
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
// and must not be "fixed" on suspicion:
// line 0 ... 1 column "# Research Dashboard export — scope: ..."
// line 1 ... N columns slug,name,religion,...
// rows ... N columns each, ZERO with a wrong column count
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
// file is comma-separated, not tab-separated — the tabs in the agent's paste were his terminal
// rendering, not the bytes.
//
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.
// So the scope sentence became the column names, the real header became data row 1, and a 1-column
// line above a 68-column table is exactly what "glitched" looks like on screen.
//
// Moving it BELOW the data keeps the provenance — which is deliberate and load-bearing, since it
// records WHICH FILTERS produced the file, the difference between a dataset and an anecdote —
// while making line 1 the header. A trailing 1-column line lands as a final row with one populated
// cell: obvious to a human, harmless to a formula, and it cannot destroy the column mapping.
const lines = [cols.map(esc).join(','), ...picked.map((r) => cols.map((c) => esc(r[c])).join(',')), provenance]
return lines.join('\r\n')
}
// Last-export status for UI feedback (toast). The platform gem-frame iframe is sandboxed WITHOUT
// `allow-downloads`, so the native <a download> is silently blocked there — CLIPBOARD is the robust
// primary path (the iframe `allow`s clipboard-write); native download still works outside a sandbox.
const lastExport = { rows: 0, clipboard: false, downloaded: false, at: 0, fields: 0, wide: false }
function exportCsv() {
const csv = buildCsv()
const rows = pickRows().length
// The header is now line 1 — the '#' provenance comment moved to the END of the file so that a
// spreadsheet reads the real column row first (see buildCsv). This index had to move WITH it:
// reading line 2 would have counted the columns of DATA ROW 1, which is usually the same number,
// so it would have looked correct and been wrong — the quiet kind of breakage a positional change
// leaves behind.
const fields = (csv.split('\r\n')[0] || '').split(',').length
try { if (typeof window !== 'undefined') window[hookPrefix + 'LastCsv'] = csv } catch (_) { /* verify hook */ }
let clipboard = false, downloaded = false
// 1) clipboard — works inside the sandboxed gem-frame iframe (the guaranteed path)
try { if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(csv).catch(() => {}); clipboard = true } } catch (_) { /* no clipboard */ }
// 2) native file download — works when NOT in a download-blocked sandbox
try {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = filename; a.style.display = 'none'
document.body.appendChild(a); a.click(); downloaded = true
setTimeout(() => { try { URL.revokeObjectURL(url); a.remove() } catch (_) {} }, 200)
} catch (_) { /* download blocked — clipboard above already has it */ }
Object.assign(lastExport, { rows, clipboard, downloaded, fields, wide: !!wideStore, at: (typeof performance !== 'undefined' ? performance.now() : 0) })
try { window[hookPrefix + 'ExportStatus'] = { rows, clipboard, downloaded, fields, wide: !!wideStore } } catch (_) {}
return { csv, rows, clipboard, downloaded, fields, wide: !!wideStore }
}
/**
* The UI entry point: guarantee the ALL-FIELDS record before writing the file.
*
* Why this is async but exportCsv() stays sync — navigator.clipboard.writeText() needs the
* TRANSIENT USER ACTIVATION of the click, and awaiting a network round-trip spends it. So the
* first click on a cold cache resolves the wide rows and then writes: the download path always
* works, and the clipboard path is re-attempted. Once cached (every later click) the await
* settles in a microtask, activation survives, and clipboard works as before.
* A failed fetch still exports the loaded 24-field record — degraded, never nothing.
*/
async function exportCsvAsync() {
try { await fetchWideRows() } catch (_) { /* fall back to the loaded record */ }
return exportCsv()
}
// Let the UI warm the cache (e.g. on hover/mount) so the first real click is already wide.
function prefetchExportFields() { try { return fetchWideRows() } catch (_) { return null } }
return { exportCsv, exportCsvAsync, buildCsv, pickRows, scopeLabel, lastExport, prefetchExportFields, EXPORT_FIELDS }
}