Five sentences a member can read carried a phrase the copy rule
refuses. They now read:
The form could not be read. Try again. (a form body, and a batch
body, that cannot be read)
That name isn't available. Choose another.
... Another can start there after %s. ... (abandonment budget)
... Another can start after %s. (initiation budget)
They were "Please try again." (twice), "Please choose another." and
"You can start another ..." (twice). The lint does not read
internal/forms or internal/domains, so it never saw them; its reach is
unchanged. The sentences in workflow results and FedWiki's sync errors
are untouched.
The two "malformed body" rows of
internal/forms/testdata/parse-answers.golden carry the new sentence;
no other golden changes. The new TestMemberMessageWording pins the
three domain sentences word for word; no test pinned them before. It
failed at the parent and passes at this commit.
How it was shown to change no other output: the names and results of
./internal/forms, ./internal/domains, ./internal/server and
./internal/integrations/fedwiki/web (on freshly reset test databases)
are equal at the parent and at this commit apart from the added test,
which fails at the parent. Five breaks, each restoring one old
sentence, are caught: three by TestMemberMessageWording, two by
TestParseAnswers.
Not probed: that no screen shows these sentences. The new tests read
the strings and the parse golden; no browser run was taken for this
commit.
651 lines
21 KiB
Go
651 lines
21 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package forms
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Parsing (design D6; spec form-library "The handler parses through the
|
|
// declaration"). A handler reads a mutation's body through the
|
|
// declaration and never through r.FormValue, which merges the query string
|
|
// into the body and hides which one a value came from. Parse reads only
|
|
// declared names, so the declaration is also the write allowlist: the CSRF
|
|
// token, htmx's own parameters and anything else a body carries are
|
|
// ignored (Django's fields warning, Rails' permit! warning; lesson L§15).
|
|
//
|
|
// Parse never builds a domain object. It returns the values and the
|
|
// errors; the handler adds its own checks by field name or at the form
|
|
// level and builds the object only once the error set is empty (L§16).
|
|
|
|
// Values holds one form's values: the raw submitted string, whether the
|
|
// name was present at all, and the typed value where the field's rules
|
|
// passed. Go cannot tell an absent string from an empty one, so presence
|
|
// is recorded beside the value (lesson L§4).
|
|
type Values struct {
|
|
raw map[string]string
|
|
present map[string]bool
|
|
typed map[string]any
|
|
}
|
|
|
|
// NewValues returns an empty value set, for binding a record to a form.
|
|
func NewValues() Values {
|
|
return Values{
|
|
raw: map[string]string{},
|
|
present: map[string]bool{},
|
|
typed: map[string]any{},
|
|
}
|
|
}
|
|
|
|
func (v *Values) init() {
|
|
if v.raw == nil {
|
|
v.raw = map[string]string{}
|
|
v.present = map[string]bool{}
|
|
v.typed = map[string]any{}
|
|
}
|
|
}
|
|
|
|
// Set records a field's raw value, marking it present. Binding a record to
|
|
// a form goes through here, so the record-bound render and the
|
|
// submission-bound render read the same way.
|
|
func (v *Values) Set(name, raw string) {
|
|
v.init()
|
|
v.raw[name] = raw
|
|
v.present[name] = true
|
|
}
|
|
|
|
// SetBool records a checkbox's state. A ticked box carries the field's
|
|
// submitted value; an unticked one carries nothing, which is how the
|
|
// browser submits it. Either way, the typed bool is recorded too, so a
|
|
// bound checkbox answers Bool the same way a parsed one does (design D1).
|
|
func (v *Values) SetBool(f Field, on bool) {
|
|
v.init()
|
|
if on {
|
|
v.Set(f.Name, f.CheckboxValue())
|
|
v.setTyped(f.Name, true)
|
|
return
|
|
}
|
|
v.raw[f.Name] = ""
|
|
v.present[f.Name] = false
|
|
v.setTyped(f.Name, false)
|
|
}
|
|
|
|
// Raw returns the submitted (or bound) string, trimmed as Parse trimmed it.
|
|
func (v Values) Raw(name string) string { return v.raw[name] }
|
|
|
|
// Present reports whether the name was submitted at all, which is not the
|
|
// same as carrying a value.
|
|
func (v Values) Present(name string) bool { return v.present[name] }
|
|
|
|
// String returns the value as text.
|
|
func (v Values) String(name string) string { return v.raw[name] }
|
|
|
|
// Int returns the value as a whole number, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) Int(name string) (int, bool) {
|
|
n, ok := v.typed[name].(int)
|
|
return n, ok
|
|
}
|
|
|
|
// Bool returns a checkbox's state. An absent checkbox is false, never an
|
|
// error (spec form-conventions "A checkbox is never required").
|
|
func (v Values) Bool(name string) bool {
|
|
b, _ := v.typed[name].(bool)
|
|
return b
|
|
}
|
|
|
|
// Time returns the value as an instant, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) Time(name string) (time.Time, bool) {
|
|
t, ok := v.typed[name].(time.Time)
|
|
return t, ok
|
|
}
|
|
|
|
// UUID returns the value as an identifier, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) UUID(name string) (uuid.UUID, bool) {
|
|
id, ok := v.typed[name].(uuid.UUID)
|
|
return id, ok
|
|
}
|
|
|
|
// setTyped records a field's typed value; an errored field never gets one,
|
|
// so a handler that reads a typed value is reading a value that passed.
|
|
func (v *Values) setTyped(name string, val any) {
|
|
v.init()
|
|
v.typed[name] = val
|
|
}
|
|
|
|
// Errors is one form's refusals: a message per field, plus the form-level
|
|
// message for a refusal that belongs to no field. The form-level slot is
|
|
// reached by the same call with an empty field name (lesson L§11).
|
|
type Errors struct {
|
|
fields map[string]string
|
|
form string
|
|
}
|
|
|
|
// NewErrors returns an empty error set.
|
|
func NewErrors() *Errors { return &Errors{fields: map[string]string{}} }
|
|
|
|
// Field records a refusal under one field. The last message wins: the
|
|
// convention is one concise message per field, not a list.
|
|
func (e *Errors) Field(name, msg string) {
|
|
if e.fields == nil {
|
|
e.fields = map[string]string{}
|
|
}
|
|
if name == "" {
|
|
e.form = msg
|
|
return
|
|
}
|
|
e.fields[name] = msg
|
|
}
|
|
|
|
// Form records a refusal that belongs to no field.
|
|
func (e *Errors) Form(msg string) { e.form = msg }
|
|
|
|
// Get returns one field's message, or the empty string.
|
|
func (e *Errors) Get(name string) string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.fields[name]
|
|
}
|
|
|
|
// FormError returns the form-level message, or the empty string.
|
|
func (e *Errors) FormError() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.form
|
|
}
|
|
|
|
// Names returns the refused fields' names, sorted, the form level not among
|
|
// them: for a handler that places each refusal where it can render, and
|
|
// moves one naming a field its form does not carry to the form's own slot
|
|
// rather than dropping it.
|
|
func (e *Errors) Names() []string {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(e.fields))
|
|
for name := range e.fields {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// Has reports whether one field was refused.
|
|
func (e *Errors) Has(name string) bool {
|
|
if e == nil {
|
|
return false
|
|
}
|
|
_, ok := e.fields[name]
|
|
return ok
|
|
}
|
|
|
|
// Any reports whether anything was refused. This is the handler's branch
|
|
// point: build the domain object only when it is false.
|
|
func (e *Errors) Any() bool {
|
|
if e == nil {
|
|
return false
|
|
}
|
|
return len(e.fields) > 0 || e.form != ""
|
|
}
|
|
|
|
// Count returns how many fields were refused, the form level included.
|
|
func (e *Errors) Count() int {
|
|
if e == nil {
|
|
return 0
|
|
}
|
|
n := len(e.fields)
|
|
if e.form != "" {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Parse reads r through the declaration and returns the values and the
|
|
// refusals. It reads r.PostForm for a mutation and r.URL.Query() for a
|
|
// search form, never r.FormValue, so the query string cannot shadow a
|
|
// body field. Repeated names on a scalar field take the first value, as
|
|
// Go's own form parsing does; a multi-valued control does not exist.
|
|
func (s FormSpec) Parse(r *http.Request) (Values, *Errors) {
|
|
return s.ParseSide(r, BothSides, nil)
|
|
}
|
|
|
|
// ParseWith is Parse with the option sets of the fields whose options are
|
|
// loaded per request (a picker of the deployment's entitlement sets). A
|
|
// submitted value outside a field's options is a refusal, never a silent
|
|
// default (finding FA-8).
|
|
func (s FormSpec) ParseWith(r *http.Request, options map[string][]Option) (Values, *Errors) {
|
|
return s.ParseSide(r, BothSides, options)
|
|
}
|
|
|
|
// ParseSide is ParseWith restricted to the fields that render on one side,
|
|
// so a create submission cannot carry an edit-only field and an edit
|
|
// submission cannot carry a create-only one. The side the form rendered is
|
|
// the side it is read as; BothSides reads every declared field, which is
|
|
// right for a form with one side.
|
|
func (s FormSpec) ParseSide(r *http.Request, side Side, options map[string][]Option) (Values, *Errors) {
|
|
values := NewValues()
|
|
errs := NewErrors()
|
|
|
|
var source map[string][]string
|
|
if s.Kind == KindSearch {
|
|
source = r.URL.Query()
|
|
} else {
|
|
if err := r.ParseForm(); err != nil {
|
|
errs.Form("The form could not be read. Try again.")
|
|
return values, errs
|
|
}
|
|
source = r.PostForm
|
|
}
|
|
|
|
fields := make([]Field, 0, len(s.Fields))
|
|
for _, f := range s.Fields {
|
|
if side != BothSides && !f.RendersOn(side) {
|
|
continue
|
|
}
|
|
if f.Control == Static {
|
|
// A static row states a fact where a control would be; it
|
|
// carries no name, submits nothing, and is never parsed
|
|
// (design D21). Reading one would invent a value the page
|
|
// never offered.
|
|
continue
|
|
}
|
|
fields = append(fields, f)
|
|
}
|
|
s.parseFields(source, fields, "", "", options, &values, errs)
|
|
return values, errs
|
|
}
|
|
|
|
// parseFields reads one group of fields out of source and applies the
|
|
// declaration's rules to each. namePrefix is what the submitted names carry
|
|
// ahead of the field name and errPrefix what the error keys do: both empty
|
|
// for an ordinary form, "rows.<instance>." or "staged.<i>." for one group
|
|
// of a batch, so one set of rules reads a form, a row and a delta alike and
|
|
// none of them can drift from the others.
|
|
func (s FormSpec) parseFields(source map[string][]string, fields []Field, namePrefix, errPrefix string, options map[string][]Option, out *Values, errs *Errors) {
|
|
values := out
|
|
for _, f := range fields {
|
|
if !f.Shown(*values) {
|
|
// A field the operator never saw (its ShowIf condition on an
|
|
// earlier field's value did not match) is neither validated
|
|
// nor carried into Values: the control was not on the page,
|
|
// so nothing was there to type (design D2, ShowIf's doc on
|
|
// the trust model). ShowIf.Field must therefore be declared
|
|
// earlier in Fields, so its value is already in Values here.
|
|
continue
|
|
}
|
|
raw, present := readRaw(source, namePrefix+f.Name)
|
|
values.raw[f.Name] = raw
|
|
values.present[f.Name] = present
|
|
key := errPrefix + f.Name
|
|
switch f.Control {
|
|
case Checkbox:
|
|
// A checkbox's absence is false and never an error; a ticked
|
|
// box submits the declared value (lessons L§21, L§34).
|
|
values.setTyped(f.Name, present && raw == f.CheckboxValue())
|
|
case Select, Radio:
|
|
opts := f.Options
|
|
if runtime, ok := options[f.Name]; ok {
|
|
opts = runtime
|
|
}
|
|
s.parseChoice(values, f, key, raw, opts, errs)
|
|
default:
|
|
s.parseText(values, f, key, raw, errs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// readRaw is the first submitted value under name, trimmed, and whether the
|
|
// name was submitted at all.
|
|
func readRaw(source map[string][]string, name string) (string, bool) {
|
|
vals, present := source[name]
|
|
raw := ""
|
|
if present && len(vals) > 0 {
|
|
raw = vals[0]
|
|
}
|
|
return strings.TrimSpace(raw), present
|
|
}
|
|
|
|
// parseChoice applies a select's or a radio group's rules: the value must
|
|
// be one the control offered.
|
|
func (s FormSpec) parseChoice(values *Values, f Field, key, raw string, opts []Option, errs *Errors) {
|
|
if !optionExists(opts, raw) {
|
|
switch {
|
|
case raw != "":
|
|
errs.Field(key, "Choose "+article(f.Label)+" from the list.")
|
|
case f.Control == Radio && f.Optional:
|
|
// A Radio group has no "choose nothing" member the way a
|
|
// Select's enabled empty option does (render.go's fieldAttrs
|
|
// mirrors this): an Optional radio group left untouched is a
|
|
// legitimate empty submission, not a missing choice.
|
|
default:
|
|
errs.Field(key, chooseMessage(f))
|
|
}
|
|
return
|
|
}
|
|
if raw == "" {
|
|
// A deliberate empty option (the edit side's "None") is a
|
|
// legitimate submission that clears the column.
|
|
return
|
|
}
|
|
s.assignTyped(values, f, key, raw, errs)
|
|
}
|
|
|
|
// parseText applies a text-shaped control's rules: requiredness, length and
|
|
// pattern, then the control's own type.
|
|
func (s FormSpec) parseText(values *Values, f Field, key, raw string, errs *Errors) {
|
|
if raw == "" {
|
|
if !f.Optional {
|
|
errs.Field(key, enterMessage(f))
|
|
}
|
|
return
|
|
}
|
|
if f.MaxLen > 0 && len([]rune(raw)) > f.MaxLen {
|
|
errs.Field(key, fmt.Sprintf("%s must be %d characters or fewer.", f.Label, f.MaxLen))
|
|
return
|
|
}
|
|
if f.Pattern != "" && !patternMatches(f.Pattern, raw) {
|
|
msg := f.PatternHint
|
|
if msg == "" {
|
|
msg = f.Label + " is not in the expected format."
|
|
}
|
|
errs.Field(key, msg)
|
|
return
|
|
}
|
|
s.assignTyped(values, f, key, raw, errs)
|
|
}
|
|
|
|
// patterns holds each declared pattern compiled once, so a request does
|
|
// not compile it again. A pattern that does not compile is stored as nil
|
|
// and refuses every value, as it did when each request compiled it.
|
|
var patterns sync.Map // string -> *regexp.Regexp
|
|
|
|
// patternMatches reports whether raw matches pattern; a pattern that does
|
|
// not compile matches nothing.
|
|
func patternMatches(pattern, raw string) bool {
|
|
re, ok := patterns.Load(pattern)
|
|
if !ok {
|
|
compiled, err := regexp.Compile(pattern)
|
|
if err != nil {
|
|
compiled = nil
|
|
}
|
|
re, _ = patterns.LoadOrStore(pattern, compiled)
|
|
}
|
|
compiled := re.(*regexp.Regexp)
|
|
return compiled != nil && compiled.MatchString(raw)
|
|
}
|
|
|
|
// assignTyped converts one passing raw value and applies the range rules
|
|
// the control has. A field that fails here keeps its raw text for the
|
|
// re-render and gets no typed value (lesson L§11, finding FA-23).
|
|
func (s FormSpec) assignTyped(values *Values, f Field, key, raw string, errs *Errors) {
|
|
switch f.Control {
|
|
case Number:
|
|
if n, msg := parseNumber(f, raw); msg != "" {
|
|
errs.Field(key, msg)
|
|
} else {
|
|
values.setTyped(f.Name, n)
|
|
}
|
|
case Date, DateTime:
|
|
if t, msg := parseDate(f, raw); msg != "" {
|
|
errs.Field(key, msg)
|
|
} else {
|
|
values.setTyped(f.Name, t)
|
|
}
|
|
case Select, Radio, Hidden:
|
|
// A picker's values are usually identifiers, read back with
|
|
// Values.UUID. Parsing here keeps the handler from parsing a
|
|
// second time and disagreeing about what a bad value means; a
|
|
// value that is not an identifier simply has no typed form, which
|
|
// is correct for the pickers whose values are enum strings.
|
|
if id, err := uuid.Parse(raw); err == nil {
|
|
values.setTyped(f.Name, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseNumber reads a whole number and holds it to the declared range; a
|
|
// bound that is not a whole number is no bound. It returns the refusal, or
|
|
// "" and the number.
|
|
func parseNumber(f Field, raw string) (int, string) {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, f.Label + " must be a whole number."
|
|
}
|
|
if min, err := strconv.Atoi(f.Min); f.Min != "" && err == nil && n < min {
|
|
return 0, fmt.Sprintf("%s must be %d or more.", f.Label, min)
|
|
}
|
|
if max, err := strconv.Atoi(f.Max); f.Max != "" && err == nil && n > max {
|
|
return 0, fmt.Sprintf("%s must be %d or fewer.", f.Label, max)
|
|
}
|
|
return n, ""
|
|
}
|
|
|
|
// parseDate reads a date, or a date and time, and holds it to the declared
|
|
// range; a bound in another layout is no bound.
|
|
func parseDate(f Field, raw string) (time.Time, string) {
|
|
layout := "2006-01-02"
|
|
if f.Control == DateTime {
|
|
layout = "2006-01-02T15:04"
|
|
}
|
|
t, err := time.Parse(layout, raw)
|
|
if err != nil {
|
|
return time.Time{}, f.Label + " is not a valid date."
|
|
}
|
|
if min, err := time.Parse(layout, f.Min); f.Min != "" && err == nil && t.Before(min) {
|
|
return time.Time{}, f.Label + " is earlier than this form allows."
|
|
}
|
|
if max, err := time.Parse(layout, f.Max); f.Max != "" && err == nil && t.After(max) {
|
|
return time.Time{}, f.Label + " is later than this form allows."
|
|
}
|
|
return t, ""
|
|
}
|
|
|
|
// optionExists reports whether value is one the control offered as a
|
|
// choice. A disabled option is not one: it is the "Choose a ..."
|
|
// placeholder, which a browser will not submit and the server must not
|
|
// accept, so a select that needs a choice refuses the empty value while an
|
|
// optional select, whose first option is an enabled "None", accepts it.
|
|
func optionExists(options []Option, value string) bool {
|
|
for _, o := range options {
|
|
if o.Disabled {
|
|
continue
|
|
}
|
|
if o.Value == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// enterMessage is the refusal for a required text-shaped field.
|
|
func enterMessage(f Field) string {
|
|
return "Enter " + article(f.Label) + "."
|
|
}
|
|
|
|
// chooseMessage is the refusal for a select or a radio group.
|
|
func chooseMessage(f Field) string {
|
|
return "Choose " + article(f.Label) + "."
|
|
}
|
|
|
|
// article lowercases a label and prefixes the indefinite article, so
|
|
// "Entitlement set" becomes "an entitlement set" and the two refusal
|
|
// messages read as sentences rather than as field names.
|
|
func article(label string) string {
|
|
if label == "" {
|
|
return "a value"
|
|
}
|
|
lower := strings.ToLower(label)
|
|
switch lower[0] {
|
|
case 'a', 'e', 'i', 'o', 'u':
|
|
return "an " + lower
|
|
}
|
|
return "a " + lower
|
|
}
|
|
|
|
// Batch is one request through a batch form's declaration (design D1's
|
|
// "Rows family contract"; spec form-library "A batch form renders the
|
|
// caller's rows and one tray"). Every posting control of a Rows form sends
|
|
// the whole form, so one request carries what was pressed, every open
|
|
// editor's typed values, the whole staged batch and the tray's own fields;
|
|
// ParseBatch reads all four through the declaration and nothing else.
|
|
//
|
|
// ParseBatch validates shape, not domain: the handler decides what Act
|
|
// means, re-reads the records the batch names, and refuses a delta whose
|
|
// record has moved under it (design D3).
|
|
type Batch struct {
|
|
// Act is what was pressed and Key the record it was pressed on, from
|
|
// the row action's own hx-vals. Both are the library's names, not
|
|
// declared fields: a declaration names controls, and these name the
|
|
// press.
|
|
Act string
|
|
Key string
|
|
// Open are the record keys whose editors were open, in the order the
|
|
// hidden inputs carried them.
|
|
Open []string
|
|
// Rows are those editors' values, keyed by record key, each read
|
|
// through the row fields' own rules.
|
|
Rows map[string]Values
|
|
// Staged is the batch as submitted, in index order, each delta's values
|
|
// read through the row and delta fields' rules. Line and Error are the
|
|
// render's, never the request's, so both are empty here.
|
|
Staged []StagedDelta
|
|
// Tray holds the tray's own fields.
|
|
Tray Values
|
|
// Errors are the refusals, keyed "rows.<instance>.<field>",
|
|
// "staged.<i>.<field>", the tray field's own name, and the form level.
|
|
Errors *Errors
|
|
}
|
|
|
|
// Row returns one open editor as the render wants it: its values, and its
|
|
// refusals under the declaration's bare field names, so the handler hands
|
|
// back what it parsed without re-keying anything by hand.
|
|
func (b Batch) Row(instance string) RowBinding {
|
|
rb := RowBinding{Values: b.Rows[instance], Errors: NewErrors()}
|
|
prefix := "rows." + instance + "."
|
|
for key, msg := range b.Errors.fields {
|
|
if strings.HasPrefix(key, prefix) {
|
|
rb.Errors.Field(strings.TrimPrefix(key, prefix), msg)
|
|
}
|
|
}
|
|
return rb
|
|
}
|
|
|
|
// maxStagedDeltas caps how many deltas one request may carry. The render
|
|
// writes one group per staged change and a set holds tens of rules, so the
|
|
// cap is out of reach in use; it exists because the groups arrive as
|
|
// numbered names a forged body could number as high as it likes.
|
|
const maxStagedDeltas = 200
|
|
|
|
// ParseBatch reads a batch form's request through its declaration.
|
|
func (s FormSpec) ParseBatch(r *http.Request) Batch {
|
|
return s.ParseBatchWith(r, nil)
|
|
}
|
|
|
|
// ParseBatchWith is ParseBatch with the option sets of the fields whose
|
|
// options are loaded per request, exactly as ParseWith is to Parse: a
|
|
// submitted value outside a field's options is a refusal. A batch form
|
|
// whose select offers a per-record option (a stored value the console
|
|
// offers back rather than writes) passes every value it ever renders here,
|
|
// because one request carries every row and every delta at once.
|
|
func (s FormSpec) ParseBatchWith(r *http.Request, options map[string][]Option) Batch {
|
|
if s.Family != Rows {
|
|
panic(fmt.Sprintf("forms: %s is a %s form; ParseBatch reads a batch form", s.Name, s.Family))
|
|
}
|
|
batch := Batch{Rows: map[string]Values{}, Tray: NewValues(), Errors: NewErrors()}
|
|
if err := r.ParseForm(); err != nil {
|
|
batch.Errors.Form("The form could not be read. Try again.")
|
|
return batch
|
|
}
|
|
source := r.PostForm
|
|
|
|
batch.Act = strings.TrimSpace(firstValue(source, "act"))
|
|
batch.Key = strings.TrimSpace(firstValue(source, "key"))
|
|
|
|
seen := map[string]bool{}
|
|
for _, raw := range source["open"] {
|
|
instance := strings.TrimSpace(raw)
|
|
if instance == "" || seen[instance] {
|
|
continue
|
|
}
|
|
seen[instance] = true
|
|
batch.Open = append(batch.Open, instance)
|
|
}
|
|
|
|
rowFields := s.placed(PlaceRow)
|
|
deltaFields := s.placed(PlaceRow, PlaceDelta)
|
|
trayFields := s.placed(PlaceTray)
|
|
|
|
// A row that is not open submits nothing and is not read: its record is
|
|
// on the page as text, and requiredness applies to what someone typed.
|
|
for _, instance := range batch.Open {
|
|
prefix := "rows." + instance + "."
|
|
values := NewValues()
|
|
s.parseFields(source, rowFields, prefix, prefix, options, &values, batch.Errors)
|
|
batch.Rows[instance] = values
|
|
}
|
|
|
|
for i := 0; ; i++ {
|
|
prefix := stagedPrefix(i) + "."
|
|
_, hasVerb := source[prefix+"verb"]
|
|
_, hasKey := source[prefix+"key"]
|
|
if !hasVerb && !hasKey {
|
|
break
|
|
}
|
|
if i >= maxStagedDeltas {
|
|
batch.Errors.Form(fmt.Sprintf("A batch holds at most %d changes.", maxStagedDeltas))
|
|
break
|
|
}
|
|
values := NewValues()
|
|
s.parseFields(source, deltaFields, prefix, prefix, options, &values, batch.Errors)
|
|
batch.Staged = append(batch.Staged, StagedDelta{
|
|
Verb: strings.TrimSpace(firstValue(source, prefix+"verb")),
|
|
Key: strings.TrimSpace(firstValue(source, prefix+"key")),
|
|
Values: values,
|
|
})
|
|
}
|
|
|
|
s.parseFields(source, trayFields, "", "", options, &batch.Tray, batch.Errors)
|
|
return batch
|
|
}
|
|
|
|
// placed returns the declared fields with one of these placements, in
|
|
// declaration order, with the static rows dropped for the same reason Parse
|
|
// drops them: a row that states a fact submits nothing.
|
|
func (s FormSpec) placed(placements ...Placement) []Field {
|
|
want := map[Placement]bool{}
|
|
for _, p := range placements {
|
|
want[p] = true
|
|
}
|
|
out := make([]Field, 0, len(s.Fields))
|
|
for _, f := range s.Fields {
|
|
if f.Control == Static || !want[f.Placement] {
|
|
continue
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// firstValue returns the first submitted value under a name, as Go's own
|
|
// form parsing does for a scalar field.
|
|
func firstValue(source map[string][]string, name string) string {
|
|
if vals, ok := source[name]; ok && len(vals) > 0 {
|
|
return vals[0]
|
|
}
|
|
return ""
|
|
}
|