- type: datepicker compiles the D-20 keys; format maps to displayFormat with WinterCMS's momentFormat table - boot fails when the mode does not match the column's Go type (time.Time, lagoon.Date, lagoon.TimeOfDay) - datepicker is a writable scalar field; minDate and maxDate are rechecked on save - columns.yaml accepts type: date and type: time; Scanner/Valuer structs are columns, not relations - conformance fixture carries date and datetime fields; README, forms and lists docs
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
// datepickerKeys are valid only on `type: datepicker` (D-20). mode is shared
|
|
// with fileupload and gated by value.
|
|
var datepickerKeys = []string{
|
|
"format", "minDate", "maxDate", "yearRange", "firstDay", "twelveHour", "ignoreTimezone",
|
|
}
|
|
|
|
// datepickerRefusedKeys are generic field keys with no meaning on a
|
|
// datepicker.
|
|
var datepickerRefusedKeys = []string{"options", "emptyOption", "nameFrom"}
|
|
|
|
// momentTokens is WinterCMS's DateTime::momentFormat table restricted to the
|
|
// PHP date() letters the admin SPA's formatter renders. Every other letter
|
|
// fails boot: t, L, B, I, O, P, T, Z, c, r and U have no equivalent, and the
|
|
// rest are not supported by the SPA.
|
|
var momentTokens = map[byte]string{
|
|
'd': "DD", 'j': "D", 'D': "ddd", 'l': "dddd",
|
|
'm': "MM", 'n': "M", 'M': "MMM", 'F': "MMMM",
|
|
'Y': "YYYY", 'y': "YY",
|
|
'H': "HH", 'G': "H", 'h': "hh", 'g': "h",
|
|
'i': "mm", 's': "ss", 'A': "A", 'a': "a",
|
|
}
|
|
|
|
var (
|
|
dateGoType = reflect.TypeOf(lagoon.Date{})
|
|
timeOfDayGoType = reflect.TypeOf(lagoon.TimeOfDay{})
|
|
)
|
|
|
|
// compileDatepickerKeys decodes the D-20 keys of a `type: datepicker` field.
|
|
// They are refused on every other type.
|
|
func compileDatepickerKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
|
if typ != "datepicker" {
|
|
for _, key := range datepickerKeys {
|
|
if _, ok := values[key]; ok {
|
|
return fmt.Errorf("%s is only valid on type: datepicker", key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
for _, key := range datepickerRefusedKeys {
|
|
if _, ok := values[key]; ok {
|
|
return fmt.Errorf("%s is not valid on type: datepicker", key)
|
|
}
|
|
}
|
|
field.Mode = "datetime"
|
|
if node, ok := values["mode"]; ok {
|
|
mode, err := nodeString(node)
|
|
if err != nil || (mode != "date" && mode != "datetime" && mode != "time") {
|
|
return fmt.Errorf("mode %q must be date, datetime or time on type: datepicker", nodeText(node))
|
|
}
|
|
field.Mode = mode
|
|
}
|
|
if node, ok := values["format"]; ok {
|
|
format, err := nodeString(node)
|
|
if err != nil || strings.TrimSpace(format) == "" {
|
|
return fmt.Errorf("format must be a PHP date format string")
|
|
}
|
|
display, err := momentFormat(format)
|
|
if err != nil {
|
|
return fmt.Errorf("format %q: %w", format, err)
|
|
}
|
|
field.Format = format
|
|
field.DisplayFormat = display
|
|
}
|
|
for _, key := range []string{"minDate", "maxDate"} {
|
|
node, ok := values[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if field.Mode == "time" {
|
|
return fmt.Errorf("%s is not valid with mode: time", key)
|
|
}
|
|
d, err := boundDate(node)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: %w", key, err)
|
|
}
|
|
if key == "minDate" {
|
|
field.MinDate = d.String()
|
|
} else {
|
|
field.MaxDate = d.String()
|
|
}
|
|
}
|
|
if field.MinDate != "" && field.MaxDate != "" && field.MinDate > field.MaxDate {
|
|
return fmt.Errorf("minDate %s is after maxDate %s", field.MinDate, field.MaxDate)
|
|
}
|
|
if node, ok := values["yearRange"]; ok {
|
|
years, err := compileYearRange(node)
|
|
if err != nil {
|
|
return fmt.Errorf("yearRange: %w", err)
|
|
}
|
|
field.YearRange = years
|
|
}
|
|
if node, ok := values["firstDay"]; ok {
|
|
n, err := nodeInt(node)
|
|
if err != nil || n < 0 || n > 6 {
|
|
return fmt.Errorf("firstDay %q must be an integer from 0 (Sunday) to 6", nodeText(node))
|
|
}
|
|
v := int(n)
|
|
field.FirstDay = &v
|
|
}
|
|
if node, ok := values["twelveHour"]; ok {
|
|
v, err := nodeBool(node)
|
|
if err != nil {
|
|
return fmt.Errorf("twelveHour: %w", err)
|
|
}
|
|
field.TwelveHour = v
|
|
}
|
|
if node, ok := values["ignoreTimezone"]; ok {
|
|
v, err := nodeBool(node)
|
|
if err != nil {
|
|
return fmt.Errorf("ignoreTimezone: %w", err)
|
|
}
|
|
if v && field.Mode != "datetime" {
|
|
return fmt.Errorf("ignoreTimezone is only valid with mode: datetime")
|
|
}
|
|
field.IgnoreTimezone = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// boundDate reads a minDate or maxDate: a YYYY-MM-DD date or the date part
|
|
// of an RFC 3339 timestamp.
|
|
func boundDate(node ast.Node) (lagoon.Date, error) {
|
|
text, err := nodeString(node)
|
|
if err != nil {
|
|
text = nodeText(node)
|
|
}
|
|
text = strings.TrimSpace(text)
|
|
if d, err := lagoon.ParseDate(text); err == nil && !d.IsZero() {
|
|
return d, nil
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, text); err == nil {
|
|
return lagoon.NewDate(t.Year(), t.Month(), t.Day()), nil
|
|
}
|
|
return lagoon.Date{}, fmt.Errorf("%q must be a date (YYYY-MM-DD)", text)
|
|
}
|
|
|
|
// compileYearRange reads a positive integer (years either side of today) or
|
|
// a two-year list [from, to] with from <= to.
|
|
func compileYearRange(node ast.Node) ([]int, error) {
|
|
switch n := unwrapNode(node).(type) {
|
|
case *ast.IntegerNode:
|
|
v, err := nodeInt(n)
|
|
if err != nil || v < 1 {
|
|
return nil, fmt.Errorf("%q must be a positive integer", nodeText(n))
|
|
}
|
|
return []int{int(v)}, nil
|
|
case *ast.SequenceNode:
|
|
items := sequenceValues(n)
|
|
if len(items) != 2 {
|
|
return nil, fmt.Errorf("a list must hold exactly two years")
|
|
}
|
|
from, err1 := nodeInt(items[0])
|
|
to, err2 := nodeInt(items[1])
|
|
if err1 != nil || err2 != nil || from < 1 || to < 1 {
|
|
return nil, fmt.Errorf("a list must hold two years")
|
|
}
|
|
if from > to {
|
|
return nil, fmt.Errorf("from %d is after to %d", from, to)
|
|
}
|
|
return []int{int(from), int(to)}, nil
|
|
default:
|
|
return nil, fmt.Errorf("must be an integer or a list of two years")
|
|
}
|
|
}
|
|
|
|
// momentFormat maps a PHP date() format to the moment-style display format
|
|
// the admin SPA renders, as WinterCMS's DateTime::momentFormat does. A
|
|
// backslash-escaped character is kept literal; a letter outside
|
|
// momentTokens is an error naming it.
|
|
func momentFormat(format string) (string, error) {
|
|
var b strings.Builder
|
|
for i := 0; i < len(format); i++ {
|
|
c := format[i]
|
|
switch {
|
|
case c == '\\':
|
|
if i+1 < len(format) {
|
|
i++
|
|
b.WriteByte('[')
|
|
b.WriteByte(format[i])
|
|
b.WriteByte(']')
|
|
}
|
|
case (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'):
|
|
token, ok := momentTokens[c]
|
|
if !ok {
|
|
return "", fmt.Errorf("token %c has no equivalent in the admin date formatter", c)
|
|
}
|
|
b.WriteString(token)
|
|
default:
|
|
b.WriteByte(c)
|
|
}
|
|
}
|
|
return b.String(), nil
|
|
}
|
|
|
|
// compiledDate is one datepicker field after the D-19 type check.
|
|
type compiledDate struct {
|
|
name string
|
|
mode string
|
|
min, max string
|
|
ignoreTimezone bool
|
|
}
|
|
|
|
// compileDateFields checks every datepicker of the controller's form
|
|
// against its model column's Go type (D-19) and keeps its server bounds.
|
|
func compileDateFields(pluginID string, cc *CompiledController) error {
|
|
if cc == nil || cc.Form == nil {
|
|
return nil
|
|
}
|
|
var model any
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Type != "datepicker" {
|
|
continue
|
|
}
|
|
if model == nil {
|
|
proto, err := newWritableModel(cc)
|
|
if err != nil {
|
|
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, fmt.Errorf("field %s: type datepicker needs a fillable model", field.Name))
|
|
}
|
|
model = proto
|
|
}
|
|
if err := checkDateType(model, field); err != nil {
|
|
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
|
|
}
|
|
if cc.dates == nil {
|
|
cc.dates = map[string]*compiledDate{}
|
|
}
|
|
cc.dates[field.Name] = &compiledDate{
|
|
name: field.Name, mode: field.Mode,
|
|
min: field.MinDate, max: field.MaxDate,
|
|
ignoreTimezone: field.IgnoreTimezone,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkDateType refuses a datepicker whose mode does not match its column's
|
|
// Go type: time.Time for datetime, lagoon.Date for date, lagoon.TimeOfDay
|
|
// for time, each also as a pointer.
|
|
func checkDateType(model any, field FormField) error {
|
|
sf, ok := structFieldByColumn(model, field.Name)
|
|
if !ok {
|
|
return fmt.Errorf("field %s is not a model column", field.Name)
|
|
}
|
|
t := sf.Type
|
|
if t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
want := map[string]reflect.Type{"datetime": timeType, "date": dateGoType, "time": timeOfDayGoType}[field.Mode]
|
|
if t != want {
|
|
names := map[string]string{"datetime": "time.Time or *time.Time", "date": "lagoon.Date or *lagoon.Date", "time": "lagoon.TimeOfDay or *lagoon.TimeOfDay"}
|
|
return fmt.Errorf("field %s: datepicker mode %s needs a %s column, found %s", field.Name, field.Mode, names[field.Mode], sf.Type)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dateBoundDetails rechecks minDate and maxDate on the filled model (D-20):
|
|
// the calendar date of the value (the UTC date of a datetime, or its
|
|
// wall-clock date with ignoreTimezone) must lie inside the bounds,
|
|
// inclusively. A violation is a message on the field.
|
|
func dateBoundDetails(ctx context.Context, tr *phrasebook.Translator, cc *CompiledController, model any, op string) map[string][]string {
|
|
if cc == nil || len(cc.dates) == 0 {
|
|
return nil
|
|
}
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return nil
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
var out map[string][]string
|
|
for _, field := range cc.Form.Fields {
|
|
cd := cc.dates[field.Name]
|
|
if cd == nil || (cd.min == "" && cd.max == "") || !contextAllows(cc, cd.name, op) {
|
|
continue
|
|
}
|
|
fv := fieldByColumn(v, cd.name)
|
|
if !fv.IsValid() || !fv.CanInterface() {
|
|
continue
|
|
}
|
|
day, ok := calendarDate(fv.Interface(), cd.ignoreTimezone)
|
|
if !ok {
|
|
continue
|
|
}
|
|
var msg string
|
|
switch {
|
|
case cd.min != "" && day < cd.min:
|
|
msg = fieldMessage(ctx, tr, "after_or_equal", cd.name, map[string]string{"date": cd.min})
|
|
case cd.max != "" && day > cd.max:
|
|
msg = fieldMessage(ctx, tr, "before_or_equal", cd.name, map[string]string{"date": cd.max})
|
|
}
|
|
if msg != "" {
|
|
if out == nil {
|
|
out = map[string][]string{}
|
|
}
|
|
out[cd.name] = append(out[cd.name], msg)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// calendarDate is the YYYY-MM-DD date of a datepicker value, false when the
|
|
// value is not set.
|
|
func calendarDate(value any, wallClock bool) (string, bool) {
|
|
switch v := value.(type) {
|
|
case lagoon.Date:
|
|
return v.String(), !v.IsZero()
|
|
case *lagoon.Date:
|
|
if v == nil || v.IsZero() {
|
|
return "", false
|
|
}
|
|
return v.String(), true
|
|
case time.Time:
|
|
return instantDate(v, wallClock)
|
|
case *time.Time:
|
|
if v == nil {
|
|
return "", false
|
|
}
|
|
return instantDate(*v, wallClock)
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func instantDate(t time.Time, wallClock bool) (string, bool) {
|
|
if t.IsZero() {
|
|
return "", false
|
|
}
|
|
if !wallClock {
|
|
t = t.UTC()
|
|
}
|
|
return t.Format("2006-01-02"), true
|
|
}
|