import { readFile } from 'node:fs/promises'; import pg from 'pg'; import { fillEnergy, importDrafts } from './national.ts'; import type { Draft, NutrientKey, Parsed, ValueType } from './national.ts'; import { chains } from '../chains/registry.ts'; import type { ChainEntry } from '../chains/registry.ts'; import type { ChainItem, Nutrients } from '../chains/parse.ts'; // Draft import of dishes read from a chain's own publication (SRC-07, FD-28, FD-63, FD-30). food_group // stays null, so every dish waits in quarantine as "unmapped" until the group step assigns one (FD-09). export const chainSourceName = (chain: ChainEntry) => `${chain.name}: published nutrition information`; const slug = (text: string) => text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); // Labels in these countries print total carbohydrate; elsewhere the carbohydrate is the available one. const totalCarbohydrate = new Set(['US', 'CA']); const fields: [keyof Nutrients, NutrientKey][] = [ ['kcal', 'enerc_kcal'], ['kj', 'enerc_kj'], ['protein', 'procnt_g'], ['fat', 'fat_g'], ['carbs', 'chocdf_g'], ['fibre', 'fibtg_g'], ['sugars', 'sugar_g'], ['satfat', 'fasat_g'], ['sodiumMg', 'na_mg'], ['cholesterolMg', 'chole_mg'], ]; export function chainDraft(item: ChainItem, entry: ChainEntry): Draft | null { const hasPer100 = item.per100 !== null && (item.per100.kcal !== undefined || item.per100.kj !== undefined); const source = hasPer100 ? item.per100! : item.perServing; if (!source || (source.kcal === undefined && source.kj === undefined)) return null; if (item.servingG !== null && !(item.servingG > 0)) return null; // FD-30: a published weight gives the base 100 g; without one the base is one piece. const scale = hasPer100 || item.servingG === null ? 1 : 100 / item.servingG; const kind: ValueType = hasPer100 ? 'label' : 'label_per_serving'; const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; for (const [from, to] of fields) { const value = source[from]; if (value === undefined) continue; values[to] = value * scale; kinds[to] = kind; } // Salt to sodium is the labelling factor 2.5, so the derived sodium is "calculated" (FD-07). if (values.na_mg === undefined && source.saltG !== undefined) { values.na_mg = source.saltG * 400 * scale; kinds.na_mg = 'calculated'; } fillEnergy(values, kinds); const basis = item.servingG === null ? 'one_piece' as const : '100g' as const; return { recordId: `${item.chain}:${item.market}:${slug(item.section ? `${item.section} ${item.name}` : item.name)}`, raw: Buffer.from(JSON.stringify(item)), name: item.name, basis, carbohydrateDefinition: totalCarbohydrate.has(item.market) ? 'total' : 'available', values, kinds, measures: item.servingG !== null ? [{ name: '1 serving', basis_amount: item.servingG }] : [], group: null, // Label rounding of one serving (5 kcal plus half a gram of each macro) seen per 100 g. energyRounding: hasPer100 ? undefined : 13.5 * scale, chain: { name: entry.name, uri: item.sourceUrl, checkedAt: item.collectedAt }, added: { chain: item.chain, market: item.market, section: item.section, source_url: item.sourceUrl, collected_at: item.collectedAt, serving_g: item.servingG }, }; } // FD-63: a chain without a row, or without the verdict "allowed", is not imported. export function parseChainItems(items: ChainItem[], registry: ChainEntry[] = chains): Parsed { const out: Parsed = { drafts: [], skipped: [] }; const seen = new Set(); for (const item of items) { const entry = registry.find(chain => chain.id === item.chain); if (!entry || entry.verdict !== 'allowed') { out.skipped.push({ recordId: null, reason: `chain ${item.chain} is not allowed by FD-63` }); continue; } const draft = chainDraft(item, entry); if (!draft) { out.skipped.push({ recordId: item.name, reason: 'no energy or weight' }); continue; } if (seen.has(draft.recordId)) { out.skipped.push({ recordId: draft.recordId, reason: 'duplicate dish in the publication' }); continue; } seen.add(draft.recordId); out.drafts.push(draft); } return out; } // Dishes of one chain may span several countries; the food market is the country of the publication, so // each chain and country imports on its own. export async function importChainItems(client: pg.Client, items: ChainItem[], collectedAt: string, registry: ChainEntry[] = chains) { const results = []; for (const key of [...new Set(items.map(item => `${item.chain}|${item.market}`))].sort()) { const [id, market] = key.split('|'); const group = items.filter(item => item.chain === id && item.market === market); const entry = registry.find(chain => chain.id === id); const parsed = parseChainItems(group, registry); if (!entry || !parsed.drafts.length) { results.push({ chain: id, market, rows: 0, skipped: parsed.skipped.length }); continue; } results.push({ chain: id, market, ...await importDrafts(client, { src: 'SRC-07', market, family: 'open', layer: 'chain' }, parsed, collectedAt, chainSourceName(entry)) }); } return results; } export async function importChainFile(client: pg.Client, file: string, collectedAt: string) { return importChainItems(client, JSON.parse(await readFile(file, 'utf8')) as ChainItem[], collectedAt); }