package dinners import ( "context" "crypto/hmac" "crypto/sha256" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "math" "slices" "strings" "time" "unicode/utf8" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) // KindGroundDinner is the job that grounds a new Regular in a recipe. const KindGroundDinner = "ground_dinner" // Grounding statuses (the dinners.grounding_status column). const ( GroundingPending = "pending" GroundingGrounded = "grounded" GroundingFailed = "failed" GroundingManual = "manual" ) // Reasons a dinner's background grounding failed, stored in // dinners.grounding_error and as the KindGroundDinner job's failure code. const ( // ReasonNoRecipe: no page the search found holds a usable recipe. ReasonNoRecipe = "no_recipe_found" // ReasonRecipeUnusable: the dinner's recipe_url holds no usable recipe. ReasonRecipeUnusable = "recipe_unusable" // ReasonSearchNotConfigured: recipe search is not set up. ReasonSearchNotConfigured = "search_not_configured" // ReasonSearchFailed: the search provider failed. ReasonSearchFailed = "search_failed" // ReasonAIFailed: normalize_recipe's model provider could not be built // or called. ReasonAIFailed = "ai_failed" // ReasonInterrupted: the server stopped while grounding. ReasonInterrupted = "interrupted" // ReasonGroundingError: any other failure, such as the database's. ReasonGroundingError = "grounding_error" ) // Reasons are every grounding failure reason. var Reasons = []string{ ReasonNoRecipe, ReasonRecipeUnusable, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonAIFailed, ReasonInterrupted, ReasonGroundingError, } // Limits on candidates and on a recipe entered by hand. const ( MaxCandidates = 3 MaxManualLines = 100 MaxManualLineChar = 500 // manualSourcePrefix starts the source_url of a recipe entered by hand, // followed by the recipe's id; it is never a fetchable page. manualSourcePrefix = "mealquest:manual:" ) // IsManualRecipe reports whether sourceURL, a recipes.source_url, is that // of a recipe entered by hand. Its ingredients are stored as typed, in // raw_text only: no model normalizes them, so name, qty and unit are NULL. func IsManualRecipe(sourceURL string) bool { return strings.HasPrefix(sourceURL, manualSourcePrefix) } // GroundTimeout bounds grounding a candidate or link by hand, which runs in // the request: the page fetch (recipes.Timeout), its photo download and // normalize_recipe (harness.DefaultTimeout), with some slack. const GroundTimeout = 90 * time.Second // groundTimeout is GroundTimeout; tests shorten it. var groundTimeout = GroundTimeout // ErrGroundTimeout is returned by Ground when grounding a candidate or link // runs past GroundTimeout; the dinner is left as it was. var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long") // ErrGroundFailed wraps any unexpected failure grounding a dinner by hand. // Its text may quote the link and dependency errors, so it is never // logged. var ErrGroundFailed = errors.New("dinners: grounding the recipe failed") // ErrCandidatesFailed wraps any unexpected failure finding a dinner's // candidates. Its text may quote a candidate's URL and dependency errors, // so it is never logged. var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed") // ErrArchived is grounding an archived dinner by hand. var ErrArchived = errors.New("dinner archived") // ErrGroundConflict is a candidate or link grounded for a dinner that was // renamed or grounded some other way while it was grounding. var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding") // GroundJob is the KindGroundDinner payload: the dinner to ground and its // grounding_revision when the job was enqueued. type GroundJob struct { DinnerID string `json:"dinner_id"` Revision int64 `json:"revision"` } // groundResult is the KindGroundDinner result: the dinner's grounding // status once the job ends. It is not grounded by this job when the dinner // was grounded by hand before the job finished, renamed (a later job // grounds it) or archived; the job then reports the status it found. type groundResult struct { DinnerID string `json:"dinner_id"` GroundingStatus string `json:"grounding_status"` } func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // runGroundJob grounds a pending dinner as its creation asked: its // recipe_url with GroundURL, else its name with Ground. Success records // the recipe and photo with grounding_status grounded, and the recipe's // method, minutes and difficulty where a member has not set them; failure // records grounding_status failed with its Reason, which is also the job's // failure code. A dinner archived, no longer pending, or at a // grounding_revision other than p.Revision is not grounded at all, and // either result is recorded only while the dinner is still unarchived, // pending and at p.Revision: a dinner grounded by hand meanwhile is left as // it is, and one renamed or archived meanwhile is left pending — for the // job its rename enqueued, or for none. Archiving or renaming the dinner // stops the grounding where it is, so no further search, fetch, photo // download or model call is made for it. func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) { // Tracked before the dinner is read: an archive or rename committed // after the read finds this job to stop. groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision) defer done() row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID) if err != nil { return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err) } if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision { return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil } var gr recipes.Grounded if row.RecipeUrl.Valid { gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String) } else { gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name) } if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) { return s.currentGrounding(ctx, row.ID) } if err != nil { reason := reasonFor(err, row.RecipeUrl.Valid) if ctx.Err() != nil { reason = ReasonInterrupted } // Only the reason is logged: the error may quote a dependency's // response, which jobs never store, return or log. s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason) write := context.WithoutCancel(ctx) if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{ GroundingError: sql.NullString{String: reason, Valid: true}, UpdatedAt: store.Now().String(), ID: row.ID, GroundingRevision: p.Revision, }); failErr != nil { return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr) } if ctx.Err() != nil { return groundResult{}, ctx.Err() } return groundResult{}, &jobs.Failure{Code: reason} } n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{ RecipeID: sql.NullString{String: gr.RecipeID, Valid: true}, PhotoID: sql.NullString{String: gr.PhotoID, Valid: true}, Method: sql.NullString{String: gr.Method, Valid: true}, MinutesTotal: nullInt(gr.MinutesTotal), Difficulty: sql.NullString{String: gr.Difficulty, Valid: true}, UpdatedAt: store.Now().String(), ID: row.ID, GroundingRevision: p.Revision, }) if err != nil { return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err) } if n == 0 { return s.currentGrounding(ctx, row.ID) } s.wake() return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil } // currentGrounding is the KindGroundDinner result of a job that did not // ground dinner id: the grounding status the dinner has now. func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) { cur, err := db.New(s.st.Reader).GetDinner(ctx, id) if err != nil { return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err) } return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil } // errGroundingStopped is the cause a running KindGroundDinner job's // grounding is cancelled with when its dinner is archived or renamed. var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed") // runningGrounding is a KindGroundDinner job running for a dinner at // revision, with the cancel of its grounding. type runningGrounding struct { revision int64 cancel context.CancelCauseFunc } // trackGrounding returns a context for grounding dinnerID at revision that // stopGroundings cancels, and the func to call when the job ends. func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) { groundCtx, cancel := context.WithCancelCause(ctx) r := &runningGrounding{revision: revision, cancel: cancel} s.groundingsMu.Lock() if s.groundings[dinnerID] == nil { s.groundings[dinnerID] = make(map[*runningGrounding]struct{}) } s.groundings[dinnerID][r] = struct{}{} s.groundingsMu.Unlock() return groundCtx, func() { s.groundingsMu.Lock() delete(s.groundings[dinnerID], r) if len(s.groundings[dinnerID]) == 0 { delete(s.groundings, dinnerID) } s.groundingsMu.Unlock() cancel(nil) } } // stopGroundings cancels, with errGroundingStopped, the grounding of every // KindGroundDinner job running for dinnerID at a revision below below. Call // it once the change that makes those jobs stale is committed. func (s *Service) stopGroundings(dinnerID string, below int64) { s.groundingsMu.Lock() defer s.groundingsMu.Unlock() for r := range s.groundings[dinnerID] { if r.revision < below { r.cancel(errGroundingStopped) } } } // FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived // dinner still pending whose KindGroundDinner job is no longer queued or // running: a previous process stopped before enqueuing the job or before // its handler recorded a result. An archived dinner is left pending, since // archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's // Start has failed the jobs that process left running, so no dinner stays // pending with nothing left to ground it. It returns how many it failed. func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) { n, err := db.New(s.st.Writer).FailOrphanedPendingDinnerGroundings(ctx, db.FailOrphanedPendingDinnerGroundingsParams{ GroundingError: sql.NullString{String: ReasonInterrupted, Valid: true}, UpdatedAt: store.Now().String(), Kind: KindGroundDinner, }) if err != nil { return 0, fmt.Errorf("dinners: fail interrupted groundings: %w", err) } if n > 0 { s.logger.WarnContext(ctx, "failed dinner groundings interrupted by restart", "count", n) } return n, nil } // reasonFor is the Reason for a grounding error; fromURL says whether the // dinner's recipe_url was grounded rather than its name. func reasonFor(err error, fromURL bool) string { var ungroundable *recipes.ErrUngroundable switch { case errors.As(err, &ungroundable) && fromURL: return ReasonRecipeUnusable case errors.As(err, &ungroundable): return ReasonNoRecipe case errors.Is(err, recipes.ErrSearchNotConfigured): return ReasonSearchNotConfigured case errors.Is(err, recipes.ErrSearchFailed): return ReasonSearchFailed case errors.Is(err, recipes.ErrNormalizeFailed): return ReasonAIFailed } return ReasonGroundingError } // Candidate is a recipe page offered to ground a dinner in. type Candidate struct { // CandidateID is an opaque id for the page, resolved by the server: // Fix.CandidateID grounds it. The page's URL is never returned, as it // may carry a token. CandidateID string `json:"candidate_id"` Title string `json:"title"` // SiteName is the page's site as SourceOf names it, null when it // cannot be named without leaking the URL. SiteName *string `json:"site_name"` // PhotoURL is the cached photo of a page already grounded, else null. PhotoURL *string `json:"photo_url"` Minutes *int `json:"minutes"` } // offerLifetime is how long the candidates Candidates offers a dinner // ground it; tests shorten it. var offerLifetime = 30 * time.Minute // now is the clock offer lifetimes are measured on; tests advance it. var now = time.Now // updateCommitted runs in Update once a rename is committed, before the // dinner's offers are cleared; tests use it to act in between. var updateCommitted = func() {} // offerSet is the candidates last offered for a dinner: the page URL of // each candidate id, found by searching the dinner's name at its // grounding_revision revision. They ground the dinner until deadline, and // only while it is still at that revision, which every rename raises; // expiry drops the set from offers once deadline has passed. type offerSet struct { urls map[string]string revision int64 deadline time.Time expiry *time.Timer } // offerSearch is a Candidates search running for a dinner. It stores no // offers found at a grounding_revision below below, which clearing the // dinner's offers raises. type offerSearch struct { below int64 } // Candidates searches for dinner id's name and returns up to MaxCandidates // recipe pages to ground it in, best first; the list may be empty. They // replace the dinner's earlier candidates: one not offered again no longer // grounds it. They ground it for offerLifetime, and not at all once it is // grounded by hand, renamed or archived, even while this search runs. A // missing dinner is ErrNotFound; any other failure wraps // ErrCandidatesFailed, and a search that fails also wraps // recipes.ErrSearchFailed. func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) { // Tracked before the dinner is read: grounding, renaming or archiving // it after the read keeps this search from storing what it found. keep, done := s.trackOffers(id) defer done() row, err := s.get(ctx, id) if errors.Is(err, ErrNotFound) { return nil, err } if err != nil { return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err) } offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates) if err != nil { return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err) } out := make([]Candidate, len(offers)) urls := make(map[string]string, len(offers)) for i, o := range offers { src, err := SourceOf(sql.NullString{String: o.SiteName, Valid: o.SiteName != ""}, o.URL) if err != nil { return nil, fmt.Errorf("%w: dinner %s: candidate %d: %w", ErrCandidatesFailed, id, i+1, err) } cid := s.candidateID(row.ID, o.URL) urls[cid] = o.URL out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: src.SiteName, Minutes: o.Minutes} if o.PhotoID != "" { u := PhotoURL(o.PhotoID) out[i].PhotoURL = &u } } if row.Status != StatusArchived { keep(urls, row.GroundingRevision) } return out, nil } // trackOffers registers a Candidates search for dinner id. It returns keep, // which stores urls, found for the dinner at grounding_revision revision, // as its offers for offerLifetime unless clearOffers has run for it since // with a revision above that, or offers found at a later revision are // stored, and done, to call when the search ends. func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) { search := &offerSearch{} s.offersMu.Lock() if s.offerSearches[id] == nil { s.offerSearches[id] = make(map[*offerSearch]struct{}) } s.offerSearches[id][search] = struct{}{} s.offersMu.Unlock() keep = func(urls map[string]string, revision int64) { s.offersMu.Lock() defer s.offersMu.Unlock() if revision < search.below { return } if set := s.offers[id]; set != nil && set.revision > revision { // Found at a later revision, so the earlier search's offers // are already stale. return } s.dropOffers(id) set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)} // expiry waits for offersMu, so it finds set stored even when it // fires at once, and drops set only, never offers stored later. set.expiry = time.AfterFunc(offerLifetime, func() { s.offersMu.Lock() defer s.offersMu.Unlock() if s.offers[id] == set { delete(s.offers, id) } }) s.offers[id] = set } done = func() { s.offersMu.Lock() delete(s.offerSearches[id], search) if len(s.offerSearches[id]) == 0 { delete(s.offerSearches, id) } s.offersMu.Unlock() } return keep, done } // clearOffers drops dinner id's offers found at a grounding_revision below // below and keeps every Candidates search running for it from storing // such offers, so none of them grounds it. Offers found at below or above // are kept. Call it with math.MaxInt64 once the dinner is grounded by hand // or archived, and with its new revision once it is renamed: a search // that read the rename meanwhile keeps its offers. func (s *Service) clearOffers(id string, below int64) { s.offersMu.Lock() defer s.offersMu.Unlock() if set := s.offers[id]; set != nil && set.revision < below { s.dropOffers(id) } for search := range s.offerSearches[id] { search.below = max(search.below, below) } } // dropOffers drops dinner id's offers and stops their expiry. The caller // holds offersMu. func (s *Service) dropOffers(id string) { if set := s.offers[id]; set != nil { set.expiry.Stop() delete(s.offers, id) } } // candidateID is the opaque id of the page at rawURL as a candidate for // dinner id: the same page is offered under the same id until the service // restarts, and the id reveals nothing of the URL. func (s *Service) candidateID(id, rawURL string) string { mac := hmac.New(sha256.New, s.offerKey) mac.Write([]byte(id + "\x00" + rawURL)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) } // candidateURL is the page URL of candidate cid last offered for dinner d. // A cid not among the dinner's live offers, or whose page is not a valid // recipe link, is a *ValidationError. Offers past their deadline, or found // at a grounding_revision other than d's, are not live, even before they // are dropped: d was renamed since they were found, if only to its name // then. func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) { var raw string var ok bool s.offersMu.Lock() if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) { raw, ok = set.urls[cid] } s.offersMu.Unlock() if ok { if u, err := checkRecipeURL(raw); err == nil { return u.String(), nil } } return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates") } // Fix grounds a dinner by hand: exactly one of a candidate from // Candidates, a pasted recipe link or a recipe typed in. type Fix struct { CandidateID *string RecipeURL *string Manual *ManualRecipe } // ManualRecipe is a recipe typed in by the household. type ManualRecipe struct { // Ingredients are the ingredient lines, at least one. Ingredients []string // Steps are the instructions, in order; there may be none. Steps []string Minutes *int Method *string } // Ground grounds dinner id as f says, recording updatedBy. A candidate or // link is grounded with recipes.Grounder.GroundURL and sets // grounding_status grounded, with the recipe's photo, method, minutes and // difficulty; one a member changed while it was grounding is kept. A // manual recipe is stored as typed — no model runs — and sets // grounding_status manual with its method and minutes; it has no photo, // and the dinner keeps its difficulty. An invalid f is a *ValidationError // and an archived dinner ErrArchived. A failure grounding a candidate or // link, or recording it on the dinner, wraps ErrGroundFailed; a page that // cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner // renamed, archived or grounded some other way while a candidate or link // was grounding is ErrGroundConflict or ErrArchived. A missing dinner is // ErrNotFound; any other failure, by candidate, link or manual recipe, // wraps ErrGroundFailed. None of these changes the dinner. func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) { if updatedBy == "" { return Dinner{}, errors.New("dinners: ground: no member to record as updated_by") } link, manual, err := f.validate() if err != nil { return Dinner{}, err } if manual != nil { return s.groundManual(ctx, id, *manual, updatedBy) } cur, err := s.get(ctx, id) if errors.Is(err, ErrNotFound) { return Dinner{}, err } if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } if cur.Status == StatusArchived { return Dinner{}, ErrArchived } if f.CandidateID != nil { if link, err = s.candidateURL(cur, *f.CandidateID); err != nil { return Dinner{}, err } } groundCtx, cancel := context.WithTimeout(ctx, groundTimeout) defer cancel() gr, err := s.grounder.GroundURL(groundCtx, link) if err != nil { if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) { return Dinner{}, ErrGroundTimeout } return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{ RecipeID: sql.NullString{String: gr.RecipeID, Valid: true}, PhotoID: sql.NullString{String: gr.PhotoID, Valid: true}, Method: sql.NullString{String: gr.Method, Valid: true}, MinutesTotal: nullInt(gr.MinutesTotal), Difficulty: sql.NullString{String: gr.Difficulty, Valid: true}, GroundingStatus: GroundingGrounded, UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedAt: store.Now().String(), ID: cur.ID, ObservedName: cur.Name, ObservedNormalizedName: cur.NormalizedName, ObservedGroundingStatus: cur.GroundingStatus, ObservedGroundingRevision: cur.GroundingRevision, ObservedRecipeID: cur.RecipeID, ObservedMethod: cur.Method, ObservedMinutesTotal: cur.MinutesTotal, ObservedDifficulty: cur.Difficulty, }) if errors.Is(err, sql.ErrNoRows) { return Dinner{}, s.groundRejected(ctx, id) } if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err) } s.wake() s.clearOffers(cur.ID, math.MaxInt64) return fromRow(row), nil } // groundRejected is the error for dinner id when it was not as observed // before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing // to read it wraps ErrGroundFailed. func (s *Service) groundRejected(ctx context.Context, id string) error { cur, err := s.get(ctx, id) if errors.Is(err, ErrNotFound) { return err } if err != nil { return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } if cur.Status == StatusArchived { return ErrArchived } return ErrGroundConflict } // groundManual stores m as dinner id's recipe, with its ingredients and // steps, and records it on the dinner, all in one transaction; the // dinner's candidates then no longer ground it. A missing or archived // dinner is ErrNotFound or ErrArchived; any other failure wraps // ErrGroundFailed. func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) { var row db.Dinner err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) cur, err := q.GetDinner(ctx, id) if errors.Is(err, sql.ErrNoRows) { return ErrNotFound } if err != nil { return fmt.Errorf("dinners: read %s: %w", id, err) } if cur.Status == StatusArchived { return ErrArchived } ld, err := json.Marshal(manualJSONLD{ Context: "https://schema.org", Type: "Recipe", Name: cur.Name, Ingredients: m.Ingredients, Instructions: m.Steps, TotalTime: fmt.Sprintf("PT%dM", *m.Minutes), }) if err != nil { return fmt.Errorf("dinners: encode manual recipe: %w", err) } now := store.Now().String() recipeID := store.NewID() minutes := sql.NullInt64{Int64: int64(*m.Minutes), Valid: true} method := sql.NullString{String: *m.Method, Valid: true} rec, err := q.CreateManualRecipe(ctx, db.CreateManualRecipeParams{ ID: recipeID, SourceUrl: manualSourcePrefix + recipeID, Title: cur.Name, TotalMinutes: minutes, RawJsonld: string(ld), Method: method, MinutesTotal: minutes, FetchedAt: now, CreatedAt: now, UpdatedAt: now, }) if err != nil { return fmt.Errorf("dinners: store manual recipe: %w", err) } for i, line := range m.Ingredients { if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{ ID: store.NewID(), RecipeID: rec.ID, Position: int64(i), RawText: line, CreatedAt: now, UpdatedAt: now, }); err != nil { return fmt.Errorf("dinners: store manual ingredient %d: %w", i+1, err) } } for i, text := range m.Steps { if err := q.CreateRecipeStep(ctx, db.CreateRecipeStepParams{ ID: store.NewID(), RecipeID: rec.ID, Position: int64(i), Text: text, CreatedAt: now, UpdatedAt: now, }); err != nil { return fmt.Errorf("dinners: store manual step %d: %w", i+1, err) } } row, err = q.GroundDinner(ctx, db.GroundDinnerParams{ RecipeID: sql.NullString{String: rec.ID, Valid: true}, Method: method, MinutesTotal: minutes, Difficulty: cur.Difficulty, GroundingStatus: GroundingManual, UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, UpdatedAt: now, ID: cur.ID, ObservedName: cur.Name, ObservedNormalizedName: cur.NormalizedName, ObservedGroundingStatus: cur.GroundingStatus, ObservedGroundingRevision: cur.GroundingRevision, ObservedRecipeID: cur.RecipeID, ObservedMethod: cur.Method, ObservedMinutesTotal: cur.MinutesTotal, ObservedDifficulty: cur.Difficulty, }) if err != nil { return fmt.Errorf("dinners: ground %s: %w", id, err) } return nil }) if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) { return Dinner{}, err } if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } s.wake() s.clearOffers(row.ID, math.MaxInt64) return fromRow(row), nil } // manualJSONLD is the schema.org Recipe stored as a manual recipe's // raw_jsonld, so it reads like a fetched page's. type manualJSONLD struct { Context string `json:"@context"` Type string `json:"@type"` Name string `json:"name"` Ingredients []string `json:"recipeIngredient"` Instructions []string `json:"recipeInstructions"` TotalTime string `json:"totalTime"` } // validate checks f and returns the link to ground, or the manual recipe // with its lines trimmed. func (f Fix) validate() (link string, manual *ManualRecipe, err error) { given := 0 for _, set := range []bool{f.CandidateID != nil, f.RecipeURL != nil, f.Manual != nil} { if set { given++ } } if given != 1 { return "", nil, invalid("ground", "give exactly one of candidate_id, recipe_url and manual") } switch { case f.CandidateID != nil: // Ground resolves the candidate to its page. return "", nil, nil case f.RecipeURL != nil: u, err := checkRecipeURL(*f.RecipeURL) if err != nil { return "", nil, err } return u.String(), nil, nil } m, err := f.Manual.validate() if err != nil { return "", nil, err } return "", &m, nil } // validate checks every field of m and returns it with its lines trimmed. func (m ManualRecipe) validate() (ManualRecipe, error) { var problems []Problem ings, ingProblem := checkLines(m.Ingredients, 1) if ingProblem != "" { problems = append(problems, Problem{"manual.ingredients", ingProblem}) } steps, stepProblem := checkLines(m.Steps, 0) if stepProblem != "" { problems = append(problems, Problem{"manual.steps", stepProblem}) } if m.Minutes == nil || *m.Minutes < 1 || *m.Minutes > MaxMinutes { problems = append(problems, Problem{"manual.minutes", fmt.Sprintf("must be 1 to %d", MaxMinutes)}) } if m.Method == nil || !slices.Contains(Methods, *m.Method) { problems = append(problems, Problem{"manual.method", "must be one of " + strings.Join(Methods, ", ")}) } if problems != nil { return ManualRecipe{}, &ValidationError{Problems: problems} } return ManualRecipe{Ingredients: ings, Steps: steps, Minutes: m.Minutes, Method: m.Method}, nil } // checkLines returns lines trimmed, or why they are refused: fewer than // least, more than MaxManualLines, or one blank or over MaxManualLineChar. func checkLines(lines []string, least int) ([]string, string) { if len(lines) < least { return nil, fmt.Sprintf("must have at least %d line", least) } if len(lines) > MaxManualLines { return nil, fmt.Sprintf("must have at most %d lines", MaxManualLines) } out := make([]string, len(lines)) for i, line := range lines { line = strings.TrimSpace(line) switch { case line == "": return nil, fmt.Sprintf("line %d is blank", i+1) case utf8.RuneCountInString(line) > MaxManualLineChar: return nil, fmt.Sprintf("line %d is over %d characters", i+1, MaxManualLineChar) } out[i] = line } return out, "" } // get reads dinner id, or ErrNotFound. func (s *Service) get(ctx context.Context, id string) (db.Dinner, error) { row, err := db.New(s.st.Reader).GetDinner(ctx, id) if errors.Is(err, sql.ErrNoRows) { return db.Dinner{}, ErrNotFound } if err != nil { return db.Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err) } return row, nil } func nullInt(v *int) sql.NullInt64 { if v == nil { return sql.NullInt64{} } return sql.NullInt64{Int64: int64(*v), Valid: true} }