Compare commits
No commits in common. "491c10e8c57460f2a81bd101cfa8862d2355a661" and "367de31247ccb5efa3b7659edac12b93ae7ec15e" have entirely different histories.
491c10e8c5
...
367de31247
|
|
@ -4,8 +4,6 @@ package constants
|
||||||
const (
|
const (
|
||||||
// DefaultScore define the default score for redissearch suggestion
|
// DefaultScore define the default score for redissearch suggestion
|
||||||
DefaultScore = 1.0
|
DefaultScore = 1.0
|
||||||
// ComponentConfigKey define component config token used at token6
|
|
||||||
ComponentConfigKey = "component"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Package database define database operation functions
|
|
||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"modelRT/orm"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QueryComponentColumnNames returns all column names from the component table.
|
|
||||||
func QueryComponentColumnNames(ctx context.Context, db *gorm.DB) ([]string, error) {
|
|
||||||
columnTypes, err := db.WithContext(ctx).Migrator().ColumnTypes((&orm.Component{}).TableName())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
columnNames := make([]string, 0, len(columnTypes))
|
|
||||||
for _, columnType := range columnTypes {
|
|
||||||
columnName := columnType.Name()
|
|
||||||
if columnName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
columnNames = append(columnNames, columnName)
|
|
||||||
}
|
|
||||||
return columnNames, nil
|
|
||||||
}
|
|
||||||
12
main.go
12
main.go
|
|
@ -235,18 +235,6 @@ func main() {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
componentColumnNames, err := database.QueryComponentColumnNames(ctx, tx)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(ctx, "query component table column names failed", "error", err)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = model.StoreComponentColumnRecommend(ctx, fullParentPath, isLocalParentPath, componentColumnNames)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(ctx, "store component column recommend content failed", "error", err)
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allMeasurement, err := database.GetAllMeasurements(ctx, tx)
|
allMeasurement, err := database.GetAllMeasurements(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(ctx, "load topologic info from postgres failed", "error", err)
|
logger.Error(ctx, "load topologic info from postgres failed", "error", err)
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
// Package model define model struct of model runtime service
|
|
||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"modelRT/constants"
|
|
||||||
"modelRT/diagram"
|
|
||||||
|
|
||||||
"github.com/RediSearch/redisearch-go/v2/redisearch"
|
|
||||||
)
|
|
||||||
|
|
||||||
// StoreComponentColumnRecommend binds token6 component config to component table column names.
|
|
||||||
func StoreComponentColumnRecommend(ctx context.Context, compTagToFullPath map[string]string, isLocalCompTagToFullPath map[string]string, componentColumnNames []string) error {
|
|
||||||
rdb := diagram.GetRedisClientInstance()
|
|
||||||
pipe := rdb.Pipeline()
|
|
||||||
|
|
||||||
pipe.SAdd(ctx, constants.RedisAllConfigSetKey, constants.ComponentConfigKey)
|
|
||||||
|
|
||||||
if len(componentColumnNames) == 0 {
|
|
||||||
_, err := pipe.Exec(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
componentColumnMembers := stringSliceToAny(componentColumnNames)
|
|
||||||
pipe.SAdd(ctx, constants.RedisAllMeasTagSetKey, componentColumnMembers...)
|
|
||||||
|
|
||||||
sug := make([]redisearch.Suggestion, 0, len(compTagToFullPath)*len(componentColumnNames)*4)
|
|
||||||
for compTag, fullPath := range compTagToFullPath {
|
|
||||||
specCompMeasKey := fmt.Sprintf(constants.RedisSpecCompTagMeasSetKey, compTag)
|
|
||||||
pipe.SAdd(ctx, specCompMeasKey, componentColumnMembers...)
|
|
||||||
|
|
||||||
isLocalFullPath := isLocalCompTagToFullPath[compTag]
|
|
||||||
fullConfigTerm := fmt.Sprintf("%s.%s", fullPath, constants.ComponentConfigKey)
|
|
||||||
sug = append(sug, redisearch.Suggestion{
|
|
||||||
Term: fullConfigTerm,
|
|
||||||
Score: constants.DefaultScore,
|
|
||||||
})
|
|
||||||
|
|
||||||
if isLocalFullPath != "" {
|
|
||||||
localConfigTerm := fmt.Sprintf("%s.%s", isLocalFullPath, constants.ComponentConfigKey)
|
|
||||||
sug = append(sug, redisearch.Suggestion{
|
|
||||||
Term: localConfigTerm,
|
|
||||||
Score: constants.DefaultScore,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, columnName := range componentColumnNames {
|
|
||||||
fullColumnTerm := fmt.Sprintf("%s.%s.%s", fullPath, constants.ComponentConfigKey, columnName)
|
|
||||||
sug = append(sug, redisearch.Suggestion{
|
|
||||||
Term: fullColumnTerm,
|
|
||||||
Score: constants.DefaultScore,
|
|
||||||
})
|
|
||||||
|
|
||||||
if isLocalFullPath == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
localColumnTerm := fmt.Sprintf("%s.%s.%s", isLocalFullPath, constants.ComponentConfigKey, columnName)
|
|
||||||
sug = append(sug, redisearch.Suggestion{
|
|
||||||
Term: localColumnTerm,
|
|
||||||
Score: constants.DefaultScore,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(sug) > 0 {
|
|
||||||
ac.AddTerms(sug...)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := pipe.Exec(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringSliceToAny(values []string) []any {
|
|
||||||
members := make([]any, 0, len(values))
|
|
||||||
for _, value := range values {
|
|
||||||
members = append(members, value)
|
|
||||||
}
|
|
||||||
return members
|
|
||||||
}
|
|
||||||
|
|
@ -24,7 +24,6 @@ type SearchResult struct {
|
||||||
RecommendType constants.RecommendHierarchyType
|
RecommendType constants.RecommendHierarchyType
|
||||||
QueryDatas []string
|
QueryDatas []string
|
||||||
IsFuzzy bool
|
IsFuzzy bool
|
||||||
IsFallback bool
|
|
||||||
Offset int
|
Offset int
|
||||||
Err error
|
Err error
|
||||||
}
|
}
|
||||||
|
|
@ -97,25 +96,11 @@ func levelOneRedisSearch(ctx context.Context, rdb *redis.Client, hierarchy const
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
|
|
||||||
fanInChan <- SearchResult{
|
|
||||||
RecommendType: hierarchy,
|
|
||||||
QueryDatas: nil,
|
|
||||||
IsFuzzy: true,
|
|
||||||
IsFallback: true,
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fanInChan <- SearchResult{
|
fanInChan <- SearchResult{
|
||||||
RecommendType: hierarchy,
|
RecommendType: hierarchy,
|
||||||
QueryDatas: recommends,
|
QueryDatas: []string{},
|
||||||
IsFuzzy: true,
|
IsFuzzy: true,
|
||||||
IsFallback: true,
|
Offset: offset,
|
||||||
Offset: 0,
|
|
||||||
Err: nil,
|
Err: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -142,7 +127,21 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RecommendType == constants.CompNSPathRecommendHierarchyType {
|
if result.RecommendType == constants.CompNSPathRecommendHierarchyType {
|
||||||
result.QueryDatas = filterLocalNSPathResults(result.QueryDatas)
|
filterResults := make([]string, 0, len(result.QueryDatas))
|
||||||
|
// TODO 增加 nspath 过滤
|
||||||
|
for _, queryData := range result.QueryDatas {
|
||||||
|
var nsPath string
|
||||||
|
if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 {
|
||||||
|
nsPath = queryData
|
||||||
|
} else {
|
||||||
|
nsPath = queryData[lastDotIndex+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal {
|
||||||
|
filterResults = append(filterResults, queryData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.QueryDatas = filterResults
|
||||||
}
|
}
|
||||||
results[result.RecommendType.String()] = result
|
results[result.RecommendType.String()] = result
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +171,26 @@ func RedisSearchRecommend(ctx context.Context, input string) map[string]SearchRe
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RecommendType == constants.CompNSPathRecommendHierarchyType {
|
if result.RecommendType == constants.CompNSPathRecommendHierarchyType {
|
||||||
result.QueryDatas = filterLocalNSPathResults(result.QueryDatas)
|
filterResults := make([]string, 0, len(result.QueryDatas))
|
||||||
|
// TODO 增加 nspath 过滤
|
||||||
|
for _, queryData := range result.QueryDatas {
|
||||||
|
if queryData == "." {
|
||||||
|
filterResults = append(filterResults, queryData)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var nsPath string
|
||||||
|
if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 {
|
||||||
|
nsPath = queryData
|
||||||
|
} else {
|
||||||
|
nsPath = queryData[lastDotIndex+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal {
|
||||||
|
filterResults = append(filterResults, queryData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.QueryDatas = filterResults
|
||||||
}
|
}
|
||||||
results[result.RecommendType.String()] = result
|
results[result.RecommendType.String()] = result
|
||||||
}
|
}
|
||||||
|
|
@ -284,11 +302,6 @@ func normalizeRecommendResults(input string, results map[string]SearchResult) ma
|
||||||
if result.Err != nil {
|
if result.Err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if result.IsFallback {
|
|
||||||
result.Offset = maxRecommendLength(result.QueryDatas)
|
|
||||||
results[key] = result
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !result.IsFuzzy {
|
if !result.IsFuzzy {
|
||||||
result.Offset = calcSearchResultOffset(input, result.QueryDatas)
|
result.Offset = calcSearchResultOffset(input, result.QueryDatas)
|
||||||
}
|
}
|
||||||
|
|
@ -309,27 +322,16 @@ func calcSearchResultOffset(input string, recommends []string) int {
|
||||||
return minOffset
|
return minOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
func maxRecommendLength(recommends []string) int {
|
|
||||||
var maxLen int
|
|
||||||
for _, recommend := range recommends {
|
|
||||||
if length := len([]rune(recommend)); length > maxLen {
|
|
||||||
maxLen = length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return maxLen
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimRecommendTerms(recommends []string, offset int) []string {
|
func trimRecommendTerms(recommends []string, offset int) []string {
|
||||||
resultRecommends := make([]string, 0, len(recommends))
|
resultRecommends := make([]string, 0, len(recommends))
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
|
|
||||||
for _, recommend := range recommends {
|
for _, recommend := range recommends {
|
||||||
recommendRunes := []rune(recommend)
|
if offset > len(recommend) {
|
||||||
if offset > len(recommendRunes) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
recommendTerm := string(recommendRunes[offset:])
|
recommendTerm := recommend[offset:]
|
||||||
if recommendTerm == "" {
|
if recommendTerm == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -343,68 +345,11 @@ func trimRecommendTerms(recommends []string, offset int) []string {
|
||||||
return resultRecommends
|
return resultRecommends
|
||||||
}
|
}
|
||||||
|
|
||||||
func filterLocalNSPathResults(queryDatas []string) []string {
|
|
||||||
filterResults := make([]string, 0, len(queryDatas))
|
|
||||||
for _, queryData := range queryDatas {
|
|
||||||
if queryData == "." {
|
|
||||||
filterResults = append(filterResults, queryData)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var nsPath string
|
|
||||||
if lastDotIndex := strings.LastIndex(queryData, "."); lastDotIndex == -1 {
|
|
||||||
nsPath = queryData
|
|
||||||
} else {
|
|
||||||
nsPath = queryData[lastDotIndex+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO 考虑为 NSPathToIsLocalMap 增加读写锁或改为并发安全快照, 保证过滤数据一致性
|
|
||||||
if isLocal, ok := NSPathToIsLocalMap[nsPath]; ok && isLocal {
|
|
||||||
filterResults = append(filterResults, queryData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filterResults
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryMemberFromSpecificsLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, keyPrefix string) ([]string, error) {
|
func queryMemberFromSpecificsLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType, keyPrefix string) ([]string, error) {
|
||||||
queryKey := getSpecificKeyByLength(hierarchy, keyPrefix)
|
queryKey := getSpecificKeyByLength(hierarchy, keyPrefix)
|
||||||
return rdb.SMembers(ctx, queryKey).Result()
|
return rdb.SMembers(ctx, queryKey).Result()
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackAllCurrentLevel(ctx context.Context, rdb *redis.Client, hierarchy constants.RecommendHierarchyType) ([]string, error) {
|
|
||||||
setKey, ok := getAllSetKeyByHierarchy(hierarchy)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unsupported recommend hierarchy for fallback: %s", hierarchy)
|
|
||||||
}
|
|
||||||
|
|
||||||
members, err := rdb.SMembers(ctx, setKey).Result()
|
|
||||||
if err != nil && err != redis.Nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return members, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllSetKeyByHierarchy(hierarchy constants.RecommendHierarchyType) (string, bool) {
|
|
||||||
switch hierarchy {
|
|
||||||
case constants.GridRecommendHierarchyType:
|
|
||||||
return constants.RedisAllGridSetKey, true
|
|
||||||
case constants.ZoneRecommendHierarchyType:
|
|
||||||
return constants.RedisAllZoneSetKey, true
|
|
||||||
case constants.StationRecommendHierarchyType:
|
|
||||||
return constants.RedisAllStationSetKey, true
|
|
||||||
case constants.CompNSPathRecommendHierarchyType:
|
|
||||||
return constants.RedisAllCompNSPathSetKey, true
|
|
||||||
case constants.CompTagRecommendHierarchyType:
|
|
||||||
return constants.RedisAllCompTagSetKey, true
|
|
||||||
case constants.ConfigRecommendHierarchyType:
|
|
||||||
return constants.RedisAllConfigSetKey, true
|
|
||||||
case constants.MeasTagRecommendHierarchyType:
|
|
||||||
return constants.RedisAllMeasTagSetKey, true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) {
|
func getAllKeyByGridLevel(ctx context.Context, rdb *redis.Client, fanInChan chan SearchResult) {
|
||||||
setKey := constants.RedisAllGridSetKey
|
setKey := constants.RedisAllGridSetKey
|
||||||
hierarchy := constants.GridRecommendHierarchyType
|
hierarchy := constants.GridRecommendHierarchyType
|
||||||
|
|
@ -648,30 +593,14 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isFallback := false
|
|
||||||
if len(recommends) == 0 {
|
if len(recommends) == 0 {
|
||||||
logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
|
logger.Info(ctx, "fuzzy search without result", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
|
||||||
recommends, err = fallbackAllCurrentLevel(ctx, rdb, hierarchy)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error(ctx, "fallback all keys at current level failed", "hierarchy", hierarchy, "search_input", searchInput, "error", err)
|
|
||||||
fanInChan <- SearchResult{
|
|
||||||
RecommendType: hierarchy,
|
|
||||||
QueryDatas: nil,
|
|
||||||
IsFuzzy: true,
|
|
||||||
IsFallback: true,
|
|
||||||
Err: err,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isFallback = true
|
|
||||||
offset = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fanInChan <- SearchResult{
|
fanInChan <- SearchResult{
|
||||||
RecommendType: hierarchy,
|
RecommendType: hierarchy,
|
||||||
QueryDatas: recommends,
|
QueryDatas: recommends,
|
||||||
IsFuzzy: true,
|
IsFuzzy: true,
|
||||||
IsFallback: isFallback,
|
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
Err: nil,
|
Err: nil,
|
||||||
}
|
}
|
||||||
|
|
@ -681,7 +610,7 @@ func handleLevelFuzzySearch(ctx context.Context, rdb *redis.Client, hierarchy co
|
||||||
func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) {
|
func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string, searchPrefix string, hierarchy constants.RecommendHierarchyType, recommendLenType string) ([]string, int, error) {
|
||||||
var configToken string
|
var configToken string
|
||||||
var comparePrefix string
|
var comparePrefix string
|
||||||
searchInputLen := len([]rune(searchInput))
|
searchInputLen := len(searchInput)
|
||||||
compareHierarchyLen := int(hierarchy)
|
compareHierarchyLen := int(hierarchy)
|
||||||
comparePrefix = searchPrefix
|
comparePrefix = searchPrefix
|
||||||
if hierarchy == constants.MeasTagRecommendHierarchyType {
|
if hierarchy == constants.MeasTagRecommendHierarchyType {
|
||||||
|
|
@ -712,10 +641,10 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) == 0 {
|
if len(results) == 0 {
|
||||||
// 如果没有结果,退一步(删除最后一个字符)并继续循环
|
// 如果没有结果,退一步(删除最后一个字节)并继续循环
|
||||||
// TODO 考虑使用其他方式代替 for 循环退一字符的查询方式
|
// TODO 考虑使用其他方式代替 for 循环退一字节的查询方式
|
||||||
searchInput = trimLastRune(searchInput)
|
searchInput = searchInput[:len(searchInput)-1]
|
||||||
searchInputLen = len([]rune(searchInput))
|
searchInputLen = len(searchInput)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -768,20 +697,12 @@ func runFuzzySearch(ctx context.Context, rdb *redis.Client, searchInput string,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(recommends) == 0 {
|
if len(recommends) == 0 {
|
||||||
searchInput = trimLastRune(searchInput)
|
searchInput = searchInput[:len(searchInput)-1]
|
||||||
searchInputLen = len([]rune(searchInput))
|
searchInputLen = len(searchInput)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return recommends, len([]rune(searchInput)), nil
|
return recommends, len(searchInput), nil
|
||||||
}
|
}
|
||||||
return []string{}, 0, nil
|
return []string{}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func trimLastRune(s string) string {
|
|
||||||
runes := []rune(s)
|
|
||||||
if len(runes) == 0 {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return string(runes[:len(runes)-1])
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,10 @@ func GetLongestCommonPrefixLength(query string, result string) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
queryRunes := []rune(query)
|
minLen := min(len(query), len(result))
|
||||||
resultRunes := []rune(result)
|
|
||||||
minLen := min(len(queryRunes), len(resultRunes))
|
|
||||||
|
|
||||||
for i := range minLen {
|
for i := range minLen {
|
||||||
if queryRunes[i] != resultRunes[i] {
|
if query[i] != result[i] {
|
||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue