// Backfills products.productimages for everything imported before the column // existed. // // The import used to keep `Images[0]` and discard the rest, so roughly six // thousand products carry one photo where the global catalogue has up to ten. // Re-importing would work but rewrites price, category and stock links; this // only fills the gallery. // // The images are re-listed rather than reconstructed. Filenames are // `image_000.jpeg`, `image_001.jpg` … — the extension varies within a single // product, so the sibling URLs cannot be derived from the first one. The // `image_id` is taken out of the stored URL and the same S3 listing the API // uses is asked for the rest, which is why this boots the app's own // connections instead of talking to Postgres alone. // // Dry by default; pass --apply to write. // // go run ./scratch/productimages # report only // go run ./scratch/productimages --apply # write // go run ./scratch/productimages --apply --tenant 1087 package main import ( "encoding/json" "flag" "fmt" "log" "strings" "nearle/db" "github.com/joho/godotenv" ) type row struct { Productid int Tenantid int Productbrand string Productimage string } func main() { apply := flag.Bool("apply", false, "write the changes; otherwise report only") tenant := flag.Int("tenant", 0, "restrict to one tenant") flag.Parse() _ = godotenv.Load() db.Connect() if db.ImageStore == nil { log.Fatal("the image store is not configured (USE_S3 / S3_*), so there is nothing to list images from") } q := ` SELECT productid, tenantid, COALESCE(productbrand,'') AS productbrand, COALESCE(productimage,'') AS productimage FROM products WHERE COALESCE(productimage,'') <> '' AND COALESCE(productimages::text,'') IN ('', 'null', '[]')` args := []interface{}{} if *tenant > 0 { q += ` AND tenantid = ?` args = append(args, *tenant) } q += ` ORDER BY productid` var rows []row if err := db.DB.Raw(q, args...).Scan(&rows).Error; err != nil { log.Fatal(err) } fmt.Printf("candidates: %d\n\n", len(rows)) var filled, single, unparsed, nolist int for _, r := range rows { brand, imageID := parseImageRef(r.Productimage) if brand == "" || imageID == "" { unparsed++ continue } // The stored brand is authoritative where it exists; the URL is only a // fallback, because a product renamed in the back office keeps its // original image path. if b := strings.ToLower(strings.TrimSpace(r.Productbrand)); b != "" { brand = b } images := db.GetImages(brand, imageID) if len(images) == 0 { nolist++ continue } if len(images) == 1 { // Nothing gained — leave the column null rather than writing a // one-element array that says the same as productimage. single++ continue } encoded, err := json.Marshal(images) if err != nil { log.Printf("product %d: encoding failed: %v", r.Productid, err) continue } if *apply { if err := db.DB.Exec( `UPDATE products SET productimages = ?::jsonb, updated = NOW() WHERE productid = ?`, string(encoded), r.Productid).Error; err != nil { log.Printf("product %d: update failed: %v", r.Productid, err) continue } } filled++ if filled <= 10 { fmt.Printf(" product %-7d %-10s %-34s %d images\n", r.Productid, brand, imageID, len(images)) } } fmt.Printf(` would fill (>1 image) : %d only one image : %d image_id unparseable : %d nothing listed in S3 : %d `, filled, single, unparsed, nolist) if !*apply { fmt.Println("\ndry run — nothing written. Re-run with --apply.") } } // parseImageRef pulls the brand and image_id out of a stored image URL. // // https://…/daily/brands/nestle/nestle_maggi_615a9fa9/image_000.jpeg // ^brand ^image_id // // Returns empty strings for anything that does not match, so a hand-uploaded // image URL is skipped rather than guessed at. func parseImageRef(url string) (brand, imageID string) { const marker = "/daily/brands/" i := strings.Index(url, marker) if i < 0 { return "", "" } parts := strings.Split(strings.Trim(url[i+len(marker):], "/"), "/") if len(parts) < 3 { return "", "" } return strings.ToLower(parts[0]), parts[1] }