Files
descrybe/apps/api/internal/processing/v1_legacy.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

479 lines
14 KiB
Go

package processing
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/google/uuid"
)
var v1PartialSteps = []string{"category", "title", "description", "attributes"}
// ParseV1ProcessingType mirrors legacy parseV1ProcessingTypeFromBody.
// Accepts string ("full" / step), JSON array of steps, or nil (defaults to full).
func ParseV1ProcessingType(raw any) (storageValue string, responseValue any, err error) {
if raw == nil {
return "full", "full", nil
}
switch v := raw.(type) {
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return "full", "full", nil
}
if strings.Contains(trimmed, ",") {
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
}
normalized := strings.ToLower(trimmed)
if normalized == "full" {
return "full", "full", nil
}
if normalized == "both" || normalized == "search" {
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
}
step := normalizeV1Step(normalized)
if step != "" {
return step, step, nil
}
// Dual-mode: accept v2 dashboard types (normalize_only, enhance_only, …).
if isV2ProcessingType(normalized) {
return normalized, normalized, nil
}
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
case []any:
if len(v) == 0 {
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
}
steps := make([]string, 0, len(v))
seen := map[string]struct{}{}
for _, entry := range v {
step := normalizeV1Step(fmt.Sprint(entry))
if step == "" {
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
}
if _, ok := seen[step]; ok {
continue
}
seen[step] = struct{}{}
steps = append(steps, step)
}
if len(steps) == 1 {
return steps[0], steps[0], nil
}
b, _ := json.Marshal(steps)
return string(b), steps, nil
default:
return "", nil, fmt.Errorf("Invalid processing_type. Use \"full\", a single step (%s), or an array of steps, e.g. [\"title\",\"attributes\"].", strings.Join(v1PartialSteps, ", "))
}
}
func isV2ProcessingType(normalized string) bool {
switch normalized {
case "normalize_only", "enhance", "enhance_only", "enhance-only",
"attributes_only", "specs", "specifications",
"eprel", "eprel_only", "categorize", "categorize_only", "categorize_enhance":
return true
default:
return false
}
}
func normalizeV1Step(raw string) string {
token := strings.ToLower(strings.TrimSpace(raw))
if token == "name" {
token = "title"
}
for _, s := range v1PartialSteps {
if s == token {
return s
}
}
return ""
}
// ProcessingTypeForAPIResponse echoes the stored job type as string or []string.
func ProcessingTypeForAPIResponse(stored string) any {
normalized := strings.ToLower(strings.TrimSpace(stored))
if normalized == "" || normalized == "full" {
return "full"
}
if normalized == "both" {
return "both"
}
if strings.HasPrefix(normalized, "[") {
var parsed []any
if err := json.Unmarshal([]byte(stored), &parsed); err == nil {
out := make([]string, 0, len(parsed))
for _, e := range parsed {
if step := normalizeV1Step(fmt.Sprint(e)); step != "" {
out = append(out, step)
} else {
out = append(out, strings.ToLower(strings.TrimSpace(fmt.Sprint(e))))
}
}
return out
}
}
if step := normalizeV1Step(normalized); step != "" {
return step
}
return normalized
}
// MapJobStatusForV1 uppercases pipeline statuses toward the legacy public API.
func MapJobStatusForV1(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending":
return "PENDING"
case "running", "processing", "queued":
return "PROCESSING"
case "completed", "success", "done", "finished", "processed":
return "COMPLETED"
case "failed", "error":
return "FAILED"
case "cancelled", "canceled":
return "CANCELLED"
default:
return strings.ToUpper(strings.TrimSpace(status))
}
}
// JobStatusIncludesProducts reports whether a finished job should expose processed product items.
func JobStatusIncludesProducts(status string) bool {
return MapJobStatusForV1(status) == "COMPLETED"
}
// FormatJobStatusResponse returns job JSON, optionally enriched with processed product items.
// Additive only: existing Job fields are preserved; items/total_items appear when includeItems.
func FormatJobStatusResponse(job Job, items []V1ProcessJobItem, includeItems bool) any {
if !includeItems {
return job
}
raw, err := json.Marshal(job)
if err != nil {
return job
}
out := map[string]any{}
if err := json.Unmarshal(raw, &out); err != nil {
return job
}
if items == nil {
items = []V1ProcessJobItem{}
}
out["items"] = items
out["total_items"] = len(items)
return out
}
// MapV1JobItemStatus normalizes processing_job_products.status for legacy poll items.
func MapV1JobItemStatus(raw string, hasProcessed bool) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "processed", "completed", "success", "done":
return "processed"
case "failed", "error":
return "failed"
case "cancelled", "canceled", "skipped":
return "cancelled"
case "processing", "running":
return "processing"
case "pending", "queued":
return "pending"
default:
if hasProcessed {
return "processed"
}
return "not_found"
}
}
// V1ProcessJobItem is one projected product in a legacy GET /products/process/{id} response.
type V1ProcessJobItem map[string]any
// LoadV1ProcessJobItems loads and projects job products for the legacy GET status response.
func (p *Pipeline) LoadV1ProcessJobItems(ctx context.Context, companyID, jobID uuid.UUID, processingType string) ([]V1ProcessJobItem, error) {
if p == nil || p.Pool == nil {
return nil, fmt.Errorf("pipeline not configured")
}
rows, err := p.Pool.Query(ctx, `
SELECT
COALESCE(r.gtin, p.product_id, '') AS ean,
p.category,
c.name AS category_name,
p.processed_name,
p.meta_title,
p.meta_description,
COALESCE(p.processed_description, p.description) AS description,
p.processed_attributes,
r.mapped_data,
r.raw_data,
pjp.status AS item_status,
pjp.error AS item_error,
p.id AS processed_id,
pjp.raw_product_id AS raw_product_id
FROM processing_job_products pjp
LEFT JOIN raw_products r ON r.id = pjp.raw_product_id
LEFT JOIN processed_products p
ON p.raw_product_id = pjp.raw_product_id AND p.company_id = $2
LEFT JOIN categories c
ON c.unique_id = p.category AND c.company_id = $2
WHERE pjp.job_id = $1
ORDER BY pjp.created_at ASC, pjp.id ASC`, jobID, companyID)
if err != nil {
return nil, err
}
defer rows.Close()
full := make([]V1ProcessJobItem, 0)
for rows.Next() {
var (
ean, itemStatus string
category, categoryName, title, metaTitle, metaDesc, descTxt *string
attrsJSON, mappedJSON, rawJSON []byte
itemError *string
processedID *uuid.UUID
rawProductID *uuid.UUID
)
if err := rows.Scan(
&ean, &category, &categoryName, &title, &metaTitle, &metaDesc, &descTxt,
&attrsJSON, &mappedJSON, &rawJSON, &itemStatus, &itemError, &processedID, &rawProductID,
); err != nil {
return nil, err
}
if processedID == nil {
st := MapV1JobItemStatus(itemStatus, false)
if st == "processed" || st == "processing" || st == "pending" {
st = "not_found"
}
item := V1ProcessJobItem{
"ean": ean,
"status": st,
"error": "Product data not available",
}
if itemError != nil && *itemError != "" {
item["error"] = *itemError
}
applyV1ProcessItemIDs(item, nil, rawProductID)
full = append(full, item)
continue
}
attrs := map[string]any{}
if len(attrsJSON) > 0 {
var raw any
if err := json.Unmarshal(attrsJSON, &raw); err == nil {
switch t := raw.(type) {
case map[string]any:
attrs = t
case []any:
for _, entry := range t {
if m, ok := entry.(map[string]any); ok {
if k, ok := m["key"].(string); ok && k != "" {
attrs[k] = m
}
}
}
}
}
}
var mapped, rawData map[string]any
_ = json.Unmarshal(mappedJSON, &mapped)
_ = json.Unmarshal(rawJSON, &rawData)
main, more := catalog.ExtractProductImages(mapped, rawData)
var description any
if descTxt != nil && strings.TrimSpace(*descTxt) != "" {
description = []string{*descTxt}
} else {
description = nil
}
item := V1ProcessJobItem{
"ean": ean,
"status": MapV1JobItemStatus(itemStatus, true),
"category": nullIfEmptyPtr(category),
"category_name": nullIfEmptyPtr(categoryName),
"title": nullIfEmptyPtr(title),
"meta_title": nullIfEmptyPtr(metaTitle),
"meta_description": nullIfEmptyPtr(metaDesc),
"description": description,
"attributes": nil,
"main_image": nil,
"more_images": nil,
"eprel": extractEPRELFromAttrs(attrs),
}
applyV1ProcessItemIDs(item, processedID, rawProductID)
if itemError != nil && *itemError != "" {
item["error"] = *itemError
}
if len(attrs) > 0 {
item["attributes"] = attrs
}
if main != "" {
item["main_image"] = main
}
if len(more) > 0 {
item["more_images"] = more
}
full = append(full, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return ProjectV1ProcessJobItems(processingType, full), nil
}
func nullIfEmptyPtr(s *string) any {
if s == nil || strings.TrimSpace(*s) == "" {
return nil
}
return *s
}
func extractEPRELFromAttrs(attrs map[string]any) any {
if attrs == nil {
return nil
}
if e, ok := attrs["eprel"]; ok && e != nil {
return e
}
out := map[string]any{}
for _, k := range []string{"label", "pdf", "energy_class", "energy_scale"} {
if v, ok := attrs["eprel_"+k]; ok && v != nil {
out[k] = v
}
}
if len(out) == 0 {
return nil
}
return out
}
// ProjectV1ProcessJobItems applies legacy partial-type field projection.
func ProjectV1ProcessJobItems(storedType string, items []V1ProcessJobItem) []V1ProcessJobItem {
resolved := resolveV1Steps(storedType)
if resolved.isFull {
return items
}
out := make([]V1ProcessJobItem, 0, len(items))
for _, item := range items {
if _, hasID := item["id"]; !hasID {
if status, _ := item["status"].(string); status == "not_found" || status == "failed" || status == "cancelled" {
out = append(out, item)
continue
}
}
main, _ := item["main_image"].(string)
var more []string
if m, ok := item["more_images"].([]string); ok {
more = m
} else if arr, ok := item["more_images"].([]any); ok {
for _, e := range arr {
if s, ok := e.(string); ok {
more = append(more, s)
}
}
}
eprel := item["eprel"]
ean, _ := item["ean"].(string)
projected := withItemMeta(V1ProcessJobItem{"ean": ean}, item)
if len(resolved.steps) == 0 {
out = append(out, withAlwaysIncluded(projected, main, more, eprel))
continue
}
for step := range resolved.steps {
switch step {
case "category":
projected["category"] = item["category"]
projected["category_name"] = item["category_name"]
case "title":
projected["title"] = item["title"]
projected["meta_title"] = item["meta_title"]
case "description":
projected["description"] = item["description"]
projected["meta_description"] = item["meta_description"]
case "attributes":
projected["attributes"] = item["attributes"]
}
}
out = append(out, withAlwaysIncluded(projected, main, more, eprel))
}
return out
}
func withItemMeta(dst, src V1ProcessJobItem) V1ProcessJobItem {
for _, k := range []string{"status", "error", "id", "processed_product_id", "raw_product_id"} {
if v, ok := src[k]; ok {
dst[k] = v
}
}
return dst
}
// applyV1ProcessItemIDs sets legacy id (= processed UUID) plus additive dual-mode aliases.
// id is preserved for existing integrators; processed_product_id mirrors it; raw_product_id is raw_products.id.
func applyV1ProcessItemIDs(item V1ProcessJobItem, processedID, rawProductID *uuid.UUID) {
if processedID != nil {
s := processedID.String()
item["id"] = s
item["processed_product_id"] = s
}
if rawProductID != nil {
item["raw_product_id"] = rawProductID.String()
}
}
func withAlwaysIncluded(item V1ProcessJobItem, main string, more []string, eprel any) V1ProcessJobItem {
if main != "" {
item["main_image"] = main
} else {
item["main_image"] = nil
}
if len(more) > 0 {
item["more_images"] = more
} else {
item["more_images"] = nil
}
if eprel == nil {
item["eprel"] = nil
} else {
item["eprel"] = eprel
}
return item
}
type v1ResolvedSteps struct {
isFull bool
steps map[string]struct{}
}
func resolveV1Steps(stored string) v1ResolvedSteps {
normalized := strings.ToLower(strings.TrimSpace(stored))
if normalized == "" || normalized == "full" {
return v1ResolvedSteps{isFull: true, steps: map[string]struct{}{}}
}
if normalized == "both" {
return v1ResolvedSteps{steps: map[string]struct{}{"title": {}, "description": {}}}
}
if strings.HasPrefix(normalized, "[") {
var parsed []any
if err := json.Unmarshal([]byte(stored), &parsed); err == nil {
steps := map[string]struct{}{}
for _, e := range parsed {
if step := normalizeV1Step(fmt.Sprint(e)); step != "" {
steps[step] = struct{}{}
}
}
return v1ResolvedSteps{steps: steps}
}
}
if step := normalizeV1Step(normalized); step != "" {
return v1ResolvedSteps{steps: map[string]struct{}{step: {}}}
}
// Unknown stored types (normalize_only, enhance_only, …) → treat as full projection.
return v1ResolvedSteps{isFull: true, steps: map[string]struct{}{}}
}