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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user