Unverified Commit e49db634 authored by Eric Myhre's avatar Eric Myhre Committed by GitHub

Merge pull request #130 from ipld/focused-transform

Implement traversal.FocusedTransform.
parents 8fa241ea 39cddf71
...@@ -147,13 +147,21 @@ func (p Path) String() string { ...@@ -147,13 +147,21 @@ func (p Path) String() string {
return sb.String() return sb.String()
} }
// Segements returns a slice of the path segment strings. // Segments returns a slice of the path segment strings.
// //
// It is not lawful to mutate nor append the returned slice. // It is not lawful to mutate nor append the returned slice.
func (p Path) Segments() []PathSegment { func (p Path) Segments() []PathSegment {
return p.segments return p.segments
} }
// Len returns the number of segments in this path.
//
// Zero segments means the path refers to "the current node".
// One segment means it refers to a child of the current node; etc.
func (p Path) Len() int {
return len(p.segments)
}
// Join creates a new path composed of the concatenation of this and the given path's segments. // Join creates a new path composed of the concatenation of this and the given path's segments.
func (p Path) Join(p2 Path) Path { func (p Path) Join(p2 Path) Path {
combinedSegments := make([]PathSegment, len(p.segments)+len(p2.segments)) combinedSegments := make([]PathSegment, len(p.segments)+len(p2.segments))
...@@ -191,3 +199,20 @@ func (p Path) Parent() Path { ...@@ -191,3 +199,20 @@ func (p Path) Parent() Path {
func (p Path) Truncate(i int) Path { func (p Path) Truncate(i int) Path {
return Path{p.segments[0:i]} return Path{p.segments[0:i]}
} }
// Last returns the trailing segment of the path.
func (p Path) Last() PathSegment {
if len(p.segments) < 1 {
return PathSegment{}
}
return p.segments[len(p.segments)-1]
}
// Shift returns the first segment of the path together with the remaining path after that first segment.
// If applied to a zero-length path, it returns an empty segment and the same zero-length path.
func (p Path) Shift() (PathSegment, Path) {
if len(p.segments) < 1 {
return PathSegment{}, Path{}
}
return p.segments[0], Path{p.segments[1:]}
}
...@@ -45,3 +45,24 @@ func (prog *Progress) init() { ...@@ -45,3 +45,24 @@ func (prog *Progress) init() {
} }
prog.Cfg.init() prog.Cfg.init()
} }
// asPathSegment figures out how to coerce a node into a PathSegment.
// If it's a typed node: we take its representation. (Could be a struct with some string representation.)
// If it's a string or an int, that's it.
// Any other case will panic. (If you're using this one keys returned by a MapIterator, though, you can ignore this possibility;
// any compliant map implementation should've already rejected that data long ago, and should not be able to yield it to you from an iterator.)
func asPathSegment(n ipld.Node) ipld.PathSegment {
if n2, ok := n.(schema.TypedNode); ok {
n = n2.Representation()
}
switch n.Kind() {
case ipld.Kind_String:
s, _ := n.AsString()
return ipld.PathSegmentOfString(s)
case ipld.Kind_Int:
i, _ := n.AsInt()
return ipld.PathSegmentOfInt(i)
default:
panic(fmt.Errorf("cannot get pathsegment from a %s", n.Kind()))
}
}
...@@ -36,8 +36,8 @@ func Get(n ipld.Node, p ipld.Path) (ipld.Node, error) { ...@@ -36,8 +36,8 @@ func Get(n ipld.Node, p ipld.Path) (ipld.Node, error) {
// It cannot cross links automatically (since this requires configuration). // It cannot cross links automatically (since this requires configuration).
// Use the equivalent FocusedTransform function on the Progress structure // Use the equivalent FocusedTransform function on the Progress structure
// for more advanced and configurable walks. // for more advanced and configurable walks.
func FocusedTransform(n ipld.Node, p ipld.Path, fn TransformFn) (ipld.Node, error) { func FocusedTransform(n ipld.Node, p ipld.Path, fn TransformFn, createParents bool) (ipld.Node, error) {
return Progress{}.FocusedTransform(n, p, fn) return Progress{}.FocusedTransform(n, p, fn, createParents)
} }
// Focus traverses a Node graph according to a path, reaches a single Node, // Focus traverses a Node graph according to a path, reaches a single Node,
...@@ -171,13 +171,200 @@ func (prog *Progress) get(n ipld.Node, p ipld.Path, trackProgress bool) (ipld.No ...@@ -171,13 +171,200 @@ func (prog *Progress) get(n ipld.Node, p ipld.Path, trackProgress bool) (ipld.No
// using more TransformFn calls as desired to produce the replacement elements // using more TransformFn calls as desired to produce the replacement elements
// if it so happens that those replacement elements are easiest to construct // if it so happens that those replacement elements are easiest to construct
// by regarding them as incremental updates to the previous values. // by regarding them as incremental updates to the previous values.
// (This approach can also be used when doing other modifications like insertion
// or reordering -- which would otherwise be tricky to define, since
// each operation could change the meaning of subsequently used indexes.)
//
// As a special case, list appending is supported by using the path segment "-".
// (This is determined by the node it applies to -- if that path segment
// is applied to a map, it's just a regular map key of the string of dash.)
// //
// Note that anything you can do with the Transform function, you can also // Note that anything you can do with the Transform function, you can also
// do with regular Node and NodeBuilder usage directly. Transform just // do with regular Node and NodeBuilder usage directly. Transform just
// does a large amount of the intermediate bookkeeping that's useful when // does a large amount of the intermediate bookkeeping that's useful when
// creating new values which are partial updates to existing values. // creating new values which are partial updates to existing values.
// //
// This feature is not yet implemented. func (prog Progress) FocusedTransform(n ipld.Node, p ipld.Path, fn TransformFn, createParents bool) (ipld.Node, error) {
func (prog Progress) FocusedTransform(n ipld.Node, p ipld.Path, fn TransformFn) (ipld.Node, error) { prog.init()
panic("TODO") // TODO surprisingly different from Focus -- need to store nodes we traversed, and able do building. nb := n.Prototype().NewBuilder()
if err := prog.focusedTransform(n, nb, p, fn, createParents); err != nil {
return nil, err
}
return nb.Build(), nil
}
// focusedTransform assumes that an update will actually happen, and as it recurses deeper,
// begins building an updated node tree.
//
// As implemented, this is not actually efficient if the update will be a no-op; it won't notice until it gets there.
func (prog Progress) focusedTransform(n ipld.Node, na ipld.NodeAssembler, p ipld.Path, fn TransformFn, createParents bool) error {
if p.Len() == 0 {
n2, err := fn(prog, n)
if err != nil {
return err
}
return na.ConvertFrom(n2)
}
seg, p2 := p.Shift()
// Special branch for if we've entered createParent mode in an earlier step.
// This needs slightly different logic because there's no prior node to reference
// (and we wouldn't want to waste time creating a dummy one).
if n == nil {
ma, err := na.BeginMap(1)
if err != nil {
return err
}
prog.Path = prog.Path.AppendSegment(seg)
if err := ma.AssembleKey().AssignString(seg.String()); err != nil {
return err
}
if err := prog.focusedTransform(nil, ma.AssembleValue(), p2, fn, createParents); err != nil {
return err
}
return ma.Finish()
}
// Handle node based on kind.
// If it's a recursive kind (map or list), we'll be recursing on it.
// If it's a link, load it! And recurse on it.
// If it's a scalar kind (any of the rest), we'll... be erroring, actually;
// if we're at the end, it was already handled at the top of the function,
// so we only get to this case if we were expecting to go deeper.
switch n.Kind() {
case ipld.Kind_Map:
ma, err := na.BeginMap(n.Length())
if err != nil {
return err
}
// Copy children over. Replace the target (preserving its current position!) while doing this, if found.
// Note that we don't recurse into copying children (assuming ConvertFrom doesn't); this is as shallow/COW as the ConvertFrom implementation permits.
var replaced bool
for itr := n.MapIterator(); !itr.Done(); {
k, v, err := itr.Next()
if err != nil {
return err
}
if err := ma.AssembleKey().ConvertFrom(k); err != nil {
return err
}
if asPathSegment(k).Equals(seg) {
prog.Path = prog.Path.AppendSegment(seg)
if err := prog.focusedTransform(v, ma.AssembleValue(), p2, fn, createParents); err != nil {
return err
}
replaced = true
} else {
if err := ma.AssembleValue().ConvertFrom(v); err != nil {
return err
}
}
}
if replaced {
return ma.Finish()
}
// If we didn't find the target yet: append it.
// If we're at the end, always do this;
// if we're in the middle, only do this if createParents mode is enabled.
prog.Path = prog.Path.AppendSegment(seg)
if p.Len() > 1 && !createParents {
return fmt.Errorf("transform: parent position at %q did not exist (and createParents was false)", prog.Path)
}
if err := ma.AssembleKey().AssignString(seg.String()); err != nil {
return err
}
if err := prog.focusedTransform(nil, ma.AssembleValue(), p2, fn, createParents); err != nil {
return err
}
return ma.Finish()
case ipld.Kind_List:
la, err := na.BeginList(n.Length())
if err != nil {
return err
}
// First figure out if this path segment can apply to a list sanely at all.
// Simultaneously, get it in numeric format, so subsequent operations are cheaper.
ti, err := seg.Index()
if err != nil {
if seg.String() == "-" {
ti = -1
} else {
return fmt.Errorf("transform: cannot navigate path segment %q at %q because a list is here", seg, prog.Path)
}
}
// Copy children over. Replace the target (preserving its current position!) while doing this, if found.
// Note that we don't recurse into copying children (assuming ConvertFrom doesn't); this is as shallow/COW as the ConvertFrom implementation permits.
var replaced bool
for itr := n.ListIterator(); !itr.Done(); {
i, v, err := itr.Next()
if err != nil {
return err
}
if ti == i {
prog.Path = prog.Path.AppendSegment(seg)
if err := prog.focusedTransform(v, la.AssembleValue(), p2, fn, createParents); err != nil {
return err
}
replaced = true
} else {
if err := la.AssembleValue().ConvertFrom(v); err != nil {
return err
}
}
}
if replaced {
return la.Finish()
}
// If we didn't find the target yet: hopefully this was an append operation;
// if it wasn't, then it's index out of bounds. We don't arbitrarily extend lists with filler.
if ti >= 0 {
return fmt.Errorf("transform: cannot navigate path segment %q at %q because it is beyond the list bounds", seg, prog.Path)
}
prog.Path = prog.Path.AppendSegment(ipld.PathSegmentOfInt(n.Length()))
if err := prog.focusedTransform(nil, la.AssembleValue(), p2, fn, createParents); err != nil {
return err
}
return la.Finish()
case ipld.Kind_Link:
lnkCtx := ipld.LinkContext{
LinkPath: prog.Path,
LinkNode: n,
ParentNode: nil, // TODO inconvenient that we don't have this. maybe this whole case should be a helper function.
}
lnk, _ := n.AsLink()
// Pick what in-memory format we will build.
np, err := prog.Cfg.LinkTargetNodePrototypeChooser(lnk, lnkCtx)
if err != nil {
return fmt.Errorf("transform: error traversing node at %q: could not load link %q: %s", prog.Path, lnk, err)
}
nb := np.NewBuilder()
// Load link!
err = lnk.Load(
prog.Cfg.Ctx,
lnkCtx,
nb,
prog.Cfg.LinkLoader,
)
if err != nil {
return fmt.Errorf("transform: error traversing node at %q: could not load link %q: %s", prog.Path, lnk, err)
}
prog.LastBlock.Path = prog.Path
prog.LastBlock.Link = lnk
n = nb.Build()
// Recurse.
// Start a new builder for this, using the same prototype we just used for loading the link.
// (Or more specifically: this is an opportunity for just resetting a builder and reusing memory!)
// When we come back... we'll have to engage serialization and storage on the new node!
// Path isn't updated here (neither progress nor to-go).
nb.Reset()
if err := prog.focusedTransform(n, nb, p, fn, createParents); err != nil {
return err
}
n = nb.Build()
lnk, err = lnk.LinkBuilder().Build(prog.Cfg.Ctx, lnkCtx, n, prog.Cfg.LinkStorer)
if err != nil {
return fmt.Errorf("transform: error storing transformed node at %q: %s", prog.Path, err)
}
return na.AssignLink(lnk)
default:
return fmt.Errorf("transform: parent position at %q was a scalar, cannot go deeper", prog.Path)
}
} }
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
cid "github.com/ipfs/go-cid" cid "github.com/ipfs/go-cid"
ipld "github.com/ipld/go-ipld-prime" ipld "github.com/ipld/go-ipld-prime"
"github.com/ipld/go-ipld-prime/must"
_ "github.com/ipld/go-ipld-prime/codec/dagjson" _ "github.com/ipld/go-ipld-prime/codec/dagjson"
"github.com/ipld/go-ipld-prime/fluent" "github.com/ipld/go-ipld-prime/fluent"
...@@ -207,3 +208,196 @@ func TestGetWithLinkLoading(t *testing.T) { ...@@ -207,3 +208,196 @@ func TestGetWithLinkLoading(t *testing.T) {
Wish(t, n, ShouldEqual, basicnode.NewString("zoo")) Wish(t, n, ShouldEqual, basicnode.NewString("zoo"))
}) })
} }
func TestFocusedTransform(t *testing.T) {
t.Run("UpdateMapEntry", func(t *testing.T) {
n, err := traversal.FocusedTransform(rootNode, ipld.ParsePath("plain"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "plain")
Wish(t, must.String(prev), ShouldEqual, "olde string")
nb := prev.Prototype().NewBuilder()
nb.AssignString("new string!")
return nb.Build(), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// updated value should be there
Wish(t, must.Node(n.LookupByString("plain")), ShouldEqual, basicnode.NewString("new string!"))
// everything else should be there
Wish(t, must.Node(n.LookupByString("linkedString")), ShouldEqual, must.Node(rootNode.LookupByString("linkedString")))
Wish(t, must.Node(n.LookupByString("linkedMap")), ShouldEqual, must.Node(rootNode.LookupByString("linkedMap")))
Wish(t, must.Node(n.LookupByString("linkedList")), ShouldEqual, must.Node(rootNode.LookupByString("linkedList")))
// everything should still be in the same order
Wish(t, keys(n), ShouldEqual, []string{"plain", "linkedString", "linkedMap", "linkedList"})
})
t.Run("UpdateDeeperMap", func(t *testing.T) {
n, err := traversal.FocusedTransform(middleMapNode, ipld.ParsePath("nested/alink"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "nested/alink")
Wish(t, prev, ShouldEqual, basicnode.NewLink(leafAlphaLnk))
return basicnode.NewString("new string!"), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// updated value should be there
Wish(t, must.Node(must.Node(n.LookupByString("nested")).LookupByString("alink")), ShouldEqual, basicnode.NewString("new string!"))
// everything else in the parent map should should be there!
Wish(t, must.Node(n.LookupByString("foo")), ShouldEqual, must.Node(middleMapNode.LookupByString("foo")))
Wish(t, must.Node(n.LookupByString("bar")), ShouldEqual, must.Node(middleMapNode.LookupByString("bar")))
// everything should still be in the same order
Wish(t, keys(n), ShouldEqual, []string{"foo", "bar", "nested"})
})
t.Run("AppendIfNotExists", func(t *testing.T) {
n, err := traversal.FocusedTransform(rootNode, ipld.ParsePath("newpart"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "newpart")
Wish(t, prev, ShouldEqual, nil) // REVIEW: should ipld.Absent be used here? I lean towards "no" but am unsure what's least surprising here.
// An interesting thing to note about inserting a value this way is that you have no `prev.Prototype().NewBuilder()` to use if you wanted to.
// But if that's an issue, then what you do is a focus or walk (transforming or not) to the parent node, get its child prototypes, and go from there.
return basicnode.NewString("new string!"), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// updated value should be there
Wish(t, must.Node(n.LookupByString("newpart")), ShouldEqual, basicnode.NewString("new string!"))
// everything should still be in the same order... with the new entry at the end.
Wish(t, keys(n), ShouldEqual, []string{"plain", "linkedString", "linkedMap", "linkedList", "newpart"})
})
t.Run("CreateParents", func(t *testing.T) {
n, err := traversal.FocusedTransform(rootNode, ipld.ParsePath("newsection/newpart"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "newsection/newpart")
Wish(t, prev, ShouldEqual, nil) // REVIEW: should ipld.Absent be used here? I lean towards "no" but am unsure what's least surprising here.
return basicnode.NewString("new string!"), nil
}, true)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// a new map node in the middle should've been created
n2 := must.Node(n.LookupByString("newsection"))
Wish(t, n2.Kind(), ShouldEqual, ipld.Kind_Map)
// updated value should in there
Wish(t, must.Node(n2.LookupByString("newpart")), ShouldEqual, basicnode.NewString("new string!"))
// everything in the root map should still be in the same order... with the new entry at the end.
Wish(t, keys(n), ShouldEqual, []string{"plain", "linkedString", "linkedMap", "linkedList", "newsection"})
// and the created intermediate map of course has just one entry.
Wish(t, keys(n2), ShouldEqual, []string{"newpart"})
})
t.Run("CreateParentsRequiresPermission", func(t *testing.T) {
_, err := traversal.FocusedTransform(rootNode, ipld.ParsePath("newsection/newpart"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, true, ShouldEqual, false) // ought not be reached
return nil, nil
}, false)
Wish(t, err, ShouldEqual, fmt.Errorf("transform: parent position at \"newsection\" did not exist (and createParents was false)"))
})
t.Run("UpdateListEntry", func(t *testing.T) {
n, err := traversal.FocusedTransform(middleListNode, ipld.ParsePath("2"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "2")
Wish(t, prev, ShouldEqual, basicnode.NewLink(leafBetaLnk))
return basicnode.NewString("new string!"), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_List)
// updated value should be there
Wish(t, must.Node(n.LookupByIndex(2)), ShouldEqual, basicnode.NewString("new string!"))
// everything else should be there
Wish(t, n.Length(), ShouldEqual, int64(4))
Wish(t, must.Node(n.LookupByIndex(0)), ShouldEqual, basicnode.NewLink(leafAlphaLnk))
Wish(t, must.Node(n.LookupByIndex(1)), ShouldEqual, basicnode.NewLink(leafAlphaLnk))
Wish(t, must.Node(n.LookupByIndex(3)), ShouldEqual, basicnode.NewLink(leafAlphaLnk))
})
t.Run("AppendToList", func(t *testing.T) {
n, err := traversal.FocusedTransform(middleListNode, ipld.ParsePath("-"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "4")
Wish(t, prev, ShouldEqual, nil)
return basicnode.NewString("new string!"), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_List)
// updated value should be there
Wish(t, must.Node(n.LookupByIndex(4)), ShouldEqual, basicnode.NewString("new string!"))
// everything else should be there
Wish(t, n.Length(), ShouldEqual, int64(5))
})
t.Run("ListBounds", func(t *testing.T) {
_, err := traversal.FocusedTransform(middleListNode, ipld.ParsePath("4"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, true, ShouldEqual, false) // ought not be reached
return nil, nil
}, false)
Wish(t, err, ShouldEqual, fmt.Errorf("transform: cannot navigate path segment \"4\" at \"\" because it is beyond the list bounds"))
})
t.Run("ReplaceRoot", func(t *testing.T) { // a fairly degenerate case and no reason to do this, but should work.
n, err := traversal.FocusedTransform(middleListNode, ipld.ParsePath(""), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "")
Wish(t, prev, ShouldEqual, middleListNode)
nb := basicnode.Prototype.Any.NewBuilder()
la, _ := nb.BeginList(0)
la.Finish()
return nb.Build(), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_List)
Wish(t, n.Length(), ShouldEqual, int64(0))
})
}
func TestFocusedTransformWithLinks(t *testing.T) {
var storage2 = make(map[ipld.Link][]byte)
cfg := traversal.Config{
LinkLoader: func(lnk ipld.Link, _ ipld.LinkContext) (io.Reader, error) {
return bytes.NewReader(storage[lnk]), nil
},
LinkTargetNodePrototypeChooser: func(_ ipld.Link, _ ipld.LinkContext) (ipld.NodePrototype, error) {
return basicnode.Prototype.Any, nil
},
LinkStorer: func(lnkCtx ipld.LinkContext) (io.Writer, ipld.StoreCommitter, error) {
wr := bytes.Buffer{}
return &wr, func(link ipld.Link) error {
storage2[link] = wr.Bytes()
return nil
}, nil
},
}
t.Run("UpdateMapBeyondLink", func(t *testing.T) {
n, err := traversal.Progress{
Cfg: &cfg,
}.FocusedTransform(rootNode, ipld.ParsePath("linkedMap/nested/nonlink"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "linkedMap/nested/nonlink")
Wish(t, must.String(prev), ShouldEqual, "zoo")
Wish(t, progress.LastBlock.Path.String(), ShouldEqual, "linkedMap")
Wish(t, progress.LastBlock.Link.String(), ShouldEqual, "baguqefye7xlxqda")
nb := prev.Prototype().NewBuilder()
nb.AssignString("new string!")
return nb.Build(), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// there should be a new object in our new storage!
Wish(t, len(storage2), ShouldEqual, 1)
// cleanup for next test
storage2 = make(map[ipld.Link][]byte)
})
t.Run("UpdateNotBeyondLink", func(t *testing.T) {
// This is replacing a link with a non-link. Doing so shouldn't hit storage.
n, err := traversal.Progress{
Cfg: &cfg,
}.FocusedTransform(rootNode, ipld.ParsePath("linkedMap"), func(progress traversal.Progress, prev ipld.Node) (ipld.Node, error) {
Wish(t, progress.Path.String(), ShouldEqual, "linkedMap")
nb := prev.Prototype().NewBuilder()
nb.AssignString("new string!")
return nb.Build(), nil
}, false)
Wish(t, err, ShouldEqual, nil)
Wish(t, n.Kind(), ShouldEqual, ipld.Kind_Map)
// there should be no new objects in our new storage!
Wish(t, len(storage2), ShouldEqual, 0)
// cleanup for next test
storage2 = make(map[ipld.Link][]byte)
})
// link traverse to scalar // this is unspecifiable using the current path syntax! you'll just end up replacing the link with the scalar!
}
func keys(n ipld.Node) []string {
v := make([]string, 0, n.Length())
for itr := n.MapIterator(); !itr.Done(); {
k, _, _ := itr.Next()
v = append(v, must.String(k))
}
return v
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment