- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
85 lines
2.8 KiB
Go
85 lines
2.8 KiB
Go
package product
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
)
|
|
|
|
// OpenFoodFacts is the client for the Open Food Facts public API.
|
|
type OpenFoodFacts struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewOpenFoodFacts creates a new OpenFoodFacts client with the default HTTP client.
|
|
func NewOpenFoodFacts() *OpenFoodFacts {
|
|
return &OpenFoodFacts{httpClient: &http.Client{}}
|
|
}
|
|
|
|
// offProduct is the JSON shape returned by the Open Food Facts v2 API.
|
|
type offProduct struct {
|
|
ProductName string `json:"product_name"`
|
|
Brands string `json:"brands"`
|
|
Nutriments offNutriments `json:"nutriments"`
|
|
}
|
|
|
|
type offNutriments struct {
|
|
EnergyKcal100g *float64 `json:"energy-kcal_100g"`
|
|
Proteins100g *float64 `json:"proteins_100g"`
|
|
Fat100g *float64 `json:"fat_100g"`
|
|
Carbohydrates100g *float64 `json:"carbohydrates_100g"`
|
|
Fiber100g *float64 `json:"fiber_100g"`
|
|
}
|
|
|
|
type offResponse struct {
|
|
Status int `json:"status"`
|
|
Product offProduct `json:"product"`
|
|
}
|
|
|
|
// Fetch retrieves a product from Open Food Facts by barcode.
|
|
// Returns an error if the product is not found or the API call fails.
|
|
func (client *OpenFoodFacts) Fetch(requestContext context.Context, barcode string) (*Product, error) {
|
|
url := fmt.Sprintf("https://world.openfoodfacts.org/api/v2/product/%s.json", barcode)
|
|
httpRequest, requestError := http.NewRequestWithContext(requestContext, http.MethodGet, url, nil)
|
|
if requestError != nil {
|
|
return nil, fmt.Errorf("build open food facts request: %w", requestError)
|
|
}
|
|
httpRequest.Header.Set("User-Agent", "FoodAI/1.0")
|
|
|
|
httpResponse, fetchError := client.httpClient.Do(httpRequest)
|
|
if fetchError != nil {
|
|
return nil, fmt.Errorf("open food facts request: %w", fetchError)
|
|
}
|
|
defer httpResponse.Body.Close()
|
|
|
|
if httpResponse.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("open food facts returned status %d for barcode %s", httpResponse.StatusCode, barcode)
|
|
}
|
|
|
|
var offResp offResponse
|
|
if decodeError := json.NewDecoder(httpResponse.Body).Decode(&offResp); decodeError != nil {
|
|
return nil, fmt.Errorf("decode open food facts response: %w", decodeError)
|
|
}
|
|
if offResp.Status == 0 {
|
|
return nil, fmt.Errorf("product %s not found in open food facts", barcode)
|
|
}
|
|
|
|
canonicalName := offResp.Product.ProductName
|
|
if canonicalName == "" {
|
|
canonicalName = barcode // Fall back to barcode as canonical name
|
|
}
|
|
|
|
barcodeValue := barcode
|
|
catalogProduct := &Product{
|
|
CanonicalName: canonicalName,
|
|
Barcode: &barcodeValue,
|
|
CaloriesPer100g: offResp.Product.Nutriments.EnergyKcal100g,
|
|
ProteinPer100g: offResp.Product.Nutriments.Proteins100g,
|
|
FatPer100g: offResp.Product.Nutriments.Fat100g,
|
|
CarbsPer100g: offResp.Product.Nutriments.Carbohydrates100g,
|
|
FiberPer100g: offResp.Product.Nutriments.Fiber100g,
|
|
}
|
|
return catalogProduct, nil
|
|
}
|