Browse Source

Add inherited options and values from flagset to config

master
Tyler Sommer 7 months ago
parent
commit
0c12899c88
Signed by: tyler-sommer GPG Key ID: C09C010500DBD008
10 changed files with 439 additions and 44 deletions
  1. +28
    -8
      config/config.go
  2. +4
    -4
      config/config_test.go
  3. +154
    -4
      config/option.go
  4. +156
    -0
      config/option_test.go
  5. +1
    -0
      config/plugin.go
  6. +54
    -2
      config/setup.go
  7. +37
    -15
      config/value.go
  8. +2
    -2
      config/value_test.go
  9. +1
    -5
      irc/plugin.go
  10. +2
    -4
      plugins/squircy2_compat/plugin.go

+ 28
- 8
config/config.go View File

@ -22,7 +22,7 @@
// co := &Config{"veonik", &struct{Age int}{30}}
// c, err := config.Wrap(co,
// config.WithRequiredOption("Name"),
// config.WithGenericSection("Bio", config.WithInitValue(co.Bio), config.WithRequiredOption("Age")))
// config.WithGenericSection("Bio", config.WithRequiredOption("Age")))
// if err != nil {
// panic(err)
// }
@ -37,26 +37,44 @@
//
package config // import "code.dopame.me/veonik/squircy3/config"
// A Value is some value stored in a configuration.
type Value interface{}
// A Config represents a single, configured section.
// Configs are collections of Values each with one or more keys referencing
// each Value stored.
// Configs may be nested within other Configs by using sections.
type Config interface {
// Self returns the Value stored for the Config itself.
// This will be a map[string]interface{} unless otherwise set with an
// initial value or prototype func.
Self() Value
// Get returns the Value stored with the given key.
// The second return parameter will be false if the given key is unset.
Get(key string) (Value, bool)
// String returns the string stored with the given key.
// The second return parameter will be false if the given key is unset
// or not a string.
String(key string) (string, bool)
// Bool returns the bool stored with the given key.
// The second return parameter will be false if the given key is unset
// or not a bool.
Bool(key string) (bool, bool)
// Int returns the int stored with the given key.
// The second return parameter will be false if the given key is unset
// or not an int.
Int(key string) (int, bool)
// Set sets the given key to the given Value.
Set(key string, val Value)
// Section returns the nested configuration for the given key.
// If the section does not exist, an error will be returned.
Section(key string) (Config, error)
}
type Section interface {
Prefix() string
Prototype() Value
Singleton() bool
}
// New creates and populates a new Config using the given options.
func New(options ...SetupOption) (Config, error) {
s := newSetup("root")
s := newSetup("root", nil)
if err := s.apply(options...); err != nil {
return nil, err
}
@ -66,6 +84,8 @@ func New(options ...SetupOption) (Config, error) {
return s.config, s.validate()
}
// Wrap creates and populates a new Config using the given Value as the stored
// representation of the configuration.
func Wrap(wrapped Value, options ...SetupOption) (Config, error) {
return New(append([]SetupOption{WithInitValue(wrapped)}, options...)...)
}

+ 4
- 4
config/config_test.go View File

@ -7,7 +7,7 @@ import (
"code.dopame.me/veonik/squircy3/config"
)
func TestWrap(t *testing.T) {
func TestWrapWithFieldStructPointer(t *testing.T) {
type Config struct {
Name string
Bio *struct {
@ -17,7 +17,7 @@ func TestWrap(t *testing.T) {
co := &Config{"veonik", &struct{ Age int }{30}}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithInitValue(co.Bio), config.WithRequiredOption("Age")))
config.WithGenericSection("Bio", config.WithRequiredOption("Age")))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
@ -44,7 +44,7 @@ func TestWrap(t *testing.T) {
// veonik is 30.
}
func TestWrap2(t *testing.T) {
func TestWrapWithFieldStructNonPointer(t *testing.T) {
type Config struct {
Name string
Bio struct {
@ -54,7 +54,7 @@ func TestWrap2(t *testing.T) {
co := &Config{"veonik", struct{ Age int }{30}}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithInitValue(&co.Bio), config.WithRequiredOption("Age")))
config.WithGenericSection("Bio", config.WithRequiredOption("Age")))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return


+ 154
- 4
config/option.go View File

@ -1,17 +1,33 @@
package config
import (
"flag"
"strings"
"github.com/BurntSushi/toml"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// A Section describes the pre-configured state of a nested configuration
// section.
type Section interface {
// Name is used as the name of the section.
Name() string
// Prototype returns the zero value for the section.
Prototype() Value
// Singleton is true if the section may only exist once.
Singleton() bool
}
// section is a default implementation of a Section.
type section struct {
name string
prototype protoFunc
singleton bool
}
func (sec section) Prefix() string {
func (sec section) Name() string {
return sec.name
}
@ -26,13 +42,15 @@ func (sec section) Singleton() bool {
return sec.singleton
}
// WithGenericSection will add a basic section with the given name and options.
func WithGenericSection(name string, options ...SetupOption) SetupOption {
return WithSection(&section{name: name}, options...)
}
// WithSection will add a Section with the given options.
func WithSection(sec Section, options ...SetupOption) SetupOption {
return func(s *Setup) error {
n := sec.Prefix()
n := sec.Name()
if _, ok := s.sections[n]; ok {
return errors.Errorf(`section "%s" already exists`, n)
}
@ -45,7 +63,7 @@ func WithSection(sec Section, options ...SetupOption) SetupOption {
if pr := sec.Prototype(); !isNil(pr) {
opts = append(opts, WithInitPrototype(sec.Prototype))
}
ns := newSetup(n)
ns := newSetup(n, s)
if err := ns.apply(opts...); err != nil {
return err
}
@ -54,6 +72,7 @@ func WithSection(sec Section, options ...SetupOption) SetupOption {
}
}
// WithSingleton will enable or disable a section's singleton property.
func WithSingleton(singleton bool) SetupOption {
return func(s *Setup) error {
s.singleton = singleton
@ -61,6 +80,9 @@ func WithSingleton(singleton bool) SetupOption {
}
}
// WithInitValue uses the given Value as the starting point for the section.
// Initial values are updated via reflection and kept in sync with changes made
// to the Config.
func WithInitValue(value Value) SetupOption {
return func(s *Setup) error {
s.prototype = nil
@ -69,6 +91,9 @@ func WithInitValue(value Value) SetupOption {
}
}
// WithInitPrototype sets the given func as the prototype.
// The prototype func will be invoked and its return value will be used to
// populate the initial value in the Config.
func WithInitPrototype(proto func() Value) SetupOption {
return func(c *Setup) error {
c.initial = nil
@ -77,10 +102,12 @@ func WithInitPrototype(proto func() Value) SetupOption {
}
}
// WithOption adds an optional option to the Config.
func WithOption(name string) SetupOption {
return WithOptions(name)
}
// WithOptions adds multiple optional options to the Config.
func WithOptions(names ...string) SetupOption {
return func(s *Setup) error {
for _, n := range names {
@ -90,10 +117,12 @@ func WithOptions(names ...string) SetupOption {
}
}
// WithRequiredOption adds a required option to the Config.
func WithRequiredOption(name string) SetupOption {
return WithRequiredOptions(name)
}
// WithRequiredOptions adds multiple required options to the Config.
func WithRequiredOptions(names ...string) SetupOption {
return func(s *Setup) error {
for _, n := range names {
@ -103,12 +132,133 @@ func WithRequiredOptions(names ...string) SetupOption {
}
}
// WithInheritedOption will inherit an option from the parent Config.
func WithInheritedOption(name string) SetupOption {
return func(s *Setup) error {
ps := s.parent
if ps == nil {
return errors.Errorf("config: unable to inherit option '%s' for section %s; no parent found", name, s.name)
}
s.inherits[name] = struct{}{}
return nil
}
}
// WithInheritedSection will inherit a section from the parent Config.
func WithInheritedSection(name string) SetupOption {
return WithInheritedOption(name)
}
// WithValuesFromTOMLFile will populate the Config with values parsed from a
// TOML file.
func WithValuesFromTOMLFile(filename string) SetupOption {
return func(s *Setup) error {
s.raw = make(map[string]interface{})
if s.raw == nil {
s.raw = make(map[string]interface{})
}
if _, err := toml.DecodeFile(filename, &s.raw); err != nil {
return err
}
return nil
}
}
type flagMapper struct {
s *Setup
}
func newFlagMapper(s *Setup) *flagMapper {
return &flagMapper{s}
}
func (fm *flagMapper) normalize(name string) string {
return strings.ToLower(
strings.ReplaceAll(name, "-", "_"))
}
// Map converts a flag name into a path based on sections and options.
// If a Config has sections "A" and "B" has an option "c", then the flag
// named "-a-b-c" would be converted into the path ["A","B","c"].
func (fm *flagMapper) Map(flagName string) (path []string) {
normal := fm.normalize(flagName)
s := fm.s
loop:
for s != nil {
// a valueInspector here handles struct tag aliases.
is, err := inspect(s.initial)
if err != nil {
logrus.Debugf("config: unable to create valueInspector for %s: %s", s.name, err)
} else {
if _, err := is.Get(normal); err == nil {
path = append(path, normal)
s = nil
goto loop
} else {
logrus.Debugf("config: valueInspector returned error for %s in %s: %s", normal, s.name, err)
}
}
// check for a match in options next
for k := range s.options {
kn := fm.normalize(k)
if kn == normal {
// found it
path = append(path, k)
s = nil
goto loop
}
}
// check for a matching section, using the name as a prefix
for k, ks := range s.sections {
kn := fm.normalize(k) + "_"
if strings.HasPrefix(normal, kn) {
// found the next step in the path
normal = strings.Replace(normal, kn, "", 1)
path = append(path, k)
s = ks
goto loop
}
}
return nil
}
return path
}
// WithValuesFromFlagSet populates the Config using command-line flags.
func WithValuesFromFlagSet(fs *flag.FlagSet) SetupOption {
return func(s *Setup) error {
if !fs.Parsed() {
return errors.Errorf("given FlagSet must be parsed")
}
if s.raw == nil {
s.raw = make(map[string]interface{})
}
m := newFlagMapper(s)
fs.Visit(func(f *flag.Flag) {
path := m.Map(f.Name)
if len(path) == 0 {
logrus.Debugf("config: did not match anything for flag '%s' for section %s", f.Name, s.name)
return
}
var val interface{} = f.Value.String()
if fg, ok := f.Value.(flag.Getter); ok {
val = fg.Get()
}
v := s.raw
i := 0
for i = 0; i < len(path)-1; i++ {
if vs, ok := v[path[i]].(map[string]interface{}); ok {
v = vs
} else {
if vr, ok := v[path[i]]; ok {
logrus.Debugf("config: overriding existing value in raw config for %s -- was type %T", f.Name, vr)
}
nv := make(map[string]interface{})
v[path[i]] = nv
v = nv
}
}
v[path[i]] = val
})
return nil
}
}

+ 156
- 0
config/option_test.go View File

@ -0,0 +1,156 @@
package config_test
import (
"flag"
"fmt"
"testing"
"code.dopame.me/veonik/squircy3/config"
)
func TestWithValuesFromFlagSet(t *testing.T) {
type Config struct {
Name string
Bio *struct {
Age int
}
}
co := &Config{"veonik", &struct{ Age int }{30}}
fs := flag.NewFlagSet("", flag.ExitOnError)
fs.String("name", "", "your name")
fs.Int("bio-age", 0, "you age")
if err := fs.Parse([]string{"-name", "tyler", "-bio-age", "31"}); err != nil {
t.Errorf("unexpected error parsing flagset: %s", err)
return
}
c, err := config.Wrap(co,
config.WithRequiredOption("Name"),
config.WithGenericSection("Bio", config.WithRequiredOption("Age")),
config.WithValuesFromFlagSet(fs))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
fmt.Printf("Hi, %s!\n", co.Name)
n, ok := c.String("Name")
if !ok {
t.Errorf("expected name to be a string")
return
}
b, err := c.Section("Bio")
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
a, ok := b.Int("Age")
if !ok {
t.Errorf("expected age to be an int")
return
}
if co.Name != n {
t.Errorf("expected Name option (%s) to match Name field on Config struct (%s)", n, co.Name)
}
if co.Bio.Age != a {
t.Errorf("expected Bio.Age option (%d) to match Age field on Bio struct (%d)", a, co.Bio.Age)
}
fmt.Printf("%s is %d.\n", n, a)
// Outputs:
// Hi, tyler!
// tyler is 31.
}
func TestWithInheritedOption(t *testing.T) {
type Config struct {
BasePath string
Name string
Bio *struct {
BasePath string
Age int
}
}
co := &Config{"/root", "veonik", &struct {
BasePath string
Age int
}{"", 30}}
c, err := config.Wrap(co,
config.WithRequiredOption("BasePath"),
config.WithGenericSection(
"Bio",
config.WithInheritedOption("BasePath")))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
fmt.Printf("root BasePath: %s\n", co.BasePath)
fmt.Printf("bio BasePath: %s\n", co.Bio.BasePath)
_, ok := c.String("BasePath")
if !ok {
t.Errorf("expected BasePath to be a string")
return
}
b, err := c.Section("Bio")
if err != nil {
t.Errorf("expected to get section named Bio, but got error: %s", err)
return
}
bp, ok := b.String("BasePath")
if !ok {
t.Errorf("expected base-path to be a string")
return
}
if co.Bio.BasePath != bp {
t.Errorf("expected Bio.BasePath option (%s) to match BasePath field on Bio struct (%s)", bp, co.Bio.BasePath)
}
if co.BasePath != bp {
t.Errorf("expected BasePath field on Config struct (%s) to match BasePath field on Bio struct (%s)", bp, co.BasePath)
}
if co.Bio.Age != 30 {
t.Errorf("expected unmanaged field Age on Bio struct (%d) to equal initially set value (30)", co.Bio.Age)
}
// Outputs:
// root BasePath: /root
// bio BasePath: /root
}
func TestWithInheritedSection(t *testing.T) {
c, err := config.New(
config.WithGenericSection(
"Test",
config.WithRequiredOption("Var"),
config.WithInitValue(map[string]interface{}{"Var": "test"})),
config.WithGenericSection(
"Sub",
config.WithInheritedOption("Test")))
if err != nil {
t.Errorf("expected config to be valid, but got error: %s", err)
return
}
st, err := c.Section("Test")
if err != nil {
t.Errorf("expected to get section named Test, but got error: %s", err)
return
}
ss, err := c.Section("Sub")
if err != nil {
t.Errorf("expected to get section named Sub, but got error: %s", err)
return
}
sst, err := ss.Section("Test")
if err != nil {
t.Errorf("expected to get section named Sub, but got error: %s", err)
return
}
sts, ok := st.String("Var")
if !ok {
t.Errorf("expected base-path to be a string")
return
}
ssts, ok := sst.String("Var")
if !ok {
t.Errorf("expected base-path to be a string")
return
}
fmt.Printf("%s == %s\n", sts, ssts)
// Outputs:
// test == test
}

+ 1
- 0
config/plugin.go View File

@ -36,6 +36,7 @@ type configPlugin struct {
current Config
}
// A configurablePlugin is a plugin that can be configured using this package.
type configurablePlugin interface {
plugin.Plugin


+ 54
- 2
config/setup.go View File

@ -1,9 +1,12 @@
package config
import (
"fmt"
"github.com/pkg/errors"
)
// A SetupOption is a function that modifies the given Setup in some way.
type SetupOption func(c *Setup) error
type protoFunc func() Value
@ -16,25 +19,31 @@ type Setup struct {
initial Value
config Config
parent *Setup
raw map[string]interface{}
sections map[string]*Setup
options map[string]bool
inherits map[string]struct{}
}
func newSetup(name string) *Setup {
func newSetup(name string, parent *Setup) *Setup {
return &Setup{
name: name,
prototype: nil,
singleton: false,
initial: nil,
config: nil,
parent: parent,
raw: make(map[string]interface{}),
sections: make(map[string]*Setup),
options: make(map[string]bool),
inherits: make(map[string]struct{}),
}
}
// apply calls each SetupOption, halting on the first error encountered.
func (s *Setup) apply(options ...SetupOption) error {
for _, o := range options {
if err := o(s); err != nil {
@ -44,6 +53,7 @@ func (s *Setup) apply(options ...SetupOption) error {
return nil
}
// validate checks that all required options are set, recursively.
func (s *Setup) validate() error {
if s.config == nil {
return errors.New(`expected config to be populated, found nil`)
@ -68,10 +78,21 @@ func (s *Setup) validate() error {
return nil
}
// walkAndWrap populates the Config and all nested sections.
func walkAndWrap(s *Setup) error {
wrapErr := func(err error) error {
if s.name != "root" {
return errors.WithMessage(err, fmt.Sprintf("section %s", s.name))
}
return err
}
if isNil(s.initial) && s.prototype != nil {
s.initial = s.prototype()
}
if isNil(s.initial) && s.parent != nil {
vo, _ := s.parent.config.Get(s.name)
s.initial = pointerTo(vo)
}
if isNil(s.initial) {
s.initial = make(map[string]interface{})
}
@ -80,10 +101,41 @@ func walkAndWrap(s *Setup) error {
} else {
co, err := newConfigurable(s)
if err != nil {
return err
return wrapErr(err)
}
s.config = co
}
if err := walkInherits(s); err != nil {
return wrapErr(err)
}
if err := walkSections(s); err != nil {
return wrapErr(err)
}
return nil
}
// walkInherits synchronizes inherited options and sections between this and
// the parent.
func walkInherits(s *Setup) error {
for si := range s.inherits {
if s.parent == nil {
return errors.Errorf("unable to inherit option %s from non-existent parent", si)
}
if sec, ok := s.parent.sections[si]; ok {
// its a section
s.config.Set(si, sec.config)
} else if vo, ok := s.parent.config.Get(si); ok {
// its an option
s.config.Set(si, vo)
} else {
return errors.Errorf("unable inherit non-existent option %s from parent %s", si, s.parent.name)
}
}
return nil
}
// walkSections walks through each section, populating a Config for each.
func walkSections(s *Setup) error {
for _, ns := range s.sections {
if v, ok := s.raw[ns.name].(map[string]interface{}); ok {
ns.raw = v


+ 37
- 15
config/value.go View File

@ -9,15 +9,19 @@ import (
"github.com/sirupsen/logrus"
)
// configurable must implement Config.
var _ Config = &configurable{}
type configurableOpts map[string]Value
type Configurable struct {
// configurable is the default Config implementation.
type configurable struct {
Value
options configurableOpts
inspector *valueInspector
}
func newConfigurable(s *Setup) (*Configurable, error) {
func newConfigurable(s *Setup) (*configurable, error) {
if isNil(s.initial) {
return nil, errors.New("unable to wrap <nil>")
}
@ -25,7 +29,7 @@ func newConfigurable(s *Setup) (*Configurable, error) {
if err != nil {
return nil, err
}
c := &Configurable{Value: s.initial, options: make(configurableOpts), inspector: is}
c := &configurable{Value: s.initial, options: make(configurableOpts), inspector: is}
for k, v := range s.raw {
c.Set(k, v)
}
@ -39,19 +43,23 @@ func newConfigurable(s *Setup) (*Configurable, error) {
return c, nil
}
func (c *Configurable) Get(key string) (Value, bool) {
if v, ok := c.options[key]; ok {
return v, true
}
func (c *configurable) Self() Value {
return c.Value
}
func (c *configurable) Get(key string) (Value, bool) {
if v, err := c.inspector.Get(key); err == nil {
return v, true
} else {
if v, ok := c.options[key]; ok {
return v, true
}
logrus.Warnln("error getting value from config:", err)
}
return nil, false
}
func (c *Configurable) String(key string) (string, bool) {
func (c *configurable) String(key string) (string, bool) {
v, ok := c.Get(key)
if !ok {
return "", false
@ -62,7 +70,7 @@ func (c *Configurable) String(key string) (string, bool) {
return "", false
}
func (c *Configurable) Bool(key string) (bool, bool) {
func (c *configurable) Bool(key string) (bool, bool) {
v, ok := c.Get(key)
if !ok {
return false, false
@ -73,7 +81,7 @@ func (c *Configurable) Bool(key string) (bool, bool) {
return false, false
}
func (c *Configurable) Int(key string) (int, bool) {
func (c *configurable) Int(key string) (int, bool) {
v, ok := c.Get(key)
if !ok {
return 0, false
@ -84,7 +92,7 @@ func (c *Configurable) Int(key string) (int, bool) {
return 0, false
}
func (c *Configurable) Set(key string, val Value) {
func (c *configurable) Set(key string, val Value) {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
@ -94,9 +102,9 @@ func (c *Configurable) Set(key string, val Value) {
c.inspector.Set(key, val)
}
func (c *Configurable) Section(key string) (Config, error) {
func (c *configurable) Section(key string) (Config, error) {
v := c.options[key]
if vv, ok := v.(*Configurable); ok {
if vv, ok := v.(*configurable); ok {
return vv, nil
}
if s, ok := v.(Config); ok {
@ -105,6 +113,8 @@ func (c *Configurable) Section(key string) (Config, error) {
return nil, errors.Errorf(`section "%s" contains unexpected type %T: %v`, key, v, v)
}
// A valueInspector abstracts the reading and modifying of a Value,
// particularly structs, struct pointers, and map.
type valueInspector struct {
value reflect.Value
typ reflect.Type
@ -142,7 +152,7 @@ func (i *valueInspector) Get(name string) (Value, error) {
}
func (i *valueInspector) Set(name string, val Value) {
if vc, ok := val.(*Configurable); ok {
if vc, ok := val.(*configurable); ok {
val = vc.Value
}
rv := reflect.ValueOf(val)
@ -180,7 +190,7 @@ func (i *valueInspector) Set(name string, val Value) {
func trySet(m reflect.Value, rv reflect.Value) {
defer func() {
if v := recover(); v != nil {
logrus.Warnln("config: recovered panic:", v)
logrus.Debugln("config: failed to set value using reflection:", v)
}
}()
want := m.Kind()
@ -266,3 +276,15 @@ func isNil(v Value) bool {
}
return false
}
// pointerTo returns a pointer to the given Value if it is not already one.
func pointerTo(v Value) interface{} {
if v == nil {
return true
}
vo := reflect.ValueOf(v)
if vo.Kind() != reflect.Ptr && vo.CanAddr() {
return vo.Pointer()
}
return vo.Interface()
}

+ 2
- 2
config/value_test.go View File

@ -6,7 +6,7 @@ import (
func TestConfigurable_withMap(t *testing.T) {
co := map[string]interface{}{}
s := newSetup("root")
s := newSetup("root", nil)
err := s.apply(WithInitValue(co))
if err != nil {
t.Fatalf("unexpected error: %s", err)
@ -41,7 +41,7 @@ type TestConfig struct {
func TestConfigurable_withStruct(t *testing.T) {
co := &TestConfig{}
s := newSetup("root")
s := newSetup("root", nil)
err := s.apply(WithInitValue(co))
if err != nil {
t.Fatalf("unexpected error: %s", err)


+ 1
- 5
irc/plugin.go View File

@ -58,11 +58,7 @@ func (p *ircPlugin) Configure(c config.Config) error {
}
func configFromGeneric(g config.Config) (c *Config, err error) {
gc, ok := g.(*config.Configurable)
if !ok {
return c, errors.Errorf("%s: value is not a *config.Configurable", pluginName)
}
if gcv, ok := gc.Value.(*Config); ok {
if gcv, ok := g.Self().(*Config); ok {
return gcv, nil
}
return c, errors.Errorf("%s: value is not a *irc.Config", pluginName)


+ 2
- 4
plugins/squircy2_compat/plugin.go View File

@ -38,10 +38,8 @@ type shimPlugin struct {
}
func (p *shimPlugin) Configure(c config.Config) error {
if gc, ok := c.(*config.Configurable); ok {
if gcv, ok := gc.Value.(*Config); ok {
return p.HelperSet.Configure(*gcv)
}
if gcv, ok := c.Self().(*Config); ok {
return p.HelperSet.Configure(*gcv)
}
cf := Config{}
cf.EnableFileAPI, _ = c.Bool("enable_file_api")


Loading…
Cancel
Save