Files
MealQuest/internal/dinners/dinners.go
T
orchestrator-agent 34e84964a3
CI / ci (pull_request) Successful in 9m41s
CI / ci (push) Successful in 8m42s
Feature 23: Meal photos for manually added dinners (#207)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 23:47:49 -04:00

739 lines
25 KiB
Go

// Package dinners keeps the household's dinners: adding Regulars by name or
// recipe link, listing Regulars and the Quest log, editing, archiving and
// favoriting, and grounding each dinner in a recipe. Names are deduplicated with the harness's dinner-name rules,
// so the Regulars the household types and the Side quests the AI suggests
// are compared the same way.
package dinners
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
"net/url"
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"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"
)
// Dinner statuses (the dinners.status column).
const (
StatusSuggested = "suggested"
StatusQuestLog = "quest_log"
StatusTried = "tried"
StatusRegular = "regular"
StatusArchived = "archived"
)
// Limits on what a member may enter.
const (
MaxNameChars = 120
MaxRecipeURL = 2048
MaxMinutes = 1440 // a day; a crock-pot dinner fits
photoListWidth = 400 // the stored photo size lists show
)
// Methods and Difficulties are the values the dinners table allows.
var (
Methods = []string{"stovetop", "oven", "crockpot", "grill", "other"}
Difficulties = []string{"easy", "medium", "hard"}
)
// listable are the statuses List answers for.
var listable = []string{StatusRegular, StatusQuestLog}
// ErrNotFound is returned for an unknown dinner id.
var ErrNotFound = errors.New("dinner not found")
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
// link may carry a personal token, so it stays in the database for
// grounding and never leaves the server.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Origin string `json:"origin"`
Method *string `json:"method"`
MinutesTotal *int64 `json:"minutes_total"`
Difficulty *string `json:"difficulty"`
Cuisine *string `json:"cuisine"`
IsFavorite bool `json:"is_favorite"`
RecipeID *string `json:"recipe_id"`
PhotoURL *string `json:"photo_url"`
GroundingStatus string `json:"grounding_status"`
// GroundingError is why grounding failed, one of the Reason codes;
// null unless GroundingStatus is failed.
GroundingError *string `json:"grounding_error"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
// (PhotoBackfillEligible): false for one with a pasted recipe link,
// which a search never replaces.
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
}
// NewRegular is a Regular to add: its Name, which the household always
// gives, and optionally the RecipeURL to ground it in. The name is never
// taken from the link: a pasted link may carry a personal token anywhere in
// it, and the name is shown in every dinner response.
type NewRegular struct {
Name *string
RecipeURL *string
}
// Patch edits a dinner. Nil fields are left as they are.
type Patch struct {
Name *string
Method *string
MinutesTotal *int
Difficulty *string
}
// Problem is one invalid field, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a request.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid dinner: " + strings.Join(parts, "; ")
}
func invalid(field, reason string) *ValidationError {
return &ValidationError{Problems: []Problem{{Field: field, Reason: reason}}}
}
// DuplicateError is returned when a new or renamed dinner repeats an
// unarchived one: its name is harness.Similar, or it has the same recipe
// link.
type DuplicateError struct {
Existing Dinner
}
func (e *DuplicateError) Error() string {
return fmt.Sprintf("%q is already a dinner (%s)", e.Existing.Name, e.Existing.Status)
}
// Service reads and changes dinners.
type Service struct {
st *store.Store
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// backfillJob enqueues KindBackfillPhotos jobs.
backfillJob *jobs.Kind[BackfillJob]
// wake is called after every committed change to a dinner's method or
// minutes, which can move its crock-pot start reminder.
wake func()
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
// offerKey keys the candidate ids Candidates offers, so they are
// opaque and stable for this process.
offerKey []byte
// offersMu guards offers and offerSearches. offers holds, by dinner
// id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing stale ones.
offersMu sync.Mutex
offers map[string]*offerSet
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder,
// calls wake (the scheduler's Wake) after every committed change to a
// dinner's method or minutes, and registers the KindGroundDinner and
// KindBackfillPhotos jobs on runner, which must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("dinners: nil store")
case grounder == nil:
return nil, errors.New("dinners: nil grounder")
case runner == nil:
return nil, errors.New("dinners: nil job runner")
case wake == nil:
return nil, errors.New("dinners: nil wake")
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
}
s.groundJob = kind
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
if err != nil {
return nil, err
}
if err := backfill.ServiceOnly(); err != nil {
return nil, err
}
s.backfillJob = backfill
return s, nil
}
// Created is a new Regular with the id of the job grounding it, which is
// nil when the job could not be enqueued and the dinner's grounding is
// failed instead.
type Created struct {
Dinner
GroundingJobID *string `json:"grounding_job_id"`
}
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
// grounding when given, with status and origin regular and grounding_status
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
// waiting for the job. A name repeating an unarchived dinner is a
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
// returned with grounding_status failed (ReasonGroundingError) and no job
// id, so the household can fix its recipe by hand.
func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy string) (Created, error) {
d, err := s.createRegular(ctx, r, updatedBy)
if err != nil {
return Created{}, err
}
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
if err != nil {
return Created{}, err
}
if jobID == nil {
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
if err != nil {
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
}
return Created{Dinner: fromRow(row)}, nil
}
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
}
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
// at grounding_revision revision and returns its id. When the job cannot be
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
// it is not left pending with no job, and the id is nil.
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
if err == nil {
return &jobID, nil
}
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
UpdatedAt: store.Now().String(),
ID: id,
GroundingRevision: revision,
}); err != nil {
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
}
return nil, nil
}
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
if updatedBy == "" {
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
}
if r.Name == nil {
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
}
name, err := checkName(*r.Name)
if err != nil {
return db.Dinner{}, err
}
var recipeURL sql.NullString
if r.RecipeURL != nil {
u, err := checkRecipeURL(*r.RecipeURL)
if err != nil {
return db.Dinner{}, err
}
recipeURL = sql.NullString{String: u.String(), Valid: true}
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
return err
}
now := store.Now().String()
var err error
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusRegular,
Origin: "regular",
RecipeUrl: recipeURL,
GroundingStatus: "pending",
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: create: %w", err)
}
return nil
})
if err != nil {
return db.Dinner{}, err
}
return row, nil
}
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
// for Cook from what I have, quick for Quick dinners.
var IdeaOrigins = []string{"ingredients", "quick"}
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
// the recipe the idea was already grounded in, and its origin, one of
// IdeaOrigins.
type NewQuestLog struct {
Name string
RecipeID string
Origin string
}
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
// (its method, minutes, difficulty and photo), and returns it with
// created true. When an unarchived dinner already repeats it, by the same
// rule that refuses a duplicate Regular (a harness.Similar name, or the
// recipe's link), that dinner is returned as it is, with created false,
// so adding the same idea again never makes a second dinner: a Regular
// stays a Regular and a Quest log dinner stays in the Quest log. A
// repeated dinner still awaiting the household's choice (a suggested Side
// quest, or one tried and awaiting its Rotation check) is a
// *DuplicateError and changes nothing: adding it would make that choice
// for them. An unknown recipe is an error.
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
if updatedBy == "" {
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
}
if !slices.Contains(IdeaOrigins, n.Origin) {
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
}
name, err := checkName(n.Name)
if err != nil {
return Dinner{}, false, err
}
var row db.Dinner
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
recipe, err := q.GetRecipe(ctx, n.RecipeID)
if err != nil {
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
}
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
if err != nil {
return err
}
if found {
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
return &DuplicateError{Existing: fromRow(existing)}
}
row = existing
return nil
}
now := store.Now().String()
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: StatusQuestLog,
Origin: n.Origin,
Method: recipe.Method,
MinutesTotal: recipe.MinutesTotal,
Difficulty: recipe.Difficulty,
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
PhotoID: recipe.PhotoID,
GroundingStatus: GroundingGrounded,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("dinners: add to Quest log: %w", err)
}
created = true
return nil
})
if err != nil {
return Dinner{}, false, err
}
return fromRow(row), created, nil
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name. The Quest log is the dinners
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
// household's current week or a later one is left out of it.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
var rows []db.Dinner
var err error
if status == StatusQuestLog {
rows, err = s.listQuestLog(ctx)
} else {
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
}
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
out := make([]Dinner, len(rows))
for i, row := range rows {
out[i] = fromRow(row)
}
return out, nil
}
// listQuestLog reads the Quest log in one read transaction, so the
// household's current week and the plans come from the same snapshot.
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return nil, fmt.Errorf("read household settings: %w", err)
}
loc, err := household.LoadZone(settings.Timezone)
if err != nil {
return nil, fmt.Errorf("household settings: %w", err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
}
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner raises its
// grounding_revision and drops the candidates offered for its old name:
// each was offered at an earlier revision, so none of them grounds it.
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
// job that grounds the new name, and clears its recipe_url when the
// normalized name changes: a job queued or running for the old name or
// link carries the old revision, so it can no longer write the dinner, and
// one running is stopped.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
}
name, err := p.validate()
if err != nil {
return Dinner{}, err
}
var row db.Dinner
var renamed, regrounding bool
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)
}
params := db.UpdateDinnerParams{
ID: cur.ID,
Name: cur.Name,
NormalizedName: cur.NormalizedName,
Status: cur.Status,
Origin: cur.Origin,
Method: cur.Method,
MinutesTotal: cur.MinutesTotal,
Difficulty: cur.Difficulty,
Cuisine: cur.Cuisine,
RecipeID: cur.RecipeID,
RecipeUrl: cur.RecipeUrl,
PhotoID: cur.PhotoID,
GroundingStatus: cur.GroundingStatus,
GroundingRevision: cur.GroundingRevision,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
}
if p.Name != nil {
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
return err
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if name != cur.Name {
params.GroundingRevision++
}
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
}
}
if p.Method != nil {
params.Method = sql.NullString{String: *p.Method, Valid: true}
}
if p.MinutesTotal != nil {
params.MinutesTotal = sql.NullInt64{Int64: int64(*p.MinutesTotal), Valid: true}
}
if p.Difficulty != nil {
params.Difficulty = sql.NullString{String: *p.Difficulty, Valid: true}
}
row, err = q.UpdateDinner(ctx, params)
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
renamed = row.GroundingRevision != cur.GroundingRevision
regrounding = renamed && cur.GroundingStatus == GroundingPending
return nil
})
if err != nil {
return Dinner{}, err
}
if p.Method != nil || p.MinutesTotal != nil {
s.wake()
}
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil {
return Dinner{}, err
}
if jobID == nil {
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
if err != nil {
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
}
}
}
return fromRow(row), nil
}
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped, and its candidates no
// longer ground it.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ArchiveDinner(ctx, db.ArchiveDinnerParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
// ToggleFavorite flips dinner id's is_favorite, recording updatedBy.
func (s *Service) ToggleFavorite(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: favorite: no member to record as updated_by")
}
row, err := db.New(s.st.Writer).ToggleDinnerFavorite(ctx, db.ToggleDinnerFavoriteParams{
ID: id,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: store.Now().String(),
})
if errors.Is(err, sql.ErrNoRows) {
return Dinner{}, ErrNotFound
}
if err != nil {
return Dinner{}, fmt.Errorf("dinners: favorite %s: %w", id, err)
}
return fromRow(row), nil
}
// validate checks every field of p and returns its trimmed name.
func (p Patch) validate() (string, error) {
var problems []Problem
var name string
if p.Name == nil && p.Method == nil && p.MinutesTotal == nil && p.Difficulty == nil {
return "", invalid("patch", "no fields to change")
}
if p.Name != nil {
n, err := checkName(*p.Name)
var verr *ValidationError
if errors.As(err, &verr) {
problems = append(problems, verr.Problems...)
}
name = n
}
if p.Method != nil && !slices.Contains(Methods, *p.Method) {
problems = append(problems, Problem{"method", "must be one of " + strings.Join(Methods, ", ")})
}
if p.MinutesTotal != nil && (*p.MinutesTotal < 1 || *p.MinutesTotal > MaxMinutes) {
problems = append(problems, Problem{"minutes_total", fmt.Sprintf("must be 1 to %d", MaxMinutes)})
}
if p.Difficulty != nil && !slices.Contains(Difficulties, *p.Difficulty) {
problems = append(problems, Problem{"difficulty", "must be one of " + strings.Join(Difficulties, ", ")})
}
if problems != nil {
return "", &ValidationError{Problems: problems}
}
return name, nil
}
// checkName returns raw trimmed, refusing a name that is empty, too long or
// has no words left once normalized (so it could not be deduplicated).
func checkName(raw string) (string, error) {
name := strings.TrimSpace(raw)
switch {
case name == "":
return "", invalid("name", "must not be empty")
case utf8.RuneCountInString(name) > MaxNameChars:
return "", invalid("name", fmt.Sprintf("must be at most %d characters", MaxNameChars))
case harness.Normalize(name) == "":
return "", invalid("name", "must name a dish, not only filler words")
}
return name, nil
}
// checkRecipeURL parses raw as an absolute https:// link to a recipe page,
// the only kind recipe grounding fetches.
func checkRecipeURL(raw string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if len(raw) > MaxRecipeURL {
return nil, invalid("recipe_url", fmt.Sprintf("must be at most %d bytes", MaxRecipeURL))
}
u, err := url.Parse(raw)
switch {
case err != nil:
return nil, invalid("recipe_url", "is not a URL")
case u.Scheme != "https" || u.Hostname() == "":
return nil, invalid("recipe_url", "must be an https:// link")
case u.User != nil:
return nil, invalid("recipe_url", "must not carry a user name or password")
}
return u, nil
}
// checkUnique returns a *DuplicateError when name is harness.Similar to an
// unarchived dinner other than self, or recipeURL is one's recipe link: the
// source_url of the recipe it is grounded in, else the recipe_url still
// awaiting grounding.
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
if err != nil {
return err
}
if found {
return &DuplicateError{Existing: fromRow(row)}
}
return nil
}
// findRepeat returns the first unarchived dinner other than self that
// name or recipeURL repeats, as checkUnique decides it, and whether there
// is one.
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
rows, err := q.ListUnarchivedDinners(ctx)
if err != nil {
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
}
for _, row := range rows {
if row.Dinner.ID == self {
continue
}
link := row.Dinner.RecipeUrl
if row.RecipeSourceUrl.Valid {
link = row.RecipeSourceUrl
}
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
return row.Dinner, true, nil
}
}
return db.Dinner{}, false, nil
}
// PhotoURL is where the API serves a list-sized copy of photo id.
func PhotoURL(id string) string {
return photoURL(id, photoListWidth)
}
// photoURL is where the API serves photo id at stored width size.
func photoURL(id string, size int) string {
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
}
func fromRow(r db.Dinner) Dinner {
d := Dinner{
ID: r.ID,
Name: r.Name,
Status: r.Status,
Origin: r.Origin,
Method: nullString(r.Method),
Difficulty: nullString(r.Difficulty),
Cuisine: nullString(r.Cuisine),
IsFavorite: r.IsFavorite == 1,
RecipeID: nullString(r.RecipeID),
GroundingStatus: r.GroundingStatus,
GroundingError: nullString(r.GroundingError),
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PhotoBackfillEligible: PhotoBackfillEligible(r),
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
}
if r.PhotoID.Valid {
u := PhotoURL(r.PhotoID.String)
d.PhotoURL = &u
}
return d
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}