2019-12-08 14:15:35 -05:00
|
|
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
|
|
|
// Use of this source code is governed by a MIT-style
|
|
|
|
// license that can be found in the LICENSE file.
|
|
|
|
|
|
|
|
package code
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
2019-12-15 04:51:28 -05:00
|
|
|
"os"
|
2019-12-08 14:15:35 -05:00
|
|
|
"strconv"
|
|
|
|
"strings"
|
2020-02-20 14:53:55 -05:00
|
|
|
"time"
|
2019-12-08 14:15:35 -05:00
|
|
|
|
|
|
|
"code.gitea.io/gitea/models"
|
2020-02-20 14:53:55 -05:00
|
|
|
"code.gitea.io/gitea/modules/analyze"
|
2019-12-08 14:15:35 -05:00
|
|
|
"code.gitea.io/gitea/modules/base"
|
|
|
|
"code.gitea.io/gitea/modules/charset"
|
|
|
|
"code.gitea.io/gitea/modules/git"
|
|
|
|
"code.gitea.io/gitea/modules/log"
|
|
|
|
"code.gitea.io/gitea/modules/setting"
|
2020-02-20 14:53:55 -05:00
|
|
|
"code.gitea.io/gitea/modules/timeutil"
|
2020-08-11 16:05:34 -04:00
|
|
|
"code.gitea.io/gitea/modules/util"
|
2019-12-23 07:31:16 -05:00
|
|
|
|
|
|
|
"github.com/blevesearch/bleve"
|
2020-02-20 14:53:55 -05:00
|
|
|
analyzer_custom "github.com/blevesearch/bleve/analysis/analyzer/custom"
|
|
|
|
analyzer_keyword "github.com/blevesearch/bleve/analysis/analyzer/keyword"
|
2019-12-23 07:31:16 -05:00
|
|
|
"github.com/blevesearch/bleve/analysis/token/lowercase"
|
|
|
|
"github.com/blevesearch/bleve/analysis/token/unicodenorm"
|
|
|
|
"github.com/blevesearch/bleve/analysis/tokenizer/unicode"
|
|
|
|
"github.com/blevesearch/bleve/index/upsidedown"
|
|
|
|
"github.com/blevesearch/bleve/mapping"
|
|
|
|
"github.com/blevesearch/bleve/search/query"
|
2019-12-08 14:15:35 -05:00
|
|
|
"github.com/ethantkoenig/rupture"
|
2020-04-15 13:40:39 -04:00
|
|
|
"github.com/go-enry/go-enry/v2"
|
2019-12-08 14:15:35 -05:00
|
|
|
)
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
const unicodeNormalizeName = "unicodeNormalize"
|
|
|
|
const maxBatchSize = 16
|
2019-12-08 14:15:35 -05:00
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// numericEqualityQuery a numeric equality query for the given value and field
|
|
|
|
func numericEqualityQuery(value int64, field string) *query.NumericRangeQuery {
|
|
|
|
f := float64(value)
|
|
|
|
tru := true
|
|
|
|
q := bleve.NewNumericRangeInclusiveQuery(&f, &f, &tru, &tru)
|
|
|
|
q.SetField(field)
|
|
|
|
return q
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
func addUnicodeNormalizeTokenFilter(m *mapping.IndexMappingImpl) error {
|
|
|
|
return m.AddCustomTokenFilter(unicodeNormalizeName, map[string]interface{}{
|
|
|
|
"type": unicodenorm.Name,
|
|
|
|
"form": unicodenorm.NFC,
|
|
|
|
})
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2020-08-30 12:08:01 -04:00
|
|
|
// openBleveIndexer open the index at the specified path, checking for metadata
|
2019-12-23 07:31:16 -05:00
|
|
|
// updates and bleve version updates. If index needs to be created (or
|
|
|
|
// re-created), returns (nil, nil)
|
2020-08-30 12:08:01 -04:00
|
|
|
func openBleveIndexer(path string, latestVersion int) (bleve.Index, error) {
|
2019-12-23 07:31:16 -05:00
|
|
|
_, err := os.Stat(path)
|
|
|
|
if err != nil && os.IsNotExist(err) {
|
|
|
|
return nil, nil
|
|
|
|
} else if err != nil {
|
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
metadata, err := rupture.ReadIndexMetadata(path)
|
2019-12-08 14:15:35 -05:00
|
|
|
if err != nil {
|
2019-12-23 07:31:16 -05:00
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
if metadata.Version < latestVersion {
|
|
|
|
// the indexer is using a previous version, so we should delete it and
|
|
|
|
// re-populate
|
2020-08-11 16:05:34 -04:00
|
|
|
return nil, util.RemoveAll(path)
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
index, err := bleve.Open(path)
|
|
|
|
if err != nil && err == upsidedown.IncompatibleVersion {
|
|
|
|
// the indexer was built with a previous version of bleve, so we should
|
|
|
|
// delete it and re-populate
|
2020-08-11 16:05:34 -04:00
|
|
|
return nil, util.RemoveAll(path)
|
2019-12-23 07:31:16 -05:00
|
|
|
} else if err != nil {
|
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
return index, nil
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// RepoIndexerData data stored in the repo indexer
|
|
|
|
type RepoIndexerData struct {
|
2020-02-20 14:53:55 -05:00
|
|
|
RepoID int64
|
|
|
|
CommitID string
|
|
|
|
Content string
|
|
|
|
Language string
|
|
|
|
UpdatedAt time.Time
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// Type returns the document type, for bleve's mapping.Classifier interface.
|
|
|
|
func (d *RepoIndexerData) Type() string {
|
|
|
|
return repoIndexerDocType
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
const (
|
|
|
|
repoIndexerAnalyzer = "repoIndexerAnalyzer"
|
|
|
|
repoIndexerDocType = "repoIndexerDocType"
|
2020-02-20 14:53:55 -05:00
|
|
|
repoIndexerLatestVersion = 5
|
2019-12-23 07:31:16 -05:00
|
|
|
)
|
|
|
|
|
2020-08-30 12:08:01 -04:00
|
|
|
// createBleveIndexer create a bleve repo indexer if one does not already exist
|
|
|
|
func createBleveIndexer(path string, latestVersion int) (bleve.Index, error) {
|
2019-12-23 07:31:16 -05:00
|
|
|
docMapping := bleve.NewDocumentMapping()
|
|
|
|
numericFieldMapping := bleve.NewNumericFieldMapping()
|
|
|
|
numericFieldMapping.IncludeInAll = false
|
|
|
|
docMapping.AddFieldMappingsAt("RepoID", numericFieldMapping)
|
|
|
|
|
|
|
|
textFieldMapping := bleve.NewTextFieldMapping()
|
|
|
|
textFieldMapping.IncludeInAll = false
|
|
|
|
docMapping.AddFieldMappingsAt("Content", textFieldMapping)
|
|
|
|
|
2020-02-20 14:53:55 -05:00
|
|
|
termFieldMapping := bleve.NewTextFieldMapping()
|
|
|
|
termFieldMapping.IncludeInAll = false
|
|
|
|
termFieldMapping.Analyzer = analyzer_keyword.Name
|
|
|
|
docMapping.AddFieldMappingsAt("Language", termFieldMapping)
|
|
|
|
docMapping.AddFieldMappingsAt("CommitID", termFieldMapping)
|
|
|
|
|
|
|
|
timeFieldMapping := bleve.NewDateTimeFieldMapping()
|
|
|
|
timeFieldMapping.IncludeInAll = false
|
|
|
|
docMapping.AddFieldMappingsAt("UpdatedAt", timeFieldMapping)
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
mapping := bleve.NewIndexMapping()
|
|
|
|
if err := addUnicodeNormalizeTokenFilter(mapping); err != nil {
|
|
|
|
return nil, err
|
|
|
|
} else if err := mapping.AddCustomAnalyzer(repoIndexerAnalyzer, map[string]interface{}{
|
2020-02-20 14:53:55 -05:00
|
|
|
"type": analyzer_custom.Name,
|
2019-12-23 07:31:16 -05:00
|
|
|
"char_filters": []string{},
|
|
|
|
"tokenizer": unicode.Name,
|
|
|
|
"token_filters": []string{unicodeNormalizeName, lowercase.Name},
|
|
|
|
}); err != nil {
|
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
mapping.DefaultAnalyzer = repoIndexerAnalyzer
|
|
|
|
mapping.AddDocumentMapping(repoIndexerDocType, docMapping)
|
|
|
|
mapping.AddDocumentMapping("_all", bleve.NewDocumentDisabledMapping())
|
|
|
|
|
|
|
|
indexer, err := bleve.New(path, mapping)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
|
|
|
|
if err = rupture.WriteIndexMetadata(path, &rupture.IndexMetadata{
|
|
|
|
Version: latestVersion,
|
|
|
|
}); err != nil {
|
|
|
|
return nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
return indexer, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
_ Indexer = &BleveIndexer{}
|
|
|
|
)
|
|
|
|
|
|
|
|
// BleveIndexer represents a bleve indexer implementation
|
|
|
|
type BleveIndexer struct {
|
|
|
|
indexDir string
|
|
|
|
indexer bleve.Index
|
|
|
|
}
|
|
|
|
|
|
|
|
// NewBleveIndexer creates a new bleve local indexer
|
|
|
|
func NewBleveIndexer(indexDir string) (*BleveIndexer, bool, error) {
|
|
|
|
indexer := &BleveIndexer{
|
|
|
|
indexDir: indexDir,
|
|
|
|
}
|
|
|
|
created, err := indexer.init()
|
|
|
|
return indexer, created, err
|
|
|
|
}
|
|
|
|
|
2020-08-30 12:08:01 -04:00
|
|
|
func (b *BleveIndexer) addUpdate(commitSha string, update fileUpdate, repo *models.Repository, batch rupture.FlushingBatch) error {
|
|
|
|
// Ignore vendored files in code search
|
|
|
|
if setting.Indexer.ExcludeVendored && enry.IsVendor(update.Filename) {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
stdout, err := git.NewCommand("cat-file", "-s", update.BlobSha).
|
|
|
|
RunInDir(repo.RepoPath())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if size, err := strconv.Atoi(strings.TrimSpace(stdout)); err != nil {
|
|
|
|
return fmt.Errorf("Misformatted git cat-file output: %v", err)
|
|
|
|
} else if int64(size) > setting.Indexer.MaxIndexerFileSize {
|
|
|
|
return b.addDelete(update.Filename, repo, batch)
|
|
|
|
}
|
|
|
|
|
|
|
|
fileContents, err := git.NewCommand("cat-file", "blob", update.BlobSha).
|
|
|
|
RunInDirBytes(repo.RepoPath())
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
} else if !base.IsTextFile(fileContents) {
|
|
|
|
// FIXME: UTF-16 files will probably fail here
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
id := filenameIndexerID(repo.ID, update.Filename)
|
|
|
|
return batch.Index(id, &RepoIndexerData{
|
|
|
|
RepoID: repo.ID,
|
|
|
|
CommitID: commitSha,
|
|
|
|
Content: string(charset.ToUTF8DropErrors(fileContents)),
|
|
|
|
Language: analyze.GetCodeLanguage(update.Filename, fileContents),
|
|
|
|
UpdatedAt: time.Now().UTC(),
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b *BleveIndexer) addDelete(filename string, repo *models.Repository, batch rupture.FlushingBatch) error {
|
|
|
|
id := filenameIndexerID(repo.ID, filename)
|
|
|
|
return batch.Delete(id)
|
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// init init the indexer
|
|
|
|
func (b *BleveIndexer) init() (bool, error) {
|
|
|
|
var err error
|
2020-08-30 12:08:01 -04:00
|
|
|
b.indexer, err = openBleveIndexer(b.indexDir, repoIndexerLatestVersion)
|
2019-12-08 14:15:35 -05:00
|
|
|
if err != nil {
|
2019-12-23 07:31:16 -05:00
|
|
|
return false, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
if b.indexer != nil {
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
|
2020-08-30 12:08:01 -04:00
|
|
|
b.indexer, err = createBleveIndexer(b.indexDir, repoIndexerLatestVersion)
|
2019-12-23 07:31:16 -05:00
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Close close the indexer
|
|
|
|
func (b *BleveIndexer) Close() {
|
|
|
|
log.Debug("Closing repo indexer")
|
|
|
|
if b.indexer != nil {
|
|
|
|
err := b.indexer.Close()
|
|
|
|
if err != nil {
|
|
|
|
log.Error("Error whilst closing the repository indexer: %v", err)
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
log.Info("PID: %d Repository Indexer closed", os.Getpid())
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// Index indexes the data
|
2020-08-30 12:08:01 -04:00
|
|
|
func (b *BleveIndexer) Index(repo *models.Repository, sha string, changes *repoChanges) error {
|
2019-12-23 07:31:16 -05:00
|
|
|
batch := rupture.NewFlushingBatch(b.indexer, maxBatchSize)
|
|
|
|
for _, update := range changes.Updates {
|
2020-08-30 12:08:01 -04:00
|
|
|
if err := b.addUpdate(sha, update, repo, batch); err != nil {
|
2019-12-23 07:31:16 -05:00
|
|
|
return err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
for _, filename := range changes.RemovedFilenames {
|
2020-08-30 12:08:01 -04:00
|
|
|
if err := b.addDelete(filename, repo, batch); err != nil {
|
2019-12-23 07:31:16 -05:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
2020-08-30 12:08:01 -04:00
|
|
|
return batch.Flush()
|
2019-12-23 07:31:16 -05:00
|
|
|
}
|
2019-12-08 14:15:35 -05:00
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// Delete deletes indexes by ids
|
|
|
|
func (b *BleveIndexer) Delete(repoID int64) error {
|
|
|
|
query := numericEqualityQuery(repoID, "RepoID")
|
|
|
|
searchRequest := bleve.NewSearchRequestOptions(query, 2147483647, 0, false)
|
|
|
|
result, err := b.indexer.Search(searchRequest)
|
2019-12-08 14:15:35 -05:00
|
|
|
if err != nil {
|
2019-12-23 07:31:16 -05:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
batch := rupture.NewFlushingBatch(b.indexer, maxBatchSize)
|
|
|
|
for _, hit := range result.Hits {
|
|
|
|
if err = batch.Delete(hit.ID); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
return batch.Flush()
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
// Search searches for files in the specified repo.
|
|
|
|
// Returns the matching file-paths
|
2020-02-20 14:53:55 -05:00
|
|
|
func (b *BleveIndexer) Search(repoIDs []int64, language, keyword string, page, pageSize int) (int64, []*SearchResult, []*SearchResultLanguages, error) {
|
2019-12-23 07:31:16 -05:00
|
|
|
phraseQuery := bleve.NewMatchPhraseQuery(keyword)
|
|
|
|
phraseQuery.FieldVal = "Content"
|
|
|
|
phraseQuery.Analyzer = repoIndexerAnalyzer
|
|
|
|
|
|
|
|
var indexerQuery query.Query
|
|
|
|
if len(repoIDs) > 0 {
|
|
|
|
var repoQueries = make([]query.Query, 0, len(repoIDs))
|
|
|
|
for _, repoID := range repoIDs {
|
|
|
|
repoQueries = append(repoQueries, numericEqualityQuery(repoID, "RepoID"))
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-15 04:51:28 -05:00
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
indexerQuery = bleve.NewConjunctionQuery(
|
|
|
|
bleve.NewDisjunctionQuery(repoQueries...),
|
|
|
|
phraseQuery,
|
|
|
|
)
|
|
|
|
} else {
|
|
|
|
indexerQuery = phraseQuery
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
|
|
|
|
2020-02-20 14:53:55 -05:00
|
|
|
// Save for reuse without language filter
|
|
|
|
facetQuery := indexerQuery
|
|
|
|
if len(language) > 0 {
|
|
|
|
languageQuery := bleve.NewMatchQuery(language)
|
|
|
|
languageQuery.FieldVal = "Language"
|
|
|
|
languageQuery.Analyzer = analyzer_keyword.Name
|
|
|
|
|
|
|
|
indexerQuery = bleve.NewConjunctionQuery(
|
|
|
|
indexerQuery,
|
|
|
|
languageQuery,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
from := (page - 1) * pageSize
|
|
|
|
searchRequest := bleve.NewSearchRequestOptions(indexerQuery, pageSize, from, false)
|
2020-02-20 14:53:55 -05:00
|
|
|
searchRequest.Fields = []string{"Content", "RepoID", "Language", "CommitID", "UpdatedAt"}
|
2019-12-23 07:31:16 -05:00
|
|
|
searchRequest.IncludeLocations = true
|
2019-12-08 14:15:35 -05:00
|
|
|
|
2020-02-20 14:53:55 -05:00
|
|
|
if len(language) == 0 {
|
|
|
|
searchRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
|
|
|
|
}
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
result, err := b.indexer.Search(searchRequest)
|
|
|
|
if err != nil {
|
2020-02-20 14:53:55 -05:00
|
|
|
return 0, nil, nil, err
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
|
2020-02-20 14:53:55 -05:00
|
|
|
total := int64(result.Total)
|
|
|
|
|
2019-12-23 07:31:16 -05:00
|
|
|
searchResults := make([]*SearchResult, len(result.Hits))
|
|
|
|
for i, hit := range result.Hits {
|
|
|
|
var startIndex, endIndex int = -1, -1
|
|
|
|
for _, locations := range hit.Locations["Content"] {
|
|
|
|
location := locations[0]
|
|
|
|
locationStart := int(location.Start)
|
|
|
|
locationEnd := int(location.End)
|
|
|
|
if startIndex < 0 || locationStart < startIndex {
|
|
|
|
startIndex = locationStart
|
|
|
|
}
|
|
|
|
if endIndex < 0 || locationEnd > endIndex {
|
|
|
|
endIndex = locationEnd
|
|
|
|
}
|
|
|
|
}
|
2020-02-20 14:53:55 -05:00
|
|
|
language := hit.Fields["Language"].(string)
|
|
|
|
var updatedUnix timeutil.TimeStamp
|
|
|
|
if t, err := time.Parse(time.RFC3339, hit.Fields["UpdatedAt"].(string)); err == nil {
|
|
|
|
updatedUnix = timeutil.TimeStamp(t.Unix())
|
|
|
|
}
|
2019-12-23 07:31:16 -05:00
|
|
|
searchResults[i] = &SearchResult{
|
2020-02-20 14:53:55 -05:00
|
|
|
RepoID: int64(hit.Fields["RepoID"].(float64)),
|
|
|
|
StartIndex: startIndex,
|
|
|
|
EndIndex: endIndex,
|
|
|
|
Filename: filenameOfIndexerID(hit.ID),
|
|
|
|
Content: hit.Fields["Content"].(string),
|
|
|
|
CommitID: hit.Fields["CommitID"].(string),
|
|
|
|
UpdatedUnix: updatedUnix,
|
|
|
|
Language: language,
|
|
|
|
Color: enry.GetColor(language),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
searchResultLanguages := make([]*SearchResultLanguages, 0, 10)
|
|
|
|
if len(language) > 0 {
|
|
|
|
// Use separate query to go get all language counts
|
|
|
|
facetRequest := bleve.NewSearchRequestOptions(facetQuery, 1, 0, false)
|
|
|
|
facetRequest.Fields = []string{"Content", "RepoID", "Language", "CommitID", "UpdatedAt"}
|
|
|
|
facetRequest.IncludeLocations = true
|
|
|
|
facetRequest.AddFacet("languages", bleve.NewFacetRequest("Language", 10))
|
|
|
|
|
|
|
|
if result, err = b.indexer.Search(facetRequest); err != nil {
|
|
|
|
return 0, nil, nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
languagesFacet := result.Facets["languages"]
|
|
|
|
for _, term := range languagesFacet.Terms {
|
|
|
|
if len(term.Term) == 0 {
|
|
|
|
continue
|
2019-12-23 07:31:16 -05:00
|
|
|
}
|
2020-02-20 14:53:55 -05:00
|
|
|
searchResultLanguages = append(searchResultLanguages, &SearchResultLanguages{
|
|
|
|
Language: term.Term,
|
|
|
|
Color: enry.GetColor(term.Term),
|
|
|
|
Count: term.Count,
|
|
|
|
})
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|
2020-02-20 14:53:55 -05:00
|
|
|
return total, searchResults, searchResultLanguages, nil
|
2019-12-08 14:15:35 -05:00
|
|
|
}
|