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.
154 lines
3.6 KiB
Go
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
|
|
}
|