[add] template & page asset api
This commit is contained in:
parent
3b8f1f3a6a
commit
9af92308bb
10 changed files with 488 additions and 8 deletions
|
|
@ -92,6 +92,28 @@ var dsl = []byte(`
|
|||
"process": "sui.Editor.Source",
|
||||
"in": ["$param.id", "$param.template_id", "$param.route", "$param.kind"],
|
||||
"out": { "status": 200, "type": "application/json" }
|
||||
},
|
||||
|
||||
{
|
||||
"path": "/:id/assets/:template_id/@assets/*path",
|
||||
"method": "GET",
|
||||
"process": "sui.Template.Asset",
|
||||
"in": ["$param.id", "$param.template_id", "$param.path"],
|
||||
"out": {
|
||||
"status": 200,
|
||||
"body": "?:content",
|
||||
"headers": { "Content-Type": "?:type"}
|
||||
}
|
||||
},{
|
||||
"path": "/:id/assets/:template_id/@pages/*path",
|
||||
"method": "GET",
|
||||
"process": "sui.Page.Asset",
|
||||
"in": ["$param.id", "$param.template_id", "$param.path"],
|
||||
"out": {
|
||||
"status": 200,
|
||||
"body": "?:content",
|
||||
"headers": { "Content-Type": "?:type"}
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/process"
|
||||
|
|
@ -10,8 +11,9 @@ import (
|
|||
|
||||
func init() {
|
||||
process.RegisterGroup("sui", map[string]process.Handler{
|
||||
"template.get": TemplateGet,
|
||||
"template.find": TemplateFind,
|
||||
"template.get": TemplateGet,
|
||||
"template.find": TemplateFind,
|
||||
"template.asset": TemplateAsset,
|
||||
|
||||
"locale.get": LocaleGet,
|
||||
"theme.get": ThemeGet,
|
||||
|
|
@ -22,8 +24,9 @@ func init() {
|
|||
"component.get": ComponentGet,
|
||||
"component.find": ComponentFind,
|
||||
|
||||
"page.tree": PageTree,
|
||||
"page.get": PageGet,
|
||||
"page.tree": PageTree,
|
||||
"page.get": PageGet,
|
||||
"page.asset": PageAsset,
|
||||
|
||||
"editor.render": EditorRender,
|
||||
"editor.source": EditorSource,
|
||||
|
|
@ -48,12 +51,32 @@ func TemplateFind(process *process.Process) interface{} {
|
|||
process.ValidateArgNums(2)
|
||||
|
||||
sui := get(process)
|
||||
template, err := sui.GetTemplate(process.ArgsString(1))
|
||||
tmpl, err := sui.GetTemplate(process.ArgsString(1))
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
||||
return template
|
||||
return tmpl
|
||||
}
|
||||
|
||||
// TemplateAsset handle the find Template request
|
||||
func TemplateAsset(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
sui := get(process)
|
||||
tmpl, err := sui.GetTemplate(process.ArgsString(1))
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
||||
asset, err := tmpl.Asset(process.ArgsString(2))
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 404).Throw()
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": asset.Content,
|
||||
"type": asset.Type,
|
||||
}
|
||||
}
|
||||
|
||||
// LocaleGet handle the find Template request
|
||||
|
|
@ -200,6 +223,51 @@ func PageGet(process *process.Process) interface{} {
|
|||
return tree
|
||||
}
|
||||
|
||||
// PageAsset handle the find Template request
|
||||
func PageAsset(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
|
||||
sui := get(process)
|
||||
templateID := process.ArgsString(1)
|
||||
|
||||
tmpl, err := sui.GetTemplate(templateID)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
||||
file := process.ArgsString(2)
|
||||
page, err := tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
||||
var asset *core.Asset
|
||||
|
||||
switch filepath.Ext(file) {
|
||||
case ".css":
|
||||
asset, err = page.AssetStyle()
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 400).Throw()
|
||||
}
|
||||
break
|
||||
|
||||
case ".js", ".ts":
|
||||
asset, err = page.AssetScript()
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 400).Throw()
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
exception.New("does not support the %s file", 400, filepath.Ext(file)).Throw()
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"content": asset.Content,
|
||||
"type": asset.Type,
|
||||
}
|
||||
}
|
||||
|
||||
// EditorRender handle the render page request
|
||||
func EditorRender(process *process.Process) interface{} {
|
||||
process.ValidateArgNums(3)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,26 @@ func TestTemplateFind(t *testing.T) {
|
|||
assert.Equal(t, "tech-blue", res.(*local.Template).ID)
|
||||
}
|
||||
|
||||
func TestTemplateAsset(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
// test demo
|
||||
p, err := process.Of("sui.template.asset", "demo", "tech-blue", "/css/tailwind.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := p.Exec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, res)
|
||||
assert.Equal(t, "text/css; charset=utf-8", res.(map[string]interface{})["type"])
|
||||
assert.NotEmpty(t, res.(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestTemplateLocaleGet(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
|
@ -174,7 +194,7 @@ func TestTemplateComponentFind(t *testing.T) {
|
|||
assert.Contains(t, res.(string), "window.component__Box=")
|
||||
}
|
||||
|
||||
func TestTemplatePageTree(t *testing.T) {
|
||||
func TestPageTree(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
|
|
@ -195,7 +215,7 @@ func TestTemplatePageTree(t *testing.T) {
|
|||
assert.Equal(t, "index", res.([]*core.PageTreeNode)[1].Name)
|
||||
}
|
||||
|
||||
func TestTemplatePageGet(t *testing.T) {
|
||||
func TestPageGet(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
|
|
@ -218,6 +238,64 @@ func TestTemplatePageGet(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPageAssetJS(t *testing.T) {
|
||||
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
// test demo
|
||||
p, err := process.Of("sui.page.asset", "demo", "tech-blue", "/page/404/404.js")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := p.Exec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.IsType(t, map[string]interface{}{}, res)
|
||||
assert.Equal(t, "text/javascript; charset=utf-8", res.(map[string]interface{})["type"])
|
||||
assert.NotEmpty(t, res.(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestPageAssetTS(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
// test demo
|
||||
p, err := process.Of("sui.page.asset", "demo", "tech-blue", "/page/404/404.ts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := p.Exec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.IsType(t, map[string]interface{}{}, res)
|
||||
assert.Equal(t, "text/javascript; charset=utf-8", res.(map[string]interface{})["type"])
|
||||
assert.NotEmpty(t, res.(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestPageAssetCSS(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
||||
// test demo
|
||||
p, err := process.Of("sui.page.asset", "demo", "tech-blue", "/page/[id]/[id].css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := p.Exec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.IsType(t, map[string]interface{}{}, res)
|
||||
assert.Equal(t, "text/css; charset=utf-8", res.(map[string]interface{})["type"])
|
||||
assert.NotEmpty(t, res.(map[string]interface{})["content"])
|
||||
}
|
||||
|
||||
func TestEditorRender(t *testing.T) {
|
||||
load(t)
|
||||
defer clean()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,48 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
"github.com/yaoapp/gou/runtime/transform"
|
||||
)
|
||||
|
||||
// Compile the page
|
||||
func (page *Page) Compile() {}
|
||||
|
||||
// CompileJS compile the javascript
|
||||
func (page *Page) CompileJS(source []byte, minify bool) ([]byte, error) {
|
||||
jsCode := regexp.MustCompile(`import\s+.*;`).ReplaceAllString(string(source), "")
|
||||
if minify {
|
||||
minified, err := transform.MinifyJS(jsCode)
|
||||
return []byte(minified), err
|
||||
}
|
||||
return []byte(jsCode), nil
|
||||
}
|
||||
|
||||
// CompileTS compile the typescript
|
||||
func (page *Page) CompileTS(source []byte, minify bool) ([]byte, error) {
|
||||
tsCode := regexp.MustCompile(`import\s+.*;`).ReplaceAllString(string(source), "")
|
||||
if minify {
|
||||
jsCode, err := transform.TypeScript(string(tsCode), api.TransformOptions{
|
||||
Target: api.ESNext,
|
||||
MinifyWhitespace: true,
|
||||
MinifyIdentifiers: true,
|
||||
MinifySyntax: true,
|
||||
})
|
||||
|
||||
return []byte(jsCode), err
|
||||
}
|
||||
|
||||
jsCode, err := transform.TypeScript(string(tsCode), api.TransformOptions{Target: api.ESNext})
|
||||
return []byte(jsCode), err
|
||||
}
|
||||
|
||||
// CompileCSS compile the css
|
||||
func (page *Page) CompileCSS(source []byte, minify bool) ([]byte, error) {
|
||||
if minify {
|
||||
cssCode, err := transform.MinifyCSS(string(source))
|
||||
return []byte(cssCode), err
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type ITemplate interface {
|
|||
Pages() ([]IPage, error)
|
||||
PageTree(route string) ([]*PageTreeNode, error)
|
||||
Page(route string) (IPage, error)
|
||||
GetPageFromAsset(asset string) (IPage, error)
|
||||
|
||||
Blocks() ([]IBlock, error)
|
||||
Block(name string) (IBlock, error)
|
||||
|
|
@ -25,6 +26,8 @@ type ITemplate interface {
|
|||
Assets() []string
|
||||
Locales() []SelectOption
|
||||
Themes() []SelectOption
|
||||
|
||||
Asset(file string) (*Asset, error)
|
||||
}
|
||||
|
||||
// IPage is the interface for the page
|
||||
|
|
@ -39,6 +42,9 @@ type IPage interface {
|
|||
EditorStyleSource() ResponseSource
|
||||
EditorDataSource() ResponseSource
|
||||
|
||||
AssetScript() (*Asset, error)
|
||||
AssetStyle() (*Asset, error)
|
||||
|
||||
// Render()
|
||||
|
||||
// Html()
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ type SelectOption struct {
|
|||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Asset is the struct for the asset
|
||||
type Asset struct {
|
||||
file string
|
||||
Type string `json:"type"`
|
||||
Content []byte `json:"content"`
|
||||
}
|
||||
|
||||
// Request is the struct for the request
|
||||
type Request struct {
|
||||
Method string `json:"method"`
|
||||
|
|
|
|||
|
|
@ -254,3 +254,89 @@ func (page *Page) Load() error {
|
|||
page.Document = page.tmpl.Document
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPageFromAsset get the page from the asset
|
||||
func (tmpl *Template) GetPageFromAsset(file string) (core.IPage, error) {
|
||||
route := filepath.Dir(file)
|
||||
name := tmpl.getPageBase(route)
|
||||
return &Page{
|
||||
tmpl: tmpl,
|
||||
Page: &core.Page{
|
||||
Route: route,
|
||||
Path: filepath.Join(tmpl.Root, route),
|
||||
Name: name,
|
||||
Codes: core.SourceCodes{
|
||||
CSS: core.Source{File: fmt.Sprintf("%s.css", name)},
|
||||
JS: core.Source{File: fmt.Sprintf("%s.js", name)},
|
||||
TS: core.Source{File: fmt.Sprintf("%s.ts", name)},
|
||||
LESS: core.Source{File: fmt.Sprintf("%s.less", name)},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AssetScript get the script
|
||||
func (page *Page) AssetScript() (*core.Asset, error) {
|
||||
|
||||
// Read the Script code
|
||||
// Type script is the default language
|
||||
tsFile := filepath.Join(page.Path, page.Codes.TS.File)
|
||||
if exist, _ := page.tmpl.local.fs.Exists(tsFile); exist {
|
||||
tsCode, err := page.tmpl.local.fs.ReadFile(tsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsCode, err := page.CompileTS(tsCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/javascript; charset=utf-8",
|
||||
Content: []byte(jsCode),
|
||||
}, nil
|
||||
}
|
||||
|
||||
jsFile := filepath.Join(page.Path, page.Codes.JS.File)
|
||||
if exist, _ := page.tmpl.local.fs.Exists(jsFile); exist {
|
||||
jsCode, err := page.tmpl.local.fs.ReadFile(jsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
jsCode, err = page.CompileJS(jsCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/javascript; charset=utf-8",
|
||||
Content: jsCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Page %s script not found", page.Route)
|
||||
}
|
||||
|
||||
// AssetStyle get the style
|
||||
func (page *Page) AssetStyle() (*core.Asset, error) {
|
||||
cssFile := filepath.Join(page.Path, page.Codes.CSS.File)
|
||||
if exist, _ := page.tmpl.local.fs.Exists(cssFile); exist {
|
||||
cssCode, err := page.tmpl.local.fs.ReadFile(cssFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cssCode, err = page.CompileCSS(cssCode, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &core.Asset{
|
||||
Type: "text/css; charset=utf-8",
|
||||
Content: cssCode,
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Page %s style not found", page.Route)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,3 +186,106 @@ func TestPageRenderEditor(t *testing.T) {
|
|||
assert.Equal(t, "@assets/css/tailwind.css", res.Styles[3])
|
||||
assert.Equal(t, "@pages/index/index.css", res.Styles[4])
|
||||
}
|
||||
|
||||
func TestPageGetPageFromAsset(t *testing.T) {
|
||||
|
||||
tests := prepare(t)
|
||||
defer clean()
|
||||
|
||||
tmpl, err := tests.Demo.GetTemplate("tech-blue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate error: %v", err)
|
||||
}
|
||||
|
||||
file := "/index/index.css"
|
||||
page, err := tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPageFromAsset error: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "/index", page.Get().Route)
|
||||
assert.Equal(t, "/templates/tech-blue/index", page.Get().Path)
|
||||
assert.Equal(t, "index", page.Get().Name)
|
||||
|
||||
file = "/page/404/404.js"
|
||||
page, err = tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPageFromAsset error: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "/page/404", page.Get().Route)
|
||||
assert.Equal(t, "/templates/tech-blue/page/404", page.Get().Path)
|
||||
assert.Equal(t, "404", page.Get().Name)
|
||||
}
|
||||
|
||||
func TestPageAssetScriptJS(t *testing.T) {
|
||||
tests := prepare(t)
|
||||
defer clean()
|
||||
|
||||
tmpl, err := tests.Demo.GetTemplate("tech-blue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate error: %v", err)
|
||||
}
|
||||
|
||||
file := "/page/404/404.js"
|
||||
page, err := tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPageFromAsset error: %v", err)
|
||||
}
|
||||
|
||||
asset, err := page.AssetScript()
|
||||
if err != nil {
|
||||
t.Fatalf("AssetScript error: %v", err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, asset.Content)
|
||||
assert.Equal(t, "text/javascript; charset=utf-8", asset.Type)
|
||||
}
|
||||
|
||||
func TestPageAssetScriptTS(t *testing.T) {
|
||||
tests := prepare(t)
|
||||
defer clean()
|
||||
|
||||
tmpl, err := tests.Demo.GetTemplate("tech-blue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate error: %v", err)
|
||||
}
|
||||
|
||||
file := "/page/[id]/[id].ts"
|
||||
page, err := tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPageFromAsset error: %v", err)
|
||||
}
|
||||
|
||||
asset, err := page.AssetScript()
|
||||
if err != nil {
|
||||
t.Fatalf("AssetScript error: %v", err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, asset.Content)
|
||||
assert.Equal(t, "text/javascript; charset=utf-8", asset.Type)
|
||||
}
|
||||
|
||||
func TestPageAssetStyle(t *testing.T) {
|
||||
tests := prepare(t)
|
||||
defer clean()
|
||||
|
||||
tmpl, err := tests.Demo.GetTemplate("tech-blue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate error: %v", err)
|
||||
}
|
||||
|
||||
file := "/page/[id]/[id].css"
|
||||
page, err := tmpl.GetPageFromAsset(file)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPageFromAsset error: %v", err)
|
||||
}
|
||||
|
||||
asset, err := page.AssetStyle()
|
||||
if err != nil {
|
||||
t.Fatalf("AssetStyle error: %v", err)
|
||||
}
|
||||
|
||||
assert.NotEmpty(t, asset.Content)
|
||||
assert.Equal(t, "text/css; charset=utf-8", asset.Type)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/yaoapp/yao/sui/core"
|
||||
|
|
@ -42,3 +43,49 @@ func (tmpl *Template) Locales() []core.SelectOption {
|
|||
func (tmpl *Template) Themes() []core.SelectOption {
|
||||
return tmpl.Template.Themes
|
||||
}
|
||||
|
||||
// Asset get the asset
|
||||
func (tmpl *Template) Asset(file string) (*core.Asset, error) {
|
||||
|
||||
file = filepath.Join(tmpl.Root, "__assets", file)
|
||||
if exist, _ := tmpl.local.fs.Exists(file); exist {
|
||||
|
||||
content, err := tmpl.local.fs.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
typ := "text/plain"
|
||||
switch filepath.Ext(file) {
|
||||
case ".css":
|
||||
typ = "text/css; charset=utf-8"
|
||||
break
|
||||
|
||||
case ".js":
|
||||
typ = "application/javascript; charset=utf-8"
|
||||
break
|
||||
|
||||
case ".ts":
|
||||
typ = "application/javascript; charset=utf-8"
|
||||
break
|
||||
|
||||
case ".json":
|
||||
typ = "application/json; charset=utf-8"
|
||||
break
|
||||
|
||||
case ".html":
|
||||
typ = "text/html; charset=utf-8"
|
||||
break
|
||||
|
||||
default:
|
||||
typ, err = tmpl.local.fs.MimeType(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &core.Asset{Type: typ, Content: content}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Asset %s not found", file)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,3 +49,22 @@ func TestTemplateLocales(t *testing.T) {
|
|||
assert.Equal(t, "zh-TW", locales[2].Label)
|
||||
assert.Equal(t, "zh-tw", locales[2].Value)
|
||||
}
|
||||
|
||||
func TestTemplateAsset(t *testing.T) {
|
||||
tests := prepare(t)
|
||||
defer clean()
|
||||
|
||||
tmpl, err := tests.Demo.GetTemplate("tech-blue")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTemplate error: %v", err)
|
||||
}
|
||||
|
||||
asset, err := tmpl.Asset("/css/tailwind.css")
|
||||
if err != nil {
|
||||
t.Fatalf("Asset error: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "text/css; charset=utf-8", asset.Type)
|
||||
assert.NotEmpty(t, asset.Content)
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue