import { readFile } from 'node:fs/promises'; import pg from 'pg'; import { ciqualGroup, excludedReason, isExcluded, storedGroup } from '../groups.ts'; import { importDrafts } from './national.ts'; import type { Draft, NutrientKey, Parsed } from './national.ts'; // ANSES Ciqual (SRC-10, Licence Ouverte / Etalab 2.0): the XML export of the French table. Two files // are read: alim (foods, with the English name) and compo (one row per food and constituent). // FD-07: SRC-10 values are `borrowed` in every market. "traces" is a stated non-significant amount // and becomes 0 typed `assumed_zero` (FD-08, as for CoFID "Tr"); "< x" and "-" stay unknown. export const ciqualSourceName = 'Table de composition nutritionnelle des aliments Ciqual, ANSES'; export const ciqualAttribution = 'Anses. 2025. Ciqual French food composition table.'; // Ciqual constituent codes for the FD-10 nutrients. Energy is the EU 1169/2011 pair; carbohydrate // is the available carbohydrate of that regulation (FD-11 `available`), fibre is the AOAC column. const constituents: Record = { '328': 'enerc_kcal', '327': 'enerc_kj', '25000': 'procnt_g', '40000': 'fat_g', '31000': 'chocdf_g', '34100': 'fibtg_g', '32000': 'sugar_g', '40302': 'fasat_g', '10110': 'na_mg', '75100': 'chole_mg', '10190': 'k_mg', '10200': 'ca_mg', '10260': 'fe_mg', '52100': 'vitd_ug', '60000': 'alc_g', '400': 'water_g', }; const maxInput = 200 * 1024 * 1024, maxBlock = 4000; const entities: Record = { lt: '<', gt: '>', amp: '&', apos: "'", quot: '"' }; const decode = (text: string) => text.replace(/&(?:#(\d+)|#x([\da-f]+)|(lt|gt|amp|apos|quot));/gi, (_, dec, hex, name) => name ? entities[name.toLowerCase()] : String.fromCodePoint(dec ? Number(dec) : parseInt(hex, 16))); // Every record is a flat list of value , or an empty . Nothing nests, // so a bounded scan is enough and no general XML parser is needed. function records(bytes: Buffer, tag: string): Record[] { if (bytes.length > maxInput) throw new Error(`Ciqual ${tag} file is too large`); const xml = bytes.toString('utf8'); if (!//.test(xml)) throw new Error(`Ciqual ${tag} file is not a Ciqual table`); const out: Record[] = []; for (const block of xml.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)`, 'g'))) { if (block[1].length > maxBlock) throw new Error(`Ciqual ${tag} record is too long`); const row: Record = {}; for (const field of block[1].matchAll(/<(\w+)(?:\s[^>]*)?\/>|<(\w+)>([^<]*)<\/\2>/g)) { if (field[1]) row[field[1]] = null; else row[field[2]] = decode(field[3]).trim() || null; } out.push(row); } return out; } // Ciqual writes decimals with a comma. Anything else than a plain number, "traces", "< x" or "-" // is a broken file and stops the import. export function ciqualValue(text: string | null): { value: number | null; zero: boolean } { if (text === null || text === '-' || text.startsWith('<')) return { value: null, zero: false }; if (text === 'traces') return { value: 0, zero: true }; if (!/^\d+(,\d+)?$/.test(text)) throw new Error(`Ciqual: unexpected value ${text}`); return { value: Number(text.replace(',', '.')), zero: false }; } export function parseCiqual(alimBytes: Buffer, compoBytes: Buffer): Parsed { const compo = new Map[]>(); for (const row of records(compoBytes, 'COMPO')) { const code = row.alim_code; if (!code) throw new Error('Ciqual: composition row without food code'); compo.set(code, [...(compo.get(code) ?? []), row]); } const foods = records(alimBytes, 'ALIM'); const counts = new Map(); for (const food of foods) if (food.alim_code) counts.set(food.alim_code, (counts.get(food.alim_code) ?? 0) + 1); const out: Parsed = { drafts: [], skipped: [] }; for (const food of foods) { const id = food.alim_code; if (!id) { out.skipped.push({ recordId: null, reason: 'missing source identifier' }); continue; } if ((counts.get(id) ?? 0) > 1) { out.skipped.push({ recordId: id, reason: 'duplicate source identifier' }); continue; } const name = food.alim_nom_eng?.replace(/\s+/g, ' '); if (!name) { out.skipped.push({ recordId: id, reason: 'no English name' }); continue; } const rows = compo.get(id); if (!rows) { out.skipped.push({ recordId: id, reason: 'no composition rows' }); continue; } const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; for (const row of rows) { const key = row.const_code ? constituents[row.const_code] : undefined; if (!key) continue; const { value, zero } = ciqualValue(row.teneur ?? null); if (value === null) continue; values[key] = value; kinds[key] = zero ? 'assumed_zero' : 'borrowed'; } // Group codes are kept for the group mapping; "000000" is Ciqual's "no sub-sub-group". const level = (code: string | null | undefined) => (code && !/^0+$/.test(code) ? code : null); const added = { ciqual_group: level(food.alim_grp_code), ciqual_subgroup: level(food.alim_ssgrp_code), ciqual_subsubgroup: level(food.alim_ssssgrp_code) }; const entry = ciqualGroup(added.ciqual_group, added.ciqual_subgroup, added.ciqual_subsubgroup); if (isExcluded(entry)) { out.skipped.push({ recordId: id, reason: excludedReason(entry) }); continue; } out.drafts.push({ recordId: id, name, basis: '100g', carbohydrateDefinition: 'available', values, kinds, measures: [], group: storedGroup(entry), added, raw: Buffer.from(JSON.stringify({ ALIM: food, COMPO: rows })), }); } return out; } export async function importCiqualFiles(client: pg.Client, paths: { alim: string; compo: string }, releasedAt: string) { return importDrafts(client, { src: 'SRC-10', market: 'FR', family: 'open' }, parseCiqual(await readFile(paths.alim), await readFile(paths.compo)), releasedAt, ciqualSourceName); }