mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-21 20:41:06 +02:00
Some checks failed
QA / Linting e2e (push) Has been cancelled
QA / Testing e2e (dev stack) (push) Has been cancelled
QA / Linting server/core (push) Has been cancelled
QA / Unit testing server/core (push) Has been cancelled
QA / Linting web (push) Has been cancelled
QA / Testing web (push) Has been cancelled
721 lines
22 KiB
Go
721 lines
22 KiB
Go
// Package variable provides the domain types for the global variables an
|
|
// organization filters its live page data by.
|
|
package variable
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/guregu/null/v5"
|
|
"github.com/oxynote/oxynote/server/core/internal/document"
|
|
"github.com/oxynote/oxynote/server/core/internal/tag"
|
|
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
|
"github.com/oxynote/oxynote/server/core/pkg/sliceutil"
|
|
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
|
|
"github.com/rs/xid"
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
// _maxValueLength is the most characters a value or an option holds, so
|
|
// a query that references one many times stays small.
|
|
const _maxValueLength = 128
|
|
|
|
// All available Type constants.
|
|
const (
|
|
// TypeList is a variable whose value is one of its options.
|
|
TypeList Type = "list"
|
|
|
|
// TypeNumber is a variable whose value is a decimal number.
|
|
TypeNumber Type = "number"
|
|
|
|
// TypeText is a variable whose value is free text.
|
|
TypeText Type = "text"
|
|
|
|
// TypeTimeRange is a variable whose value is a time range preset.
|
|
TypeTimeRange Type = "time-range"
|
|
)
|
|
|
|
var (
|
|
// ErrInvalidGroupName is returned when a group is given an empty name.
|
|
ErrInvalidGroupName = errutil.New(http.StatusBadRequest, "variable.invalid_group_name", "group name cannot be empty")
|
|
|
|
// ErrInvalidGroupColor is returned when a group is given a colour
|
|
// outside the palette.
|
|
ErrInvalidGroupColor = errutil.New(http.StatusBadRequest, "variable.invalid_group_color", "group colour must be one of the palette")
|
|
|
|
// ErrEmptyGroupUpdate is returned when a group update sets neither a
|
|
// name nor a colour.
|
|
ErrEmptyGroupUpdate = errutil.New(http.StatusBadRequest, "variable.empty_group_update", "group update needs a name or a colour")
|
|
|
|
// ErrDuplicateGroupName is returned when a group takes a name the
|
|
// organization already uses.
|
|
ErrDuplicateGroupName = errutil.New(http.StatusConflict, "variable.duplicate_group_name", "group name is already in use")
|
|
|
|
// ErrGroupNotEmpty is returned when a group that still holds variables
|
|
// is deleted.
|
|
ErrGroupNotEmpty = errutil.New(http.StatusConflict, "variable.group_not_empty", "group still has variables")
|
|
|
|
// ErrBuiltinGroup is returned when the built-in group is changed or
|
|
// given variables.
|
|
ErrBuiltinGroup = errutil.New(http.StatusForbidden, "variable.builtin_group", "built-in group cannot be changed")
|
|
|
|
// ErrInvalidKey is returned when a variable key is not lowercase
|
|
// letters, digits and underscores starting with a letter.
|
|
ErrInvalidKey = errutil.New(http.StatusBadRequest, "variable.invalid_key", "key must start with a letter and hold only lowercase letters, digits and underscores")
|
|
|
|
// ErrDuplicateKey is returned when a variable takes a key the
|
|
// organization already uses.
|
|
ErrDuplicateKey = errutil.New(http.StatusConflict, "variable.duplicate_key", "key is already in use")
|
|
|
|
// ErrInvalidName is returned when a variable is given an empty name.
|
|
ErrInvalidName = errutil.New(http.StatusBadRequest, "variable.invalid_name", "variable name cannot be empty")
|
|
|
|
// ErrInvalidType is returned when a variable carries an unknown type.
|
|
ErrInvalidType = errutil.New(http.StatusBadRequest, "variable.invalid_type", "variable type must be list, number, text or time-range")
|
|
|
|
// ErrMissingOptions is returned when a list variable has no options.
|
|
ErrMissingOptions = errutil.New(http.StatusBadRequest, "variable.missing_options", "list variable needs at least one option")
|
|
|
|
// ErrUnexpectedOptions is returned when a variable other than a list
|
|
// is given options.
|
|
ErrUnexpectedOptions = errutil.New(http.StatusBadRequest, "variable.unexpected_options", "only a list variable has options")
|
|
|
|
// ErrInvalidOption is returned when an option is given an empty value.
|
|
ErrInvalidOption = errutil.New(http.StatusBadRequest, "variable.invalid_option", "option value cannot be empty")
|
|
|
|
// ErrValueTooLong is returned when a value, a default or an option
|
|
// holds more than _maxValueLength characters.
|
|
ErrValueTooLong = errutil.New(http.StatusBadRequest, "variable.value_too_long", "value cannot be longer than 128 characters")
|
|
|
|
// ErrDuplicateOption is returned when a variable's input names the
|
|
// same option twice.
|
|
ErrDuplicateOption = errutil.New(http.StatusBadRequest, "variable.duplicate_option", "option is listed more than once")
|
|
|
|
// ErrUnknownOption is returned when an option id does not belong to
|
|
// the variable.
|
|
ErrUnknownOption = errutil.New(http.StatusBadRequest, "variable.unknown_option", "option does not belong to the variable")
|
|
|
|
// ErrUnexpectedDefault is returned when a list variable is given a
|
|
// default value: its first option is the default.
|
|
ErrUnexpectedDefault = errutil.New(http.StatusBadRequest, "variable.unexpected_default", "a list variable's default is its first option")
|
|
|
|
// ErrMissingDefault is returned when a time range variable has no
|
|
// default value.
|
|
ErrMissingDefault = errutil.New(http.StatusBadRequest, "variable.missing_default", "time range variable needs a default value")
|
|
|
|
// ErrInvalidNumber is returned when a number variable is given a value
|
|
// that is not a decimal number.
|
|
ErrInvalidNumber = errutil.New(http.StatusBadRequest, "variable.invalid_number", "value must be a decimal number")
|
|
|
|
// ErrInvalidTimeRange is returned when a time range variable is given
|
|
// a value that is not a known preset.
|
|
ErrInvalidTimeRange = errutil.New(http.StatusBadRequest, "variable.invalid_time_range", "value must be a time range preset")
|
|
|
|
// ErrBuiltinVariable is returned when anything but the default value of
|
|
// a built-in variable is changed.
|
|
ErrBuiltinVariable = errutil.New(http.StatusForbidden, "variable.builtin", "only the default value of a built-in variable can change")
|
|
|
|
// ErrInvalidSortIndex is returned when a move targets a position
|
|
// outside the list.
|
|
ErrInvalidSortIndex = errutil.New(http.StatusBadRequest, "variable.invalid_sort_index", "sort index is out of range")
|
|
)
|
|
|
|
var (
|
|
// _keyPattern is the shape every variable key has.
|
|
_keyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
|
|
|
// _referencePattern matches a $ and the whole word after it, so a
|
|
// longer word is never cut down to a shorter key.
|
|
_referencePattern = regexp.MustCompile(`\$[A-Za-z_][A-Za-z0-9_]*`)
|
|
|
|
// _keyReferencePattern matches the start of a reference that can name
|
|
// a key. Macros such as $__from and placeholders such as $1 cannot.
|
|
_keyReferencePattern = regexp.MustCompile(`\$[a-z]`)
|
|
)
|
|
|
|
// Type represents the kind of value a variable holds.
|
|
type Type string
|
|
|
|
// Validate reports whether the type is a known one.
|
|
func (t Type) Validate() error {
|
|
switch t {
|
|
case TypeList, TypeNumber, TypeText, TypeTimeRange:
|
|
return nil
|
|
default:
|
|
return ErrInvalidType
|
|
}
|
|
}
|
|
|
|
// MarshalText returns the type as text.
|
|
func (t Type) MarshalText() ([]byte, error) {
|
|
return []byte(t), nil
|
|
}
|
|
|
|
// UnmarshalText parses a type, refusing an unknown one so a bad value is
|
|
// reported where it is decoded.
|
|
func (t *Type) UnmarshalText(text []byte) error {
|
|
v := Type(strings.ToLower(strings.TrimSpace(string(text))))
|
|
if err := v.Validate(); err != nil {
|
|
return fmt.Errorf("variable type must be one of %s, %s, %s, %s, got %q", TypeList, TypeNumber, TypeText, TypeTimeRange, text)
|
|
}
|
|
|
|
*t = v
|
|
|
|
return nil
|
|
}
|
|
|
|
// Group represents a named, coloured set of variables.
|
|
type Group struct {
|
|
// ID specifies the unique identifier of the group.
|
|
ID xid.ID `json:"id" db:"id"`
|
|
|
|
// OrganizationID specifies the organization the group belongs to.
|
|
OrganizationID string `json:"organizationId" db:"fk_organization_id"`
|
|
|
|
// GroupName specifies the display name of the group.
|
|
GroupName string `json:"groupName" db:"group_name"`
|
|
|
|
// Color specifies the group's colour: the Hex of one tag palette
|
|
// entry.
|
|
Color string `json:"color" db:"color"`
|
|
|
|
// SortIndex specifies the position of the group among its
|
|
// organization's groups.
|
|
SortIndex int `json:"sortIndex" db:"sort_index"`
|
|
|
|
// Builtin indicates the group ships with every organization and
|
|
// cannot change.
|
|
Builtin bool `json:"builtin" db:"builtin"`
|
|
|
|
// CreatedAt specifies the moment the group was created.
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
|
|
// CreatedBy specifies the user who created the group.
|
|
CreatedBy null.String `json:"createdBy" db:"fk_created_by"`
|
|
}
|
|
|
|
// NewGroup creates a fresh instance of Group from the given input.
|
|
func NewGroup(inp GroupCreateInput, organizationID, userID string) Group {
|
|
return Group{
|
|
ID: xid.New(),
|
|
OrganizationID: organizationID,
|
|
GroupName: inp.GroupName,
|
|
Color: inp.Color,
|
|
CreatedAt: timeutil.Now(),
|
|
CreatedBy: null.StringFrom(userID),
|
|
}
|
|
}
|
|
|
|
// AllowsChange reports whether the group can be renamed, recoloured,
|
|
// moved, deleted or given variables.
|
|
func (g Group) AllowsChange() error {
|
|
if g.Builtin {
|
|
return ErrBuiltinGroup
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GroupCreateInput is the input for creating a group.
|
|
type GroupCreateInput struct {
|
|
// GroupName specifies the display name of the group.
|
|
GroupName string `json:"groupName"`
|
|
|
|
// Color specifies the group's colour: the Hex of one tag palette
|
|
// entry.
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
// Validate reports whether the input can become a group.
|
|
func (gci GroupCreateInput) Validate() error {
|
|
if gci.GroupName == "" {
|
|
return ErrInvalidGroupName
|
|
}
|
|
|
|
if tag.ColorName(gci.Color) == "" {
|
|
return ErrInvalidGroupColor
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GroupUpdateInput is the input for renaming or recolouring a group. A
|
|
// field left unset keeps its current value.
|
|
type GroupUpdateInput struct {
|
|
// GroupName specifies the new display name of the group.
|
|
GroupName null.String `json:"groupName"`
|
|
|
|
// Color specifies the new colour of the group.
|
|
Color null.String `json:"color"`
|
|
}
|
|
|
|
// Validate reports whether the input changes a group: at least one field
|
|
// has to be set, and a set field is held to the same rules as on creation.
|
|
func (gui GroupUpdateInput) Validate() error {
|
|
if !gui.GroupName.Valid && !gui.Color.Valid {
|
|
return ErrEmptyGroupUpdate
|
|
}
|
|
|
|
if gui.GroupName.Valid && gui.GroupName.String == "" {
|
|
return ErrInvalidGroupName
|
|
}
|
|
|
|
if gui.Color.Valid && tag.ColorName(gui.Color.String) == "" {
|
|
return ErrInvalidGroupColor
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GroupMoveInput is the input for moving a group to another position.
|
|
type GroupMoveInput struct {
|
|
// ID specifies the group to move.
|
|
ID xid.ID `json:"id"`
|
|
|
|
// SortIndex specifies the position among the groups other than the
|
|
// built-in one that the group ends up at.
|
|
SortIndex int `json:"sortIndex"`
|
|
}
|
|
|
|
// Variable represents a named value pages filter their live data by. Its
|
|
// definition is shared by the organization.
|
|
type Variable struct {
|
|
// ID specifies the unique identifier of the variable.
|
|
ID xid.ID `json:"id" db:"id"`
|
|
|
|
// OrganizationID specifies the organization the variable belongs to.
|
|
OrganizationID string `json:"organizationId" db:"fk_organization_id"`
|
|
|
|
// GroupID specifies the group the variable is listed under.
|
|
GroupID xid.ID `json:"groupId" db:"fk_variable_group_id"`
|
|
|
|
// Key specifies the name queries and text reference the variable by,
|
|
// without the leading $.
|
|
Key string `json:"key" db:"variable_key"`
|
|
|
|
// Name specifies the display name of the variable.
|
|
Name string `json:"name" db:"variable_name"`
|
|
|
|
// Description specifies an optional explanation of the variable.
|
|
Description null.String `json:"description" db:"description"`
|
|
|
|
// Type specifies the kind of value the variable holds.
|
|
Type Type `json:"type" db:"variable_type"`
|
|
|
|
// DefaultValue specifies the value a user who set none sees. For a
|
|
// list it is the id of the first option.
|
|
DefaultValue null.String `json:"defaultValue" db:"default_value"`
|
|
|
|
// SortIndex specifies the position of the variable within its group.
|
|
SortIndex int `json:"sortIndex" db:"sort_index"`
|
|
|
|
// Builtin indicates the variable ships with every organization and
|
|
// only its default value can change.
|
|
Builtin bool `json:"builtin" db:"builtin"`
|
|
|
|
// CreatedAt specifies the moment the variable was created.
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
|
|
// CreatedBy specifies the user who created the variable.
|
|
CreatedBy null.String `json:"createdBy" db:"fk_created_by"`
|
|
|
|
// Options specifies a list variable's options in order; empty for any
|
|
// other type.
|
|
Options []Option `json:"options" db:"-"`
|
|
}
|
|
|
|
// NewVariable creates a fresh instance of Variable from the given input,
|
|
// with its options in order. The input is expected to be valid. Every
|
|
// option gets a fresh id: an id named by the input could belong to
|
|
// another variable.
|
|
func NewVariable(inp Input, organizationID, userID string) Variable {
|
|
v := Variable{
|
|
ID: xid.New(),
|
|
OrganizationID: organizationID,
|
|
CreatedAt: timeutil.Now(),
|
|
CreatedBy: null.StringFrom(userID),
|
|
}
|
|
|
|
inp.Options = slices.Clone(inp.Options)
|
|
|
|
for i := range inp.Options {
|
|
inp.Options[i].ID = null.Value[xid.ID]{}
|
|
}
|
|
|
|
return v.apply(inp)
|
|
}
|
|
|
|
// AllowsChange reports whether anything but the default value of the
|
|
// variable can change.
|
|
func (v Variable) AllowsChange() error {
|
|
if v.Builtin {
|
|
return ErrBuiltinVariable
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Apply returns the variable as the input describes it. An option keeps
|
|
// its id when the input names it; naming an option the variable does not
|
|
// have is refused. The receiver is left untouched.
|
|
func (v Variable) Apply(inp Input) (Variable, error) {
|
|
for _, oi := range inp.Options {
|
|
if !oi.ID.Valid {
|
|
continue
|
|
}
|
|
|
|
if !slices.ContainsFunc(v.Options, func(o Option) bool { return o.ID == oi.ID.V }) {
|
|
return Variable{}, ErrUnknownOption
|
|
}
|
|
}
|
|
|
|
return v.apply(inp), nil
|
|
}
|
|
|
|
// apply copies the input's fields onto a copy of the variable and builds
|
|
// its options, reusing the ids the input names.
|
|
func (v Variable) apply(inp Input) Variable {
|
|
v.GroupID = inp.GroupID
|
|
v.Key = inp.Key
|
|
v.Name = inp.Name
|
|
v.Description = inp.Description
|
|
v.Type = inp.Type
|
|
v.DefaultValue = inp.DefaultValue
|
|
v.Options = make([]Option, 0, len(inp.Options))
|
|
|
|
for i, oi := range inp.Options {
|
|
id := oi.ID.V
|
|
if !oi.ID.Valid {
|
|
id = xid.New()
|
|
}
|
|
|
|
v.Options = append(v.Options, Option{
|
|
ID: id,
|
|
VariableID: v.ID,
|
|
Value: oi.Value,
|
|
SortIndex: i,
|
|
})
|
|
}
|
|
|
|
if v.Type == TypeList && len(v.Options) > 0 {
|
|
v.DefaultValue = null.StringFrom(v.Options[0].ID.String())
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// ValidateValue reports whether the value fits the variable: one of its
|
|
// options for a list, a decimal for a number, a preset for a time range
|
|
// and anything for text.
|
|
func (v Variable) ValidateValue(value string) error {
|
|
if utf8.RuneCountInString(value) > _maxValueLength {
|
|
return ErrValueTooLong
|
|
}
|
|
|
|
switch v.Type {
|
|
case TypeList:
|
|
if !slices.ContainsFunc(v.Options, func(o Option) bool { return o.ID.String() == value }) {
|
|
return ErrUnknownOption
|
|
}
|
|
case TypeNumber:
|
|
if _, err := decimal.NewFromString(value); err != nil {
|
|
return ErrInvalidNumber
|
|
}
|
|
case TypeTimeRange:
|
|
if !slices.Contains(document.MetricTimeRanges(), value) {
|
|
return ErrInvalidTimeRange
|
|
}
|
|
case TypeText:
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WithDefault returns the variable with the given default value. A list's
|
|
// default is its first option, so the chosen one moves to the top. The
|
|
// receiver is left untouched.
|
|
func (v Variable) WithDefault(value string) (Variable, error) {
|
|
if err := v.ValidateValue(value); err != nil {
|
|
return Variable{}, err
|
|
}
|
|
|
|
v.DefaultValue = null.StringFrom(value)
|
|
|
|
if v.Type != TypeList {
|
|
return v, nil
|
|
}
|
|
|
|
v.Options = slices.Clone(v.Options)
|
|
|
|
i := slices.IndexFunc(v.Options, func(o Option) bool { return o.ID.String() == value })
|
|
sliceutil.Move(v.Options, i, 0)
|
|
|
|
for i := range v.Options {
|
|
v.Options[i].SortIndex = i
|
|
}
|
|
|
|
return v, nil
|
|
}
|
|
|
|
// Input is the input for creating a variable or replacing its
|
|
// definition.
|
|
type Input struct {
|
|
// GroupID specifies the group the variable is listed under.
|
|
GroupID xid.ID `json:"groupId"`
|
|
|
|
// Key specifies the name queries and text reference the variable by,
|
|
// without the leading $.
|
|
Key string `json:"key"`
|
|
|
|
// Name specifies the display name of the variable.
|
|
Name string `json:"name"`
|
|
|
|
// Description specifies an optional explanation of the variable.
|
|
Description null.String `json:"description"`
|
|
|
|
// Type specifies the kind of value the variable holds.
|
|
Type Type `json:"type"`
|
|
|
|
// DefaultValue specifies the value a user who set none sees. A list
|
|
// takes none: its first option is the default.
|
|
DefaultValue null.String `json:"defaultValue"`
|
|
|
|
// Options specifies a list variable's options in order.
|
|
Options []OptionInput `json:"options"`
|
|
}
|
|
|
|
// Validate reports whether the input can become a variable.
|
|
func (vi Input) Validate() error {
|
|
if !_keyPattern.MatchString(vi.Key) {
|
|
return ErrInvalidKey
|
|
}
|
|
|
|
if vi.Name == "" {
|
|
return ErrInvalidName
|
|
}
|
|
|
|
if err := vi.Type.Validate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if vi.Type != TypeList {
|
|
if len(vi.Options) > 0 {
|
|
return ErrUnexpectedOptions
|
|
}
|
|
|
|
if vi.Type == TypeTimeRange && !vi.DefaultValue.Valid {
|
|
return ErrMissingDefault
|
|
}
|
|
|
|
if !vi.DefaultValue.Valid {
|
|
return nil
|
|
}
|
|
|
|
return Variable{Type: vi.Type}.ValidateValue(vi.DefaultValue.String)
|
|
}
|
|
|
|
if len(vi.Options) == 0 {
|
|
return ErrMissingOptions
|
|
}
|
|
|
|
if vi.DefaultValue.Valid {
|
|
return ErrUnexpectedDefault
|
|
}
|
|
|
|
seen := make(map[xid.ID]bool, len(vi.Options))
|
|
|
|
for _, oi := range vi.Options {
|
|
if oi.Value == "" {
|
|
return ErrInvalidOption
|
|
}
|
|
|
|
if utf8.RuneCountInString(oi.Value) > _maxValueLength {
|
|
return ErrValueTooLong
|
|
}
|
|
|
|
if !oi.ID.Valid {
|
|
continue
|
|
}
|
|
|
|
if seen[oi.ID.V] {
|
|
return ErrDuplicateOption
|
|
}
|
|
|
|
seen[oi.ID.V] = true
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MoveInput is the input for moving a variable to another
|
|
// position, in its own group or another one.
|
|
type MoveInput struct {
|
|
// ID specifies the variable to move.
|
|
ID xid.ID `json:"id"`
|
|
|
|
// GroupID specifies the group the variable ends up in.
|
|
GroupID xid.ID `json:"groupId"`
|
|
|
|
// SortIndex specifies the position within that group the variable
|
|
// ends up at.
|
|
SortIndex int `json:"sortIndex"`
|
|
}
|
|
|
|
// ValueInput is the input for setting a variable's default value or one
|
|
// user's own value.
|
|
type ValueInput struct {
|
|
// Value specifies the value: an option id for a list, a decimal for a
|
|
// number, a preset for a time range, or text.
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// Option represents one value a list variable can take.
|
|
type Option struct {
|
|
// ID specifies the unique identifier of the option.
|
|
ID xid.ID `json:"id" db:"id"`
|
|
|
|
// VariableID specifies the variable the option belongs to.
|
|
VariableID xid.ID `json:"variableId" db:"fk_variable_id"`
|
|
|
|
// Value specifies the text the option stands for.
|
|
Value string `json:"value" db:"option_value"`
|
|
|
|
// SortIndex specifies the position of the option within its variable.
|
|
SortIndex int `json:"sortIndex" db:"sort_index"`
|
|
}
|
|
|
|
// OptionInput is the input for one option of a list variable.
|
|
type OptionInput struct {
|
|
// ID specifies the existing option this one keeps the identity of.
|
|
// Unset creates a new option.
|
|
ID null.Value[xid.ID] `json:"id"`
|
|
|
|
// Value specifies the text the option stands for.
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// GroupTree represents a group with its variables in order.
|
|
type GroupTree struct {
|
|
Group
|
|
|
|
// Variables specifies the group's variables in order.
|
|
Variables []Entry `json:"variables"`
|
|
}
|
|
|
|
// Entry represents a variable with the value one user set.
|
|
type Entry struct {
|
|
Variable
|
|
|
|
// Value specifies the value the user who asked set, or null when they
|
|
// set none and see the default.
|
|
Value null.String `json:"value" db:"value"`
|
|
}
|
|
|
|
// Resolved returns the value the user sees: their own, else the default.
|
|
// A list resolves to the text of the option, not its id.
|
|
func (e Entry) Resolved() string {
|
|
value := e.Value.ValueOrZero()
|
|
if !e.Value.Valid {
|
|
value = e.DefaultValue.ValueOrZero()
|
|
}
|
|
|
|
if e.Type != TypeList {
|
|
return value
|
|
}
|
|
|
|
i := slices.IndexFunc(e.Options, func(o Option) bool { return o.ID.String() == value })
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
|
|
return e.Options[i].Value
|
|
}
|
|
|
|
// Tree represents an organization's groups in order, each with its
|
|
// variables.
|
|
type Tree []GroupTree
|
|
|
|
// NewTree arranges groups and entries, each already in order and the
|
|
// entries with their options, into a tree.
|
|
func NewTree(groups []Group, entries []Entry) Tree {
|
|
byGroup := make(map[xid.ID][]Entry, len(groups))
|
|
|
|
for _, e := range entries {
|
|
byGroup[e.GroupID] = append(byGroup[e.GroupID], e)
|
|
}
|
|
|
|
tree := make(Tree, 0, len(groups))
|
|
|
|
for _, g := range groups {
|
|
vars := byGroup[g.ID]
|
|
if vars == nil {
|
|
vars = []Entry{}
|
|
}
|
|
|
|
tree = append(tree, GroupTree{
|
|
Group: g,
|
|
Variables: vars,
|
|
})
|
|
}
|
|
|
|
return tree
|
|
}
|
|
|
|
// Substitute returns the texts with every $key replaced by the resolved
|
|
// value of the variable with that key, or by its value in overrides when
|
|
// it has one there. The value goes in raw. A reference to no variable,
|
|
// such as $1 or the $__from macro, stays as written, and so does an
|
|
// override for no variable.
|
|
func (t Tree) Substitute(overrides map[string]string, texts ...string) []string {
|
|
values := make(map[string]string)
|
|
|
|
for _, g := range t {
|
|
for _, e := range g.Variables {
|
|
value, ok := overrides[e.Key]
|
|
if !ok {
|
|
value = e.Resolved()
|
|
}
|
|
|
|
values[e.Key] = value
|
|
}
|
|
}
|
|
|
|
out := make([]string, len(texts))
|
|
|
|
for i, text := range texts {
|
|
out[i] = _referencePattern.ReplaceAllStringFunc(text, func(ref string) string {
|
|
if value, ok := values[ref[1:]]; ok {
|
|
return value
|
|
}
|
|
|
|
return ref
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// HasReference reports whether any of the texts may reference a
|
|
// variable, so a caller can skip fetching the tree when none does.
|
|
func HasReference(texts ...string) bool {
|
|
return slices.ContainsFunc(texts, _keyReferencePattern.MatchString)
|
|
}
|
|
|
|
// PlaceID returns a copy of ids with id at the given index, taken out of
|
|
// its old position first when the list holds it. The index counts the
|
|
// list without id, so it may be one past the last element.
|
|
func PlaceID(ids []xid.ID, id xid.ID, index int) ([]xid.ID, error) {
|
|
placed := slices.DeleteFunc(slices.Clone(ids), func(i xid.ID) bool { return i == id })
|
|
|
|
if index < 0 || index > len(placed) {
|
|
return nil, ErrInvalidSortIndex
|
|
}
|
|
|
|
return slices.Insert(placed, index, id), nil
|
|
}
|