Files
foodster/cmd/foodster/bundle.go
T
Esa Kataja d15f741ab1 Add dish CRUD to the UI, including inline from Kirjaa
The catalog could only be filled by importing JSON, which is a poor way to
add the one dish you are about to eat.

Ruoat now covers PRD §7.3 in full: add and edit mains with their categories
and has_sides, add and edit sides, and delete either. Deletes are soft, so a
log entry keeps resolving the dish it used and the freed name can be reused.
Validation messages are Finnish and the rejected form comes back filled in
rather than blank. Bulk import moves into a details element, since it is now
the occasional path rather than the only one.

Kirjaa gets the same ability without the detour: a search that finds nothing
offers to add what was typed, and saving creates the dish and continues
straight to the sides step. An empty catalog shows the same card instead of
dead-ending on a link to another tab, and the search box is no longer hidden
behind the empty state.

The importer's own insert is gone; it and the UI both go through createMain
and createSide, so duplicate detection lives in one place and reason() can
match on errNameTaken instead of poking at driver strings.
2026-09-05 18:44:09 +03:00

154 lines
3.6 KiB
Go

package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
)
// A Bundle is a set of dishes in the mass-import shape from PRD §7.3. The
// same format backs the admin paste/upload and the -import flag, so a test
// fixture and a real import travel identically.
//
// {
// "mains": [{"name": "Kanacurry", "categories": ["chicken"], "has_sides": true}],
// "sides": [{"name": "Riisi"}]
// }
type Bundle struct {
Mains []BundleMain `json:"mains"`
Sides []BundleSide `json:"sides"`
}
type BundleMain struct {
Name string `json:"name"`
Categories []string `json:"categories"`
// HasSides is a pointer so an omitted field is distinguishable from an
// explicit false, and defaults to true like the column does.
HasSides *bool `json:"has_sides"`
}
type BundleSide struct {
Name string `json:"name"`
}
// validCategories mirrors the CHECK constraint in 0001_init.sql. Stored values
// are English even though the UI is Finnish.
var validCategories = map[string]bool{
"meat": true, "chicken": true, "fish": true, "vegetarian": true,
}
// ImportReport is the per-row summary PRD §7.3 asks for. Import is
// best-effort, not atomic: good rows land, bad rows are skipped and named.
type ImportReport struct {
Added int
Skipped int
Notes []string
}
func (r *ImportReport) skip(format string, args ...any) {
r.Skipped++
r.Notes = append(r.Notes, fmt.Sprintf(format, args...))
}
func (r ImportReport) String() string {
var b strings.Builder
fmt.Fprintf(&b, "lisätty %d, ohitettu %d", r.Added, r.Skipped)
for _, n := range r.Notes {
fmt.Fprintf(&b, "\n - %s", n)
}
return b.String()
}
// importBundle reads a JSON bundle and inserts what it can. Each dish gets its
// own transaction so one bad row cannot discard the others.
func importBundle(db *sql.DB, r io.Reader) (*ImportReport, error) {
var bundle Bundle
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&bundle); err != nil {
// Unwrapped: callers add their own prefix, and the UI one is Finnish.
return nil, err
}
report := &ImportReport{}
for _, m := range bundle.Mains {
name := normalizeName(m.Name)
if name == "" {
report.skip("pääruoka ilman nimeä")
continue
}
if len(m.Categories) == 0 {
report.skip("%s: ei kategorioita", name)
continue
}
bad := ""
for _, c := range m.Categories {
if !validCategories[c] {
bad = c
break
}
}
if bad != "" {
report.skip("%s: tuntematon kategoria %q", name, bad)
continue
}
hasSides := true
if m.HasSides != nil {
hasSides = *m.HasSides
}
if _, err := createMain(db, name, m.Categories, hasSides); err != nil {
report.skip("%s: %s", name, reason(err))
continue
}
report.Added++
}
for _, s := range bundle.Sides {
name := normalizeName(s.Name)
if name == "" {
report.skip("lisuke ilman nimeä")
continue
}
if err := createSide(db, name); err != nil {
report.skip("%s: %s", name, reason(err))
continue
}
report.Added++
}
return report, nil
}
// reason turns a store error into something worth showing a person. The only
// failure a normal import hits is a name already in the catalog.
func reason(err error) string {
if errors.Is(err, errNameTaken) {
return "jo listalla"
}
return err.Error()
}
// runImport loads a bundle file, prints the report, and is what `-import`
// calls. Handy for reseeding a scratch database between test runs.
func runImport(db *sql.DB, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
report, err := importBundle(db, f)
if err != nil {
return err
}
fmt.Println(report)
return nil
}