Bring up the stage 1 skeleton described in the PRD, enough that the app builds, serves, and can be populated with dishes. - net/http server with shared-password Basic auth, /healthz outside it, graceful shutdown, and TZ-aware calendar days - SQLite via modernc (pure Go, static binary), opened with WAL and a single connection - migration runner: numbered SQL files embedded and applied once each inside a transaction, recorded in schema_migrations - bundle import (PRD §7.3) as a live feature on the Ruoat tab: paste JSON or upload a file, get a per-row Finnish report. The same importer is reachable as `foodster -import` for repopulating a scratch database - templ views and hand-written CSS with light-dark() theming; the Datastar v1.0.3 client is vendored, since the Go SDK ships no browser asset and a CDN would break an offline LAN Names are normalized to sentence case rather than title case: Finnish capitalizes only the first word of a phrase, so "Keitetyt perunat" is right and "Keitetyt Perunat" is not. PRD §6 and §7.3 are amended to match. Testing is behind make targets rather than ad-hoc commands: `make check` runs lint, unit tests and scripts/smoke.sh, which exercises auth, static assets and every import path against a scratch database on a spare port.
92 lines
2.5 KiB
Go
92 lines
2.5 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/a-h/templ"
|
|
)
|
|
|
|
// maxUpload caps a pasted or uploaded bundle. A household catalog is a few
|
|
// kilobytes; a megabyte is already absurd generosity.
|
|
const maxUpload = 1 << 20
|
|
|
|
type app struct {
|
|
db *sql.DB
|
|
loc *time.Location
|
|
}
|
|
|
|
func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := c.Render(r.Context(), w); err != nil {
|
|
log.Printf("render %s: %v", r.URL.Path, err)
|
|
}
|
|
}
|
|
|
|
func (a *app) index(w http.ResponseWriter, r *http.Request) {
|
|
render(w, r, indexPage(today(a.loc)))
|
|
}
|
|
|
|
func (a *app) history(w http.ResponseWriter, r *http.Request) {
|
|
render(w, r, historyPage())
|
|
}
|
|
|
|
func (a *app) catalog(w http.ResponseWriter, r *http.Request) {
|
|
a.renderCatalog(w, r, nil)
|
|
}
|
|
|
|
func (a *app) renderCatalog(w http.ResponseWriter, r *http.Request, report *ImportReport) {
|
|
var mains, sides int
|
|
err := a.db.QueryRow(`
|
|
SELECT (SELECT count(*) FROM main_dishes WHERE deleted_at IS NULL),
|
|
(SELECT count(*) FROM side_dishes WHERE deleted_at IS NULL)`,
|
|
).Scan(&mains, &sides)
|
|
if err != nil {
|
|
log.Printf("catalog counts: %v", err)
|
|
}
|
|
render(w, r, catalogPage(mains, sides, report))
|
|
}
|
|
|
|
// importDishes takes a bundle either pasted into the textarea or uploaded as a
|
|
// file, and reports row by row what happened (PRD §7.3).
|
|
//
|
|
// ponytail: a plain multipart form rather than a Datastar round trip. The
|
|
// result is a whole-page report, not a fragment, and a form needs no client
|
|
// code at all.
|
|
func (a *app) importDishes(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)
|
|
|
|
if err := r.ParseMultipartForm(maxUpload); err != nil {
|
|
a.renderCatalog(w, r, failed("Tiedosto on liian suuri tai vioittunut."))
|
|
return
|
|
}
|
|
|
|
var src io.Reader
|
|
if file, _, err := r.FormFile("tiedosto"); err == nil {
|
|
defer file.Close()
|
|
src = file
|
|
} else if pasted := strings.TrimSpace(r.FormValue("json")); pasted != "" {
|
|
src = strings.NewReader(pasted)
|
|
} else {
|
|
a.renderCatalog(w, r, failed("Ei tuotavaa: liitä JSON tai valitse tiedosto."))
|
|
return
|
|
}
|
|
|
|
report, err := importBundle(a.db, src)
|
|
if err != nil {
|
|
a.renderCatalog(w, r, failed("JSON ei kelpaa: "+err.Error()))
|
|
return
|
|
}
|
|
a.renderCatalog(w, r, report)
|
|
}
|
|
|
|
// failed builds a report for a whole-request failure, so the view only ever
|
|
// has one shape to render.
|
|
func failed(note string) *ImportReport {
|
|
return &ImportReport{Skipped: 1, Notes: []string{note}}
|
|
}
|