import { readFile } from 'node:fs/promises'; import pg from 'pg'; import { isInfantFood, maxJsonBytes } from './european.ts'; import { importDrafts, toNumber } from './national.ts'; import type { Draft, NutrientKey, Parsed, ValueType } from './national.ts'; // Livsmedelsdatabasen (Swedish Food Agency, Livsmedelsverket), Sweden. Open data under Creative Commons // Attribution 4.0 ("The Swedish Food Agency must be stated as the source."), delivered by the agency's public // API; scripts/fetch-food-livsmedelsverket.ts saves the food list and every food's nutrient values, in English, // into one JSON file. Each value carries EuroFIR value-type, method-type and acquisition-type codes. export const livsmedelsverketSourceName = 'Livsmedelsdatabasen, Livsmedelsverket (Swedish Food Agency)'; export const livsmedelsverketAttribution = 'Livsmedelsverket (Swedish Food Agency), The Swedish Food Database (Livsmedelsdatabasen). CC BY 4.0.'; export const livsmedelsverketLanguage = 'en'; // EuroFIR code + unit -> key. Carbohydrate is "Carbohydrates, available" (FD-11 `available`). const nutrients: Record = { 'ENERC kcal': 'enerc_kcal', 'ENERC kJ': 'enerc_kj', 'PROT g': 'procnt_g', 'FAT g': 'fat_g', 'CHO g': 'chocdf_g', 'FIBT g': 'fibtg_g', 'SUGAR g': 'sugar_g', 'SUGAD g': 'sugad_g', 'FASAT g': 'fasat_g', 'NA mg': 'na_mg', 'CHORL mg': 'chole_mg', 'K mg': 'k_mg', 'CA mg': 'ca_mg', 'FE mg': 'fe_mg', 'VITD µg': 'vitd_ug', 'ALC g': 'alc_g', 'CAFFN mg': 'caffn_mg', 'WATER g': 'water_g', }; type Value = { euroFIRkod?: string; enhet?: string; varde?: number; vardetypkod?: string; metodtypkod?: string; ursprungkod?: string; matrisenhetkod?: string }; // FD-07 type from the EuroFIR codes of the value. Below-limit values and values without any code stay // unknown. A logical zero or a trace stated as 0 is `assumed_zero`. A value taken from another food // composition table or paper is `borrowed`, one from a label `label`; otherwise the method type decides. export function livsmedelsverketType(value: Value): ValueType | null { const { vardetypkod: kind, metodtypkod: method, ursprungkod: origin } = value; if (kind === 'BL' || kind === 'BQ') return null; if (kind === 'LZ' || kind === 'TR') return value.varde === 0 ? 'assumed_zero' : null; if (origin === 'F' || origin === 'C' || origin === 'P') return 'borrowed'; if (origin === 'L') return 'label'; if (method === 'A' || method === 'AG' || method === 'D') return 'analytic'; if (method && ['R', 'S', 'T', 'CG', 'G', 'P'].includes(method)) return 'calculated'; if (method && ['K', 'I', 'IG', 'O'].includes(method)) return 'imputed'; if (method === 'U') return value.varde === 0 ? 'assumed_zero' : 'imputed'; return null; } export function parseLivsmedelsverket(bytes: Buffer): Parsed { if (bytes.length > maxJsonBytes) throw new Error('Livsmedelsverket file is too large'); const input = JSON.parse(bytes.toString('utf8')); if (!Array.isArray(input?.foods)) throw new Error('Livsmedelsverket file has no foods'); const counts = new Map(); for (const food of input.foods) counts.set(food.nummer, (counts.get(food.nummer) ?? 0) + 1); const out: Parsed = { drafts: [], skipped: [] }; for (const food of input.foods) { const id = Number.isInteger(food.nummer) ? String(food.nummer) : null; if (!id) { out.skipped.push({ recordId: null, reason: 'missing source identifier' }); continue; } if (counts.get(food.nummer) !== 1) { out.skipped.push({ recordId: id, reason: 'duplicate source identifier' }); continue; } const name = typeof food.namn === 'string' ? food.namn.replace(/\s+/g, ' ').trim() : ''; if (!name) { out.skipped.push({ recordId: id, reason: 'no English name' }); continue; } if (isInfantFood(name)) { out.skipped.push({ recordId: id, reason: 'baby food (C-23)' }); continue; } const rows: Value[] = Array.isArray(food.naringsvarden) ? food.naringsvarden : []; if (!rows.length) { out.skipped.push({ recordId: id, reason: 'no composition rows' }); continue; } const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; const seen = new Set(); let refuse: number | null = null; for (const row of rows) { if (row.euroFIRkod === 'WASTE' && row.enhet === '%' && typeof row.varde === 'number' && row.varde >= 0 && row.varde <= 100) refuse = row.varde / 100; const key = nutrients[`${row.euroFIRkod} ${row.enhet}`]; if (!key || (row.matrisenhetkod ?? 'W') !== 'W') continue; // Two rows for one nutrient cannot both be right; neither is used. if (seen.has(key)) { delete values[key]; delete kinds[key]; continue; } seen.add(key); const value = toNumber(String(row.varde)); const type = livsmedelsverketType(row); if (value === null || value < 0 || !type) continue; values[key] = value; kinds[key] = type; } const added: Record = { name_language: livsmedelsverketLanguage, native_language: 'sv', food_type: food.livsmedelsTyp ?? null, scientific_name: food.vetenskapligtNamn || null, project: food.projekt ?? null }; if (refuse !== null) added.refuse_fraction = refuse; out.drafts.push({ recordId: id, name, basis: '100g', carbohydrateDefinition: 'available', values, kinds, measures: [], added, raw: Buffer.from(JSON.stringify(food)), }); } return out; } export async function importLivsmedelsverketFile(client: pg.Client, path: string, releasedAt: string) { return importDrafts(client, { src: 'SRC-12', market: 'SE', family: 'open' }, parseLivsmedelsverket(await readFile(path)), releasedAt, livsmedelsverketSourceName); }