feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases

Backend:
- GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search
- GET /diary/recent — recently used dishes and products for the current user
- product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity)
- importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers")
- migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed)

Flutter:
- FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips
- DishPortionSheet: quick ½/1/1½/2 buttons + custom input
- + button in meal card now opens FoodSearchSheet instead of going directly to AI scan
- 7 new l10n keys across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 15:28:29 +02:00
parent 81185bb7ff
commit 78f1c8bf76
41 changed files with 1688 additions and 28 deletions

View File

@@ -21,6 +21,17 @@ type offRecord struct {
Code string `json:"code"`
ProductName string `json:"product_name"`
ProductNameEN string `json:"product_name_en"`
ProductNameRu string `json:"product_name_ru"`
ProductNameDe string `json:"product_name_de"`
ProductNameFr string `json:"product_name_fr"`
ProductNameEs string `json:"product_name_es"`
ProductNameIt string `json:"product_name_it"`
ProductNamePt string `json:"product_name_pt"`
ProductNameZh string `json:"product_name_zh"`
ProductNameJa string `json:"product_name_ja"`
ProductNameKo string `json:"product_name_ko"`
ProductNameAr string `json:"product_name_ar"`
ProductNameHi string `json:"product_name_hi"`
CategoriesTags []string `json:"categories_tags"`
Nutriments offNutriments `json:"nutriments"`
UniqueScansN int `json:"unique_scans_n"`
@@ -34,6 +45,12 @@ type offNutriments struct {
Fiber100g *float64 `json:"fiber_100g"`
}
// aliasRow holds one multilingual alias for a product.
type aliasRow struct {
lang string
alias string
}
type productImportRow struct {
canonicalName string
barcode string
@@ -43,6 +60,7 @@ type productImportRow struct {
fat *float64
carbs *float64
fiber *float64
aliases []aliasRow
}
// categoryPrefixes maps OpenFoodFacts category tag prefixes to our product_categories slugs.
@@ -217,6 +235,28 @@ func run() error {
}
seenInBatch[canonicalName] = true
// Collect non-English localised names as aliases so that searches in
// other languages (e.g. "сникерс" → "Snickers") can find the product.
langNames := map[string]string{
"ru": strings.TrimSpace(record.ProductNameRu),
"de": strings.TrimSpace(record.ProductNameDe),
"fr": strings.TrimSpace(record.ProductNameFr),
"es": strings.TrimSpace(record.ProductNameEs),
"it": strings.TrimSpace(record.ProductNameIt),
"pt": strings.TrimSpace(record.ProductNamePt),
"zh": strings.TrimSpace(record.ProductNameZh),
"ja": strings.TrimSpace(record.ProductNameJa),
"ko": strings.TrimSpace(record.ProductNameKo),
"ar": strings.TrimSpace(record.ProductNameAr),
"hi": strings.TrimSpace(record.ProductNameHi),
}
var productAliases []aliasRow
for lang, name := range langNames {
if name != "" && name != canonicalName {
productAliases = append(productAliases, aliasRow{lang: lang, alias: name})
}
}
totalAccepted++
productBatch = append(productBatch, productImportRow{
canonicalName: canonicalName,
@@ -227,6 +267,7 @@ func run() error {
fat: record.Nutriments.Fat100g,
carbs: record.Nutriments.Carbohydrates100g,
fiber: record.Nutriments.Fiber100g,
aliases: productAliases,
})
if len(productBatch) >= *batchSizeFlag {
@@ -336,18 +377,21 @@ func flushBatch(
carbs_per_100g = COALESCE(EXCLUDED.carbs_per_100g, products.carbs_per_100g),
fiber_per_100g = COALESCE(EXCLUDED.fiber_per_100g, products.fiber_per_100g),
updated_at = now()
RETURNING id`)
RETURNING id, canonical_name`)
if upsertError != nil {
return 0, fmt.Errorf("upsert products: %w", upsertError)
}
// Build a canonical_name → product_id map so we can insert aliases below.
productIDByName := make(map[string]string, len(productBatch))
var insertedCount int64
for upsertRows.Next() {
var productID string
if scanError := upsertRows.Scan(&productID); scanError != nil {
var productID, canonicalName string
if scanError := upsertRows.Scan(&productID, &canonicalName); scanError != nil {
upsertRows.Close()
return 0, fmt.Errorf("scan upserted product id: %w", scanError)
}
productIDByName[canonicalName] = productID
insertedCount++
}
upsertRows.Close()
@@ -355,5 +399,35 @@ func flushBatch(
return 0, fmt.Errorf("iterate upsert results: %w", rowsError)
}
// Insert multilingual aliases collected during parsing.
// ON CONFLICT DO NOTHING so re-imports are safe.
aliasBatch := &pgx.Batch{}
for _, row := range productBatch {
productID, exists := productIDByName[row.canonicalName]
if !exists || len(row.aliases) == 0 {
continue
}
for _, aliasEntry := range row.aliases {
aliasBatch.Queue(
`INSERT INTO product_aliases (product_id, lang, alias)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
productID, aliasEntry.lang, aliasEntry.alias,
)
}
}
if aliasBatch.Len() > 0 {
batchResults := pgxConn.SendBatch(requestContext, aliasBatch)
for range aliasBatch.Len() {
if _, execError := batchResults.Exec(); execError != nil {
batchResults.Close()
return 0, fmt.Errorf("insert product alias: %w", execError)
}
}
if closeError := batchResults.Close(); closeError != nil {
return 0, fmt.Errorf("close alias batch: %w", closeError)
}
}
return insertedCount, nil
}