import { readFile } from 'node:fs/promises'; import pg from 'pg'; import { excludedReason, isExcluded, matvaretabellenGroup, storedGroup } from '../groups.ts'; import { isInfantFood, maxJsonBytes } from './european.ts'; import { importDrafts, toNumber } from './national.ts'; import type { Draft, NutrientKey, Parsed, ValueType } from './national.ts'; // Matvaretabellen (the Norwegian Food Composition Table, Mattilsynet), version 2026. The agency publishes the // whole table through its open data API (https://www.matvaretabellen.no/api/); data.norge.no lists the // distributions under the Norwegian Licence for Open Government Data 2.0 (NLOD 2.0), which allows commercial // use with attribution, and Mattilsynet asks: "Matvaretabellen 2026. Mattilsynet. www.matvaretabellen.no". // Inputs: the English foods file (names), the Norwegian one (native names), and the sources, nutrients and // food-groups files of the same API. export const matvaretabellenSourceName = 'Matvaretabellen, Mattilsynet (Norwegian Food Safety Authority)'; export const matvaretabellenAttribution = 'Matvaretabellen 2026. Mattilsynet. www.matvaretabellen.no. Norwegian Licence for Open Government Data (NLOD 2.0).'; export const matvaretabellenLanguage = 'en'; // nutrientId -> [key, unit]. Karbo is the available carbohydrate (EU 1169/2011; FD-11 `available`); "Sukker" // is added sugar; "Mono+Di" is total sugars. const nutrients: Record = { Vann: ['water_g', 'g'], Fett: ['fat_g', 'g'], Mettet: ['fasat_g', 'g'], Kolest: ['chole_mg', 'mg'], Karbo: ['chocdf_g', 'g'], 'Mono+Di': ['sugar_g', 'g'], Sukker: ['sugad_g', 'g'], Fiber: ['fibtg_g', 'g'], Protein: ['procnt_g', 'g'], Alko: ['alc_g', 'g'], 'Vit D': ['vitd_ug', 'µg'], Ca: ['ca_mg', 'mg'], Fe: ['fe_mg', 'mg'], Na: ['na_mg', 'mg'], K: ['k_mg', 'mg'], }; // Values the table borrows from the Spanish BEDCA and the Italian BDA, whose terms of use reserve commercial // use: they are left unknown here, whatever the licence of the Norwegian publication. const restricted = /BEDCA|bedca\.net|bda\.ieo\.it|BDA V\d/i; // Source description -> FD-07 type. The table states for every value where it comes from; the first matching // class wins. Descriptions that fit no class (papers, reports of other kinds) leave the value unknown. export function matvaretabellenSourceType(description: string): ValueType | null { if (/^Missing value/i.test(description) || restricted.test(description)) return null; if (/naturally occurring zero|zero value|Estimated as zero/i.test(description)) return 'assumed_zero'; if (/Imputation|similar food|^Estimated value|^Estimated from/i.test(description)) return 'imputed'; if (/calculat|recipe|Water by difference|Aggregated value/i.test(description) && !/USDA|Department of Agriculture/i.test(description)) return 'calculated'; if (/Data from the industry.*analysed value/i.test(description)) return 'analytic'; if (/Data from the industry|Product information|nutrition labelling|Food label/i.test(description)) return 'label'; if (/USDA|Department of Agriculture|FoodData Central|Food Standards Agency|McCance|Public Health England|Food Databanks|Swedish|Livsmedelsverket|Technical University of Denmark|DTU|Ciqual|French Agency|Fineli|Finnish|Souci|NEVO|Frida|Danish|Icelandic|food composition (table|database)|food database/i.test(description)) return 'borrowed'; if (/Norwegian Food Safety Authority|Norwegian Food Control|National Council for Nutrition|Nutrient analysis/i.test(description)) return 'analytic'; return null; } type Constituent = { sourceId?: string; nutrientId?: string; quantity?: number; unit?: string }; type Food = { foodId?: string; foodName?: string; foodGroupId?: string; energy?: { sourceId?: string; quantity?: number; unit?: string } | null; calories?: { sourceId?: string; quantity?: number; unit?: string } | null; constituents?: Constituent[]; portions?: { portionName?: string; quantity?: number; unit?: string }[]; ediblePart?: { percent?: number } | null; latinName?: string }; function json(bytes: Buffer, what: string) { if (bytes.length > maxJsonBytes) throw new Error(`Matvaretabellen ${what} is too large`); return JSON.parse(bytes.toString('utf8')); } export function parseMatvaretabellen(files: { foods: Buffer; foodsNative: Buffer; sources: Buffer; nutrients: Buffer; groups: Buffer }): Parsed { const foods: Food[] = json(files.foods, 'foods').foods; const native = new Map((json(files.foodsNative, 'native foods').foods as Food[]).map(food => [food.foodId ?? '', food.foodName ?? ''])); const descriptions = new Map((json(files.sources, 'sources').sources as { sourceId: string; description: string }[]) .map(source => [source.sourceId, source.description])); const units = new Map((json(files.nutrients, 'nutrients').nutrients as { nutrientId: string; unit: string }[]) .map(nutrient => [nutrient.nutrientId, nutrient.unit])); for (const [id, [, unit]] of Object.entries(nutrients)) if (units.get(id) !== unit) throw new Error(`Matvaretabellen nutrient ${id} unit changed`); const groups = new Map((json(files.groups, 'groups').foodGroups as { foodGroupId: string; name: string }[]) .map(group => [group.foodGroupId, group.name])); if (!Array.isArray(foods)) throw new Error('Matvaretabellen foods file has no foods'); const counts = new Map(); for (const food of foods) if (food.foodId) counts.set(food.foodId, (counts.get(food.foodId) ?? 0) + 1); const typeOf = (sourceId: string | undefined) => { const description = descriptions.get(sourceId ?? ''); return description === undefined ? null : matvaretabellenSourceType(description); }; const out: Parsed = { drafts: [], skipped: [] }; for (const food of foods) { const id = food.foodId; 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.foodName?.replace(/\s+/g, ' ').trim(); if (!name) { out.skipped.push({ recordId: id, reason: 'no English name' }); continue; } // Group 11 is the table's own "Infant food" (C-23). if (food.foodGroupId === '11' || food.foodGroupId?.startsWith('11.') || isInfantFood(name)) { out.skipped.push({ recordId: id, reason: 'baby food (C-23)' }); continue; } const entry = matvaretabellenGroup(food.foodGroupId); if (isExcluded(entry)) { out.skipped.push({ recordId: id, reason: excludedReason(entry) }); continue; } const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; // Energy is calculated by the table with the EU 1169/2011 factors (sources MI0114 and MI0115). if (food.energy?.unit === 'kJ' && food.energy.quantity !== undefined && food.energy.quantity >= 0 && typeOf(food.energy.sourceId) === 'calculated') { values.enerc_kj = food.energy.quantity; kinds.enerc_kj = 'calculated'; } if (food.calories?.unit === 'kcal' && food.calories.quantity !== undefined && food.calories.quantity >= 0 && typeOf(food.calories.sourceId) === 'calculated') { values.enerc_kcal = food.calories.quantity; kinds.enerc_kcal = 'calculated'; } const seen = new Set(); for (const item of food.constituents ?? []) { const known = nutrients[item.nutrientId ?? '']; if (!known) continue; // Two entries for one nutrient cannot both be right; neither is used. if (seen.has(known[0])) { delete values[known[0]]; delete kinds[known[0]]; continue; } seen.add(known[0]); const value = toNumber(item.quantity === undefined ? null : String(item.quantity)); const type = typeOf(item.sourceId); if (value === null || value < 0 || item.unit !== known[1] || !type) continue; values[known[0]] = value; kinds[known[0]] = type; } const measures = (food.portions ?? []).filter(portion => portion.portionName && portion.unit === 'g' && typeof portion.quantity === 'number' && portion.quantity > 0) .map(portion => ({ name: portion.portionName!, basis_amount: portion.quantity! })); const added: Record = { name_language: matvaretabellenLanguage, native_name: native.get(id) || null, native_language: 'nb', matvaretabellen_group_id: food.foodGroupId ?? null, matvaretabellen_group: groups.get(food.foodGroupId ?? '') ?? null, latin_name: food.latinName || null }; const percent = food.ediblePart?.percent; if (typeof percent === 'number' && percent > 0 && percent < 100) added.refuse_fraction = 1 - percent / 100; out.drafts.push({ recordId: id, name, basis: '100g', carbohydrateDefinition: 'available', values, kinds, measures, added, group: storedGroup(entry), raw: Buffer.from(JSON.stringify(food)), }); } return out; } export const matvaretabellenFiles = { foods: 'foods-en.json', foodsNative: 'foods-nb.json', sources: 'sources-en.json', nutrients: 'nutrients-en.json', groups: 'food-groups-en.json' } as const; export async function importMatvaretabellenFiles(client: pg.Client, paths: Record, releasedAt: string) { const read = (key: keyof typeof matvaretabellenFiles) => readFile(paths[key]); return importDrafts(client, { src: 'SRC-13', market: 'NO', family: 'open' }, parseMatvaretabellen({ foods: await read('foods'), foodsNative: await read('foodsNative'), sources: await read('sources'), nutrients: await read('nutrients'), groups: await read('groups') }), releasedAt, matvaretabellenSourceName); }