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 }