mirror of https://github.com/tidwall/tile38.git
optional output formats
This commit is contained in:
parent
4e2bbf1c33
commit
4ce4e1af71
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -13,6 +14,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/peterh/liner"
|
||||
"github.com/tidwall/resp"
|
||||
"github.com/tidwall/tile38/client"
|
||||
"github.com/tidwall/tile38/core"
|
||||
)
|
||||
|
@ -39,9 +41,11 @@ type connError struct {
|
|||
|
||||
var (
|
||||
hostname = "127.0.0.1"
|
||||
output = "json"
|
||||
port = 9851
|
||||
oneCommand string
|
||||
tokml bool
|
||||
raw bool
|
||||
)
|
||||
|
||||
func showHelp() bool {
|
||||
|
@ -54,8 +58,10 @@ func showHelp() bool {
|
|||
}
|
||||
fmt.Fprintf(os.Stdout, "tile38-cli %s%s\n\n", core.Version, gitsha)
|
||||
fmt.Fprintf(os.Stdout, "Usage: tile38-cli [OPTIONS] [cmd [arg [arg ...]]]\n")
|
||||
fmt.Fprintf(os.Stdout, " -h <hostname> Server hostname (default: %s).\n", hostname)
|
||||
fmt.Fprintf(os.Stdout, " -p <port> Server port (default: %d).\n", port)
|
||||
fmt.Fprintf(os.Stdout, " --raw Use raw formatting for replies (default when STDOUT is not a tty)\n")
|
||||
fmt.Fprintf(os.Stdout, " --resp Use RESP output formatting (default is JSON output)\n")
|
||||
fmt.Fprintf(os.Stdout, " -h <hostname> Server hostname (default: %s)\n", hostname)
|
||||
fmt.Fprintf(os.Stdout, " -p <port> Server port (default: %d)\n", port)
|
||||
fmt.Fprintf(os.Stdout, "\n")
|
||||
return false
|
||||
}
|
||||
|
@ -82,9 +88,10 @@ func parseArgs() bool {
|
|||
fmt.Fprintf(os.Stderr, "Unrecognized option or bad number of args for: '%s'\n", arg)
|
||||
return false
|
||||
}
|
||||
|
||||
for len(args) > 0 {
|
||||
arg := readArg("")
|
||||
if arg == "--help" {
|
||||
if arg == "--help" || arg == "-?" {
|
||||
return showHelp()
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") {
|
||||
|
@ -96,6 +103,10 @@ func parseArgs() bool {
|
|||
return badArg(arg)
|
||||
case "-kml":
|
||||
tokml = true
|
||||
case "--raw":
|
||||
raw = true
|
||||
case "--resp":
|
||||
output = "resp"
|
||||
case "-h":
|
||||
hostname = readArg(arg)
|
||||
case "-p":
|
||||
|
@ -120,6 +131,15 @@ func main() {
|
|||
if !parseArgs() {
|
||||
return
|
||||
}
|
||||
|
||||
if !raw {
|
||||
fi, err := os.Stdout.Stat()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
return
|
||||
}
|
||||
raw = (fi.Mode() & os.ModeCharDevice) == 0
|
||||
}
|
||||
if len(oneCommand) > 0 && (oneCommand[0] == 'h' || oneCommand[0] == 'H') && strings.Split(strings.ToLower(oneCommand), " ")[0] == "help" {
|
||||
showHelp()
|
||||
return
|
||||
|
@ -222,7 +242,13 @@ func main() {
|
|||
f.Close()
|
||||
}
|
||||
}()
|
||||
var raw bool
|
||||
if output == "resp" {
|
||||
_, err := conn.Do("output resp")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
var command string
|
||||
var err error
|
||||
|
@ -254,11 +280,6 @@ func main() {
|
|||
if (command[0] == 'q' || command[0] == 'Q') && strings.ToLower(command) == "quit" {
|
||||
return
|
||||
}
|
||||
if (command[0] == 'r' || command[0] == 'R') && strings.ToLower(command) == "raw" {
|
||||
raw = true
|
||||
fmt.Fprintln(os.Stderr, "raw mode is ON")
|
||||
continue
|
||||
}
|
||||
if (command[0] == 'h' || command[0] == 'H') && (strings.ToLower(command) == "help" || strings.HasPrefix(strings.ToLower(command), "help")) {
|
||||
err = help(strings.TrimSpace(command[4:]))
|
||||
if err != nil {
|
||||
|
@ -266,11 +287,6 @@ func main() {
|
|||
}
|
||||
continue
|
||||
}
|
||||
if (command[0] == 'p' || command[0] == 'P') && strings.ToLower(command) == "pretty" {
|
||||
raw = false
|
||||
fmt.Fprintln(os.Stderr, "raw mode is OFF")
|
||||
continue
|
||||
}
|
||||
aof = (command[0] == 'a' || command[0] == 'A') && strings.HasPrefix(strings.ToLower(command), "aof ")
|
||||
msg, err := conn.Do(command)
|
||||
if err != nil {
|
||||
|
@ -296,7 +312,13 @@ func main() {
|
|||
if mustOutput {
|
||||
if tokml {
|
||||
msg = convert2kml(msg)
|
||||
fmt.Fprintln(os.Stdout, string(msg))
|
||||
} else if output == "resp" {
|
||||
if !raw {
|
||||
msg = convert2termresp(msg)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, string(msg))
|
||||
} else {
|
||||
if raw {
|
||||
fmt.Fprintln(os.Stdout, string(msg))
|
||||
} else {
|
||||
|
@ -304,6 +326,7 @@ func main() {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if err == liner.ErrPromptAborted {
|
||||
return
|
||||
} else {
|
||||
|
@ -315,6 +338,67 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
func convert2termresp(msg []byte) []byte {
|
||||
rd := resp.NewReader(bytes.NewBuffer(msg))
|
||||
out := ""
|
||||
for {
|
||||
v, _, err := rd.ReadValue()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
out += convert2termrespval(v, 0)
|
||||
}
|
||||
return []byte(strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
func convert2termrespval(v resp.Value, spaces int) string {
|
||||
switch v.Type() {
|
||||
default:
|
||||
return v.String()
|
||||
case resp.BulkString:
|
||||
if v.IsNull() {
|
||||
return "(nil)"
|
||||
} else {
|
||||
return "\"" + v.String() + "\""
|
||||
}
|
||||
case resp.Integer:
|
||||
return "(integer) " + v.String()
|
||||
case resp.Error:
|
||||
return "(error) " + v.String()
|
||||
case resp.Array:
|
||||
arr := v.Array()
|
||||
if len(arr) == 0 {
|
||||
return "(empty list or set)"
|
||||
}
|
||||
out := ""
|
||||
nspaces := spaces + numlen(len(arr))
|
||||
for i, v := range arr {
|
||||
if i > 0 {
|
||||
out += strings.Repeat(" ", spaces)
|
||||
}
|
||||
iout := strings.TrimSpace(convert2termrespval(v, nspaces+2))
|
||||
out += fmt.Sprintf("%d) %s\n", i+1, iout)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func numlen(n int) int {
|
||||
l := 1
|
||||
if n < 0 {
|
||||
l++
|
||||
n = n * -1
|
||||
}
|
||||
for i := 0; i < 1000; i++ {
|
||||
if n < 10 {
|
||||
break
|
||||
}
|
||||
l++
|
||||
n = n / 10
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func convert2kml(msg []byte) []byte {
|
||||
k := NewKML()
|
||||
var m map[string]interface{}
|
||||
|
|
|
@ -190,7 +190,12 @@ func (c *Controller) handleInputCommand(conn *server.Conn, msg *server.Message,
|
|||
_, err = io.WriteString(w, res+"\r\n")
|
||||
return err
|
||||
case server.RESP:
|
||||
_, err := io.WriteString(w, res)
|
||||
var err error
|
||||
if msg.OutputType == server.JSON {
|
||||
_, err = fmt.Fprintf(w, "$%d\r\n%s\r\n", len(res), res)
|
||||
} else {
|
||||
_, err = io.WriteString(w, res)
|
||||
}
|
||||
return err
|
||||
case server.Native:
|
||||
_, err := fmt.Fprintf(w, "$%d %s\r\n", len(res), res)
|
||||
|
@ -278,6 +283,8 @@ func (c *Controller) handleInputCommand(conn *server.Conn, msg *server.Message,
|
|||
// does not write to aof, but requires a write lock.
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
case "output":
|
||||
// this is local connection operation. Locks not needed.
|
||||
case "massinsert":
|
||||
// dev operation
|
||||
// ** danger zone **
|
||||
|
@ -340,14 +347,12 @@ func (c *Controller) command(msg *server.Message, w io.Writer) (res string, d co
|
|||
res, d, err = c.cmdDrop(msg)
|
||||
case "flushdb":
|
||||
res, d, err = c.cmdFlushDB(msg)
|
||||
// case "sethook":
|
||||
// err = c.cmdSetHook(nline)
|
||||
// resp = okResp()
|
||||
// case "delhook":
|
||||
// err = c.cmdDelHook(nline)
|
||||
// resp = okResp()
|
||||
// case "hooks":
|
||||
// err = c.cmdHooks(nline, w)
|
||||
case "sethook":
|
||||
res, d, err = c.cmdSetHook(msg)
|
||||
case "delhook":
|
||||
res, d, err = c.cmdDelHook(msg)
|
||||
case "hooks":
|
||||
res, err = c.cmdHooks(msg)
|
||||
// case "massinsert":
|
||||
// if !core.DevMode {
|
||||
// err = fmt.Errorf("unknown command '%s'", cmd)
|
||||
|
@ -376,6 +381,8 @@ func (c *Controller) command(msg *server.Message, w io.Writer) (res string, d co
|
|||
res, err = c.cmdGet(msg)
|
||||
case "keys":
|
||||
res, err = c.cmdKeys(msg)
|
||||
case "output":
|
||||
res, err = c.cmdOutput(msg)
|
||||
// case "aof":
|
||||
// err = c.cmdAOF(nline, w)
|
||||
// case "aofmd5":
|
||||
|
|
|
@ -4,7 +4,6 @@ import (
|
|||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
|
@ -42,7 +41,7 @@ type Hook struct {
|
|||
Key string
|
||||
Name string
|
||||
Endpoints []Endpoint
|
||||
Command string
|
||||
Message *server.Message
|
||||
Fence *liveFenceSwitches
|
||||
ScanWriter *scanWriter
|
||||
}
|
||||
|
@ -156,70 +155,96 @@ func parseEndpoint(s string) (Endpoint, error) {
|
|||
return endpoint, nil
|
||||
}
|
||||
|
||||
func (c *Controller) cmdSetHook(line string) (err error) {
|
||||
//start := time.Now()
|
||||
func (c *Controller) cmdSetHook(msg *server.Message) (res string, d commandDetailsT, err error) {
|
||||
start := time.Now()
|
||||
|
||||
vs := msg.Values[1:]
|
||||
var name, values, cmd string
|
||||
if line, name = token(line); name == "" {
|
||||
return errInvalidNumberOfArguments
|
||||
var ok bool
|
||||
if vs, name, ok = tokenval(vs); !ok || name == "" {
|
||||
return "", d, errInvalidNumberOfArguments
|
||||
}
|
||||
if line, values = token(line); values == "" {
|
||||
return errInvalidNumberOfArguments
|
||||
if vs, values, ok = tokenval(vs); !ok || values == "" {
|
||||
return "", d, errInvalidNumberOfArguments
|
||||
}
|
||||
var endpoints []Endpoint
|
||||
for _, value := range strings.Split(values, ",") {
|
||||
endpoint, err := parseEndpoint(value)
|
||||
if err != nil {
|
||||
log.Errorf("sethook: %v", err)
|
||||
return errInvalidArgument(value)
|
||||
return "", d, errInvalidArgument(value)
|
||||
}
|
||||
endpoints = append(endpoints, endpoint)
|
||||
}
|
||||
command := line
|
||||
if line, cmd = token(line); cmd == "" {
|
||||
return errInvalidNumberOfArguments
|
||||
commandvs := vs
|
||||
if vs, cmd, ok = tokenval(vs); !ok || cmd == "" {
|
||||
return "", d, errInvalidNumberOfArguments
|
||||
}
|
||||
cmdlc := strings.ToLower(cmd)
|
||||
var types []string
|
||||
switch cmdlc {
|
||||
default:
|
||||
return errInvalidArgument(cmd)
|
||||
return "", d, errInvalidArgument(cmd)
|
||||
case "nearby":
|
||||
types = nearbyTypes
|
||||
case "within", "intersects":
|
||||
types = withinOrIntersectsTypes
|
||||
}
|
||||
var vs []resp.Value
|
||||
panic("todo: assign vs correctly")
|
||||
s, err := c.cmdSearchArgs(cmdlc, vs, types)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", d, err
|
||||
}
|
||||
if !s.fence {
|
||||
return errors.New("missing FENCE argument")
|
||||
return "", d, errors.New("missing FENCE argument")
|
||||
}
|
||||
s.cmd = cmdlc
|
||||
|
||||
cmsg := &server.Message{}
|
||||
*cmsg = *msg
|
||||
cmsg.Values = commandvs
|
||||
cmsg.Command = strings.ToLower(cmsg.Values[0].String())
|
||||
|
||||
hook := &Hook{
|
||||
Key: s.key,
|
||||
Name: name,
|
||||
Endpoints: endpoints,
|
||||
Fence: &s,
|
||||
Command: command,
|
||||
Message: cmsg,
|
||||
}
|
||||
var wr bytes.Buffer
|
||||
var msg *server.Message
|
||||
panic("todo: cmdSetHook message must be defined")
|
||||
hook.ScanWriter, err = c.newScanWriter(&wr, msg, s.key, s.output, s.precision, s.glob, s.limit, s.wheres, s.nofields)
|
||||
hook.ScanWriter, err = c.newScanWriter(&wr, cmsg, s.key, s.output, s.precision, s.glob, s.limit, s.wheres, s.nofields)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", d, err
|
||||
}
|
||||
|
||||
// delete the previous hook
|
||||
if h, ok := c.hooks[name]; ok {
|
||||
// lets see if the previous hook matches the new hook
|
||||
if h.Key == hook.Key && h.Name == hook.Name {
|
||||
if len(h.Endpoints) == len(hook.Endpoints) {
|
||||
match := true
|
||||
for i, endpoint := range h.Endpoints {
|
||||
if endpoint.Original != hook.Endpoints[i].Original {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match && resp.ArrayValue(h.Message.Values).Equals(resp.ArrayValue(hook.Message.Values)) {
|
||||
switch msg.OutputType {
|
||||
case server.JSON:
|
||||
return server.OKMessage(msg, start), d, nil
|
||||
case server.RESP:
|
||||
return ":0\r\n", d, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if hm, ok := c.hookcols[h.Key]; ok {
|
||||
delete(hm, h.Name)
|
||||
}
|
||||
delete(c.hooks, h.Name)
|
||||
}
|
||||
d.updated = true
|
||||
c.hooks[name] = hook
|
||||
hm, ok := c.hookcols[hook.Key]
|
||||
if !ok {
|
||||
|
@ -227,47 +252,72 @@ func (c *Controller) cmdSetHook(line string) (err error) {
|
|||
c.hookcols[hook.Key] = hm
|
||||
}
|
||||
hm[name] = hook
|
||||
return nil
|
||||
switch msg.OutputType {
|
||||
case server.JSON:
|
||||
return server.OKMessage(msg, start), d, nil
|
||||
case server.RESP:
|
||||
return ":1\r\n", d, nil
|
||||
}
|
||||
return "", d, nil
|
||||
}
|
||||
|
||||
func (c *Controller) cmdDelHook(line string) (err error) {
|
||||
func (c *Controller) cmdDelHook(msg *server.Message) (res string, d commandDetailsT, err error) {
|
||||
start := time.Now()
|
||||
vs := msg.Values[1:]
|
||||
|
||||
var name string
|
||||
if line, name = token(line); name == "" {
|
||||
return errInvalidNumberOfArguments
|
||||
var ok bool
|
||||
if vs, name, ok = tokenval(vs); !ok || name == "" {
|
||||
return "", d, errInvalidNumberOfArguments
|
||||
}
|
||||
if line != "" {
|
||||
return errInvalidNumberOfArguments
|
||||
if len(vs) != 0 {
|
||||
return "", d, errInvalidNumberOfArguments
|
||||
}
|
||||
if h, ok := c.hooks[name]; ok {
|
||||
if hm, ok := c.hookcols[h.Key]; ok {
|
||||
delete(hm, h.Name)
|
||||
}
|
||||
delete(c.hooks, h.Name)
|
||||
d.updated = true
|
||||
}
|
||||
|
||||
switch msg.OutputType {
|
||||
case server.JSON:
|
||||
return server.OKMessage(msg, start), d, nil
|
||||
case server.RESP:
|
||||
if d.updated {
|
||||
return ":1\r\n", d, nil
|
||||
} else {
|
||||
return ":0\r\n", d, nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (c *Controller) cmdHooks(line string, w io.Writer) (err error) {
|
||||
func (c *Controller) cmdHooks(msg *server.Message) (res string, err error) {
|
||||
start := time.Now()
|
||||
vs := msg.Values[1:]
|
||||
|
||||
var pattern string
|
||||
if line, pattern = token(line); pattern == "" {
|
||||
return errInvalidNumberOfArguments
|
||||
var ok bool
|
||||
if vs, pattern, ok = tokenval(vs); !ok || pattern == "" {
|
||||
return "", errInvalidNumberOfArguments
|
||||
}
|
||||
if line != "" {
|
||||
return errInvalidNumberOfArguments
|
||||
if len(vs) != 0 {
|
||||
return "", errInvalidNumberOfArguments
|
||||
}
|
||||
|
||||
var hooks []*Hook
|
||||
for name, hook := range c.hooks {
|
||||
if ok, err := globMatch(pattern, name); err == nil && ok {
|
||||
match, _ := globMatch(pattern, name)
|
||||
if match {
|
||||
hooks = append(hooks, hook)
|
||||
} else if err != nil {
|
||||
return errInvalidArgument(pattern)
|
||||
}
|
||||
}
|
||||
sort.Sort(hooksByName(hooks))
|
||||
|
||||
switch msg.OutputType {
|
||||
case server.JSON:
|
||||
buf := &bytes.Buffer{}
|
||||
buf.WriteString(`{"ok":true,"hooks":[`)
|
||||
for i, hook := range hooks {
|
||||
|
@ -284,13 +334,39 @@ func (c *Controller) cmdHooks(line string, w io.Writer) (err error) {
|
|||
}
|
||||
buf.WriteString(jsonString(endpoint.Original))
|
||||
}
|
||||
buf.WriteString(`],"command":` + jsonString(hook.Command))
|
||||
buf.WriteString(`}`)
|
||||
buf.WriteString(`],"command":[`)
|
||||
for i, v := range hook.Message.Values {
|
||||
if i > 0 {
|
||||
buf.WriteString(`,`)
|
||||
}
|
||||
buf.WriteString(jsonString(v.String()))
|
||||
}
|
||||
|
||||
buf.WriteString(`]}`)
|
||||
}
|
||||
buf.WriteString(`],"elapsed":"` + time.Now().Sub(start).String() + "\"}")
|
||||
|
||||
w.Write(buf.Bytes())
|
||||
return
|
||||
return buf.String(), nil
|
||||
case server.RESP:
|
||||
var vals []resp.Value
|
||||
for _, hook := range hooks {
|
||||
var hvals []resp.Value
|
||||
hvals = append(hvals, resp.StringValue(hook.Name))
|
||||
hvals = append(hvals, resp.StringValue(hook.Key))
|
||||
var evals []resp.Value
|
||||
for _, endpoint := range hook.Endpoints {
|
||||
evals = append(evals, resp.StringValue(endpoint.Original))
|
||||
}
|
||||
hvals = append(hvals, resp.ArrayValue(evals))
|
||||
hvals = append(hvals, resp.ArrayValue(hook.Message.Values))
|
||||
vals = append(vals, resp.ArrayValue(hvals))
|
||||
}
|
||||
data, err := resp.ArrayValue(vals).MarshalRESP()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (c *Controller) sendHTTPMessage(endpoint Endpoint, msg []byte) error {
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tidwall/tile38/controller/server"
|
||||
)
|
||||
|
||||
func (c *Controller) cmdOutput(msg *server.Message) (res string, err error) {
|
||||
start := time.Now()
|
||||
vs := msg.Values[1:]
|
||||
var arg string
|
||||
var ok bool
|
||||
if len(vs) != 0 {
|
||||
if vs, arg, ok = tokenval(vs); !ok || arg == "" {
|
||||
return "", errInvalidNumberOfArguments
|
||||
}
|
||||
// Setting the original message output type will be picked up by the
|
||||
// server prior to the next command being executed.
|
||||
switch strings.ToLower(arg) {
|
||||
default:
|
||||
return "", errInvalidArgument(arg)
|
||||
case "json":
|
||||
msg.OutputType = server.JSON
|
||||
case "resp":
|
||||
msg.OutputType = server.RESP
|
||||
}
|
||||
return server.OKMessage(msg, start), nil
|
||||
}
|
||||
// return the output
|
||||
switch msg.OutputType {
|
||||
default:
|
||||
return "", nil
|
||||
case server.JSON:
|
||||
return `{"ok":true,"output":"json","elapsed":` + time.Now().Sub(start).String() + `}`, nil
|
||||
case server.RESP:
|
||||
return "$4\r\nresp\r\n", nil
|
||||
}
|
||||
}
|
|
@ -98,6 +98,7 @@ func handleConn(
|
|||
}
|
||||
}
|
||||
defer conn.Close()
|
||||
outputType := Null
|
||||
rd := NewAnyReaderWriter(conn)
|
||||
brd := rd.rd
|
||||
for {
|
||||
|
@ -114,6 +115,9 @@ func handleConn(
|
|||
return
|
||||
}
|
||||
if msg != nil && msg.Command != "" {
|
||||
if outputType != Null {
|
||||
msg.OutputType = outputType
|
||||
}
|
||||
if msg.Command == "quit" {
|
||||
if msg.OutputType == RESP {
|
||||
io.WriteString(conn, "+OK\r\n")
|
||||
|
@ -125,6 +129,7 @@ func handleConn(
|
|||
log.Error(err)
|
||||
return
|
||||
}
|
||||
outputType = msg.OutputType
|
||||
} else {
|
||||
conn.Write([]byte("HTTP/1.1 500 Bad Request\r\nConnection: close\r\n\r\n"))
|
||||
return
|
||||
|
@ -132,6 +137,7 @@ func handleConn(
|
|||
if msg.ConnType == HTTP || msg.ConnType == WebSocket {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue