commit 30611aa45d37f8d4e7de1c76f93206169ab4753a Author: snoutie Date: Mon Mar 24 08:50:01 2025 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..946313f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_* +*.sqlite diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 0000000..c83f43a --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,83 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sap-cds-search/cmd/database/table" + "time" +) + +type CDSViewFieldJSON struct { + Fields []table.CDSViewField `json:"fields"` +} + +func GetCDSViewField(CDSViewTechnicalName string) error { + var json CDSViewFieldJSON + path := "https://api.sap.com/odata/1.0/catalog.svc/CdsViewsContent.CdsViews('" + CDSViewTechnicalName + "')/$value" + fmt.Println("Getting: ", CDSViewTechnicalName) + err := RequestJSON(path, &json) + if err != nil { + return err + } + + for _, field := range json.Fields { + field.CDSViewTechnicalName = CDSViewTechnicalName + + err := table.InsertOrReplaceCDSViewField(field) + if err != nil { + fmt.Println(err) + continue + } + } + return nil +} + +func GetCDSViewFields() { + + cdsViews, _ := table.QueryAllCDSViewTechnicalNames() + + for _, cdsView := range *cdsViews { + var json CDSViewFieldJSON + path := "https://api.sap.com/odata/1.0/catalog.svc/CdsViewsContent.CdsViews('" + cdsView + "')/$value" + fmt.Println("Getting: ", cdsView) + err := RequestJSON(path, &json) + if err != nil { + fmt.Println(cdsView, err) + continue + } + for _, field := range json.Fields { + field.CDSViewTechnicalName = cdsView + + err := table.InsertOrReplaceCDSViewField(field) + if err != nil { + fmt.Println(field.CDSViewTechnicalName, field.FieldName, err) + } + } + time.Sleep(200 * time.Millisecond) + } +} + +func RequestJSON(url string, v any) error { + response, err := http.Get(url) + if err != nil { + return err + } + + responseData, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + if len(responseData) == 0 { + return errors.New("empty response") + } + + err = json.Unmarshal(responseData, v) + if err != nil { + return err + } + return nil +} diff --git a/cmd/database/database.go b/cmd/database/database.go new file mode 100644 index 0000000..1ef0f81 --- /dev/null +++ b/cmd/database/database.go @@ -0,0 +1,36 @@ +package database + +import ( + "database/sql" + _ "embed" + "fmt" + + _ "github.com/glebarez/go-sqlite" +) + +type database struct { + *sql.DB +} + +const ( + database_path = "./db.sqlite" +) + +var DB database + +//go:embed sql/create_tables.sql +var create_tables string + +func Load() { + db, err := sql.Open("sqlite", database_path+"?_pragma=busy_timeout(5000)&foreign_keys(1)") + if err != nil { + panic(err) + } + + _, err = db.Exec(create_tables) + if err != nil { + fmt.Println(err) + } + + DB.DB = db +} diff --git a/cmd/database/sql/create_tables.sql b/cmd/database/sql/create_tables.sql new file mode 100644 index 0000000..fadadf4 --- /dev/null +++ b/cmd/database/sql/create_tables.sql @@ -0,0 +1,17 @@ +CREATE TABLE CDSView ( + TechnicalName TEXT PRIMARY KEY, + DisplayName TEXT, + Description TEXT, + Version TEXT, + State TEXT, + CreatedAt INTEGER, + ModifiedAt INTEGER +) +CREATE TABLE CDSViewField ( + CDSViewTechnicalName TEXT PRIMARY KEY, + FieldName TEXT PRIMARY KEY, + Description TEXT, + DataType TEXT, + FieldLength TEXT, + FOREIGN KEY (CDSViewTechnicalName) REFERENCES CDSView (TechnicalName) +) diff --git a/cmd/database/table/cdsView.go b/cmd/database/table/cdsView.go new file mode 100644 index 0000000..292ae42 --- /dev/null +++ b/cmd/database/table/cdsView.go @@ -0,0 +1,53 @@ +package table + +import ( + _ "embed" + "sap-cds-search/cmd/database" +) + +type CDSView struct { + TechnicalName string `json:"Name"` + DisplayName string `json:"DisplayName"` + Description string `json:"Description"` + Version string `json:"Version"` + State string `json:"State"` + CreatedAt int `json:"CreatedAt"` + ModifiedAt int `json:"ModifiedAt"` +} + +//go:embed sql/query_cds_view.sql +var query_cds_view string + +func GetCDSView(TechnicalName string) (*CDSView, error) { + row := database.DB.QueryRow(query_cds_view, TechnicalName) + + var CDSView CDSView + err := row.Scan(&CDSView.TechnicalName, &CDSView.DisplayName, &CDSView.Description, &CDSView.Version, &CDSView.State, &CDSView.CreatedAt, &CDSView.ModifiedAt) + if err != nil { + return nil, err + } + + return &CDSView, nil +} + +//go:embed sql/query_all_cds_view_technical_names.sql +var query_all_cds_view_technical_names string + +func QueryAllCDSViewTechnicalNames() (*[]string, error) { + rows, err := database.DB.Query(query_all_cds_view_technical_names) + if err != nil { + return nil, err + } + + var technicalNames []string + for rows.Next() { + var technicalName string + err := rows.Scan(&technicalName) + if err != nil { + return nil, err + } + technicalNames = append(technicalNames, technicalName) + } + + return &technicalNames, nil +} diff --git a/cmd/database/table/cdsViewField.go b/cmd/database/table/cdsViewField.go new file mode 100644 index 0000000..0c7411a --- /dev/null +++ b/cmd/database/table/cdsViewField.go @@ -0,0 +1,45 @@ +package table + +import ( + _ "embed" + "sap-cds-search/cmd/database" +) + +type CDSViewField struct { + CDSViewTechnicalName string + FieldName string `json:"fieldname"` + Description string `json:"description"` + DataType string `json:"datatype"` + FieldLength string `json:"fieldlength"` +} + +//go:embed sql/query_cds_view_fields.sql +var query_cds_view_fields string + +func GetCDSViewFields(CDSViewTechnicalName string) (*[]CDSViewField, error) { + rows, err := database.DB.Query(query_cds_view_fields, CDSViewTechnicalName) + if err != nil { + return nil, err + } + var fields []CDSViewField = make([]CDSViewField, 0) + for rows.Next() { + var field CDSViewField + err := rows.Scan(&field.CDSViewTechnicalName, &field.FieldName, &field.Description, &field.DataType, &field.FieldLength) + if err != nil { + return nil, err + } + fields = append(fields, field) + } + return &fields, nil +} + +//go:embed sql/insert_or_replace_cds_view_field.sql +var insert_or_replace_cds_view_field string + +func InsertOrReplaceCDSViewField(field CDSViewField) error { + _, err := database.DB.Exec(insert_or_replace_cds_view_field, field.CDSViewTechnicalName, field.FieldName, field.Description, field.DataType, field.FieldLength) + if err != nil { + return err + } + return nil +} diff --git a/cmd/database/table/keyword.go b/cmd/database/table/keyword.go new file mode 100644 index 0000000..0347555 --- /dev/null +++ b/cmd/database/table/keyword.go @@ -0,0 +1,42 @@ +package table + +import ( + _ "embed" + "sap-cds-search/cmd/database" +) + +type Keyword struct { + CDSViewTechnicalName string + Keyword string +} + +//go:embed sql/query_all_keywords.sql +var query_all_keywords string + +func GetAllKeywords() (*[]Keyword, error) { + rows, err := database.DB.Query(query_all_keywords) + if err != nil { + return nil, err + } + var keywords []Keyword + for rows.Next() { + var keyword Keyword + err := rows.Scan(&keyword.CDSViewTechnicalName, &keyword.Keyword) + if err != nil { + return nil, err + } + keywords = append(keywords, keyword) + } + return &keywords, nil +} + +//go:embed sql/insert_or_replace_keyword.sql +var insert_or_replace_keyword string + +func InsertOrReplaceKeyword(CDSViewTechnicalName string, Keywords string) error { + _, err := database.DB.Exec(insert_or_replace_keyword, CDSViewTechnicalName, Keywords) + if err != nil { + return err + } + return nil +} diff --git a/cmd/database/table/sql/insert_or_replace_cds_view_field.sql b/cmd/database/table/sql/insert_or_replace_cds_view_field.sql new file mode 100644 index 0000000..4e23b84 --- /dev/null +++ b/cmd/database/table/sql/insert_or_replace_cds_view_field.sql @@ -0,0 +1,4 @@ +INSERT +OR REPLACE INTO CDSViewField +VALUES + (?, ?, ?, ?, ?) diff --git a/cmd/database/table/sql/insert_or_replace_keyword.sql b/cmd/database/table/sql/insert_or_replace_keyword.sql new file mode 100644 index 0000000..8e886d7 --- /dev/null +++ b/cmd/database/table/sql/insert_or_replace_keyword.sql @@ -0,0 +1,4 @@ +INSERT +OR REPLACE INTO Keyword +VALUES + (?, ?) diff --git a/cmd/database/table/sql/query_all_cds_view_technical_names.sql b/cmd/database/table/sql/query_all_cds_view_technical_names.sql new file mode 100644 index 0000000..71ac6fd --- /dev/null +++ b/cmd/database/table/sql/query_all_cds_view_technical_names.sql @@ -0,0 +1,4 @@ +SELECT + TechnicalName +FROM + CDSView diff --git a/cmd/database/table/sql/query_all_keywords.sql b/cmd/database/table/sql/query_all_keywords.sql new file mode 100644 index 0000000..e0535f3 --- /dev/null +++ b/cmd/database/table/sql/query_all_keywords.sql @@ -0,0 +1,29 @@ +select + CDSViewTechnicalName as CDSViewTechnicalName, + lower(FieldName) as keyword +from + CDSViewField +UNION +select + CDSViewTechnicalName as CDSViewTechnicalName, + lower(Description) as keyword +from + CDSViewField +UNION +select + TechnicalName as CDSViewTechnicalName, + lower(TechnicalName) as keyword +from + CDSView +UNION +select + TechnicalName as CDSViewTechnicalName, + lower(Description) as keyword +from + CDSView +UNION +select + TechnicalName as CDSViewTechnicalName, + lower(DisplayName) as keyword +from + CDSView diff --git a/cmd/database/table/sql/query_cds_view.sql b/cmd/database/table/sql/query_cds_view.sql new file mode 100644 index 0000000..63f0bf7 --- /dev/null +++ b/cmd/database/table/sql/query_cds_view.sql @@ -0,0 +1,2 @@ +SELECT * FROM CDSView + WHERE TechnicalName = ? diff --git a/cmd/database/table/sql/query_cds_view_fields.sql b/cmd/database/table/sql/query_cds_view_fields.sql new file mode 100644 index 0000000..8ab613e --- /dev/null +++ b/cmd/database/table/sql/query_cds_view_fields.sql @@ -0,0 +1,2 @@ +SELECT * FROM CDSViewField + WHERE CDSViewTechnicalName = ? diff --git a/cmd/model/cdsView.go b/cmd/model/cdsView.go new file mode 100644 index 0000000..e8da444 --- /dev/null +++ b/cmd/model/cdsView.go @@ -0,0 +1,80 @@ +package model + +import ( + "fmt" + "sap-cds-search/cmd/database/table" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type CDSViewFieldModel struct { + table.CDSViewField + DataTypeTitle string + FieldLengthOut string + DescriptionOut string +} + +type CDSViewModel struct { + *table.CDSView + StateTitle string + Fields *[]CDSViewFieldModel +} + +var englishCases = cases.Title(language.English) + +func GetCDSViewModel(TechnicalName string) (*CDSViewModel, error) { + var model CDSViewModel + + cdsView, err := table.GetCDSView(TechnicalName) + if err != nil { + return nil, err + } + + model.CDSView = cdsView + + model.StateTitle = englishCases.String(model.State) + + fields, err := table.GetCDSViewFields(TechnicalName) + if err != nil { + return nil, err + } + + var fieldModel CDSViewFieldModel + var fieldsModel []CDSViewFieldModel + for _, field := range *fields { + fieldModel.CDSViewField = field + fieldModel.DataTypeTitle = englishCases.String(field.DataType) + fieldModel.FieldLengthOut = strings.TrimLeft(field.FieldLength, "0") + fieldModel.DescriptionOut = field.Description + if fieldModel.DescriptionOut == "" { + fieldModel.DescriptionOut = "-" + } + fieldsModel = append(fieldsModel, fieldModel) + } + + model.Fields = &fieldsModel + + return &model, nil +} + +func GetAllCDSViewModels() (*[]CDSViewModel, error) { + cdsViewTechnicalNames, err := table.QueryAllCDSViewTechnicalNames() + if err != nil { + return nil, err + } + + var cdsViewModels []CDSViewModel + for _, cdsViewTechnicalName := range *cdsViewTechnicalNames { + cdsViewModel, err := GetCDSViewModel(cdsViewTechnicalName) + if err != nil { + fmt.Println(err) + continue + } + + cdsViewModels = append(cdsViewModels, *cdsViewModel) + } + + return &cdsViewModels, nil +} diff --git a/cmd/model/keyword.go b/cmd/model/keyword.go new file mode 100644 index 0000000..3e95cc7 --- /dev/null +++ b/cmd/model/keyword.go @@ -0,0 +1,12 @@ +package model + +import "sap-cds-search/cmd/database/table" + +func GetKeywordsModel() (*[]table.Keyword, error) { + keywords, err := table.GetAllKeywords() + if err != nil { + return nil, err + } + + return keywords, nil +} diff --git a/cmd/model/results.go b/cmd/model/results.go new file mode 100644 index 0000000..0b998df --- /dev/null +++ b/cmd/model/results.go @@ -0,0 +1,20 @@ +package model + +type ResultsModel struct { + SearchTerm string + CurrentPage int + MaxPage int + Views []CDSViewModel +} + +type ResultsModelBuffer struct { + Views []CDSViewModel +} + +func NewResultsModel() *ResultsModel { + return &ResultsModel{} +} + +func (r *ResultsModel) AppendResultsModelViews(cdsView *CDSViewModel) { + r.Views = append(r.Views, *cdsView) +} diff --git a/cmd/router/router.go b/cmd/router/router.go new file mode 100644 index 0000000..3177e23 --- /dev/null +++ b/cmd/router/router.go @@ -0,0 +1,33 @@ +package router + +import ( + "fmt" + "net/http" + "sap-cds-search/cmd/search" + "sap-cds-search/cmd/ui" + + "github.com/go-chi/chi" +) + +func Load() *chi.Mux { + r := chi.NewRouter() + r.Group(func(r chi.Router) { + ui.Load() + r.Get("/", handleRoot) + r.Route("/search", func(r chi.Router) { + r.Get("/", search.HandleSearch) + }) + }) + + r.Handle("/ui/css/*", http.StripPrefix("/ui/css", http.FileServer(http.Dir("./cmd/ui/css")))) + r.Handle("/ui/svg/*", http.StripPrefix("/ui/svg", http.FileServer(http.Dir("./cmd/ui/svg")))) + return r +} + +func handleRoot(w http.ResponseWriter, r *http.Request) { + err := ui.Template.ExecuteTemplate(w, "main", nil) + if err != nil { + fmt.Println(err) + return + } +} diff --git a/cmd/search/search.go b/cmd/search/search.go new file mode 100644 index 0000000..8555fa7 --- /dev/null +++ b/cmd/search/search.go @@ -0,0 +1,215 @@ +package search + +import ( + "errors" + "fmt" + "net/http" + "sap-cds-search/cmd/model" + "sap-cds-search/cmd/ui" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/lithammer/fuzzysearch/fuzzy" +) + +type fuzzyResult struct { + CDSViewTechnicalName string + hits int + averageDistance float32 + score float32 +} + +type resultBuffer struct { + createdAt time.Time + hits int + mu sync.Mutex + results []fuzzyResult +} + +const pageStep = 12 + +var ( + ErrNoBuffer = errors.New("no buffer") + ErrNoResults = errors.New("no results") +) + +var searchTargets map[string][]string = make(map[string][]string) +var resultsBuffer map[string]*resultBuffer = make(map[string]*resultBuffer) + +func Load() error { + keywords, err := model.GetKeywordsModel() + if err != nil { + return err + } + for _, keyword := range *keywords { + table := searchTargets[keyword.CDSViewTechnicalName] + table = append(table, keyword.Keyword) + searchTargets[keyword.CDSViewTechnicalName] = table + } + + ticker := time.NewTicker(5 * time.Minute) + + go func() { + for { + select { + case <-ticker.C: + ClearOldBuffers() + } + } + }() + + return nil +} + +func GetSearchTerm(r *http.Request) string { + return strings.ToLower(r.URL.Query().Get("q")) +} + +func GetPage(r *http.Request) int { + page, _ := strconv.Atoi(r.URL.Query().Get("p")) + if page < 0 { + page = 0 + } + return page +} + +func GetBuffer(searchTerm string) (*resultBuffer, error) { + buffer, exists := resultsBuffer[searchTerm] + + if !exists { + return nil, ErrNoBuffer + } + + return buffer, nil +} + +func BuildBuffer(searchTerm string) error { + if searchTerm == "" { + return ErrNoResults + } + + splitSearchTerms := strings.Split(searchTerm, " ") + + splitSearchTerms = append(splitSearchTerms, searchTerm) + splitSearchTerms = append(splitSearchTerms, strings.ReplaceAll(searchTerm, " ", searchTerm)) + + var fuzzyResults []fuzzyResult + + for viewName, targets := range searchTargets { + totalHits := 0 + var totalDistance float32 + for i, splitSearchTerm := range splitSearchTerms { + ranks := fuzzy.RankFindFold(splitSearchTerm, targets) + + hits := len(ranks) + if hits == 0 { + if i < len(splitSearchTerm)-3 { + totalDistance += 500 + } + continue + } + + totalHits += hits + + var averageDistance float32 = 0 + for _, rank := range ranks { + averageDistance += float32(rank.Distance) + } + + totalDistance += averageDistance / float32(hits) + } + + if totalHits > 0 { + fuzzyResults = append(fuzzyResults, fuzzyResult{ + CDSViewTechnicalName: viewName, + hits: totalHits, + averageDistance: totalDistance, + score: totalDistance / float32(totalHits), + }) + } + } + + hits := len(fuzzyResults) + if hits == 0 { + return ErrNoResults + } + + slices.SortFunc(fuzzyResults, func(a, b fuzzyResult) int { + if a.score > b.score { + return 1 + } else if a.score < b.score { + return -1 + } + return 0 + }) + + resultsBuffer[searchTerm] = &resultBuffer{ + createdAt: time.Now(), + hits: hits, + results: fuzzyResults, + } + + return nil +} + +func HandleSearch(w http.ResponseWriter, r *http.Request) { + searchTerm := GetSearchTerm(r) + + buffer, err := GetBuffer(searchTerm) + if err != nil { + err := BuildBuffer(searchTerm) + if err != nil { + err := ui.Template.ExecuteTemplate(w, "search-placeholder-no-result", nil) + if err != nil { + fmt.Println(err) + } + return + } + buffer, _ = GetBuffer(searchTerm) + } + + buffer.mu.Lock() + defer buffer.mu.Unlock() + + page := GetPage(r) + + maxPage := (buffer.hits / pageStep) - 1 + if page+1 > maxPage { + page = maxPage + } + + resultsModel := model.NewResultsModel() + for i := page * pageStep; i < page*pageStep+pageStep; i++ { + result := buffer.results[i] + cdsView, err := model.GetCDSViewModel(result.CDSViewTechnicalName) + if err != nil { + fmt.Println(err) + continue + } + + resultsModel.AppendResultsModelViews(cdsView) + } + + resultsModel.SearchTerm = searchTerm + resultsModel.CurrentPage = page + resultsModel.MaxPage = maxPage + + err = ui.Template.ExecuteTemplate(w, "results", resultsModel) + if err != nil { + fmt.Println(err) + return + } +} + +func ClearOldBuffers() { + for key, buffer := range resultsBuffer { + if time.Since(buffer.createdAt) >= time.Minute*10 { + buffer.mu.Lock() + delete(resultsBuffer, key) + buffer.mu.Unlock() + } + } +} diff --git a/cmd/ui/css/styles.css b/cmd/ui/css/styles.css new file mode 100644 index 0000000..79ca640 --- /dev/null +++ b/cmd/ui/css/styles.css @@ -0,0 +1,511 @@ +:root { + --base_length: 35px; + --base_margin: 15px; + --radius_large: 12px; + --shadow: 0px 3px 5px #0003; + + --accent: #78aeed; + + --blue-1: #99c1f1; + --blue-2: #62a0ea; + --blue-3: #3584e4; + --blue-4: #1c71d8; + --blue-5: #1a5fb4; + + --green-3: #33d17a; + --green-4: #2ec27e; + --green-5: #26a269; + + --yellow-4: #f5c211; + --yellow-5: #e5a50a; + + --default-margin: 8px var(--base_margin) 8px var(--base_margin); +} + +@media (prefers-color-scheme: dark) { + :root { + --success: var(--green-4); + --warning: var(--yellow-5); + --border: #ffffff17; + + --brightness-mixin: #fff; + --brightness-hover: 110%; + --brightness-active: 130%; + + --background: #242424; + --on_background: #fff; + + --card: #363636; + --on_card: #fff; + + --osd: #000; + --on_osd: #fff; + + --raised: #4a4a4a; + --on_raised: #fff; + } +} + +@media (prefers-color-scheme: light) { + :root { + --success: var(--green-5); + --warning: var(--yellow-4); + + --border: #0000001f; + + --brightness-mixin: #000; + --brightness-hover: 90%; + --brightness-active: 70%; + + --background: #fafafa; + --on_background: #323232; + + --card: #fff; + --on_card: #333333; + + --osd: #4c4c4c; + --on_osd: #fff; + + --raised: #ebebeb; + --on_raised: #2f2f2f; + } +} + +body { + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; + background-color: var(--background); + color: var(--on_background); +} + +.min-w2 { + min-width: calc(2 * var(--base_length)); +} + +.min-w3 { + min-width: calc(3 * var(--base_length)); +} + +.min-w5 { + min-width: calc(5 * var(--base_length)); +} + +.min-w6 { + min-width: calc(6 * var(--base_length)); +} + +.min-w8 { + min-width: calc(8 * var(--base_length)); +} + +.min-w10 { + min-width: calc(10 * var(--base_length)); +} + +.min-w12 { + min-width: calc(12 * var(--base_length)); +} + +.min-w14 { + min-width: calc(14 * var(--base_length)); +} + +.max-w1 { + max-width: calc(1 * var(--base_length)); +} + +.max-w2 { + max-width: calc(2 * var(--base_length)); +} + +.max-w3 { + max-width: calc(3 * var(--base_length)); +} + +.max-w6 { + max-width: calc(6 * var(--base_length)); +} + +.max-w8 { + max-width: calc(8 * var(--base_length)); +} + +.max-w10 { + max-width: calc(10 * var(--base_length)); +} + +.max-w16 { + max-width: calc(16 * var(--base_length)); +} + +.max-w21 { + max-width: calc(20 * var(--base_length)); +} + +.max-w24 { + max-width: calc(24 * var(--base_length)); +} + +.boxed-list { + background-color: var(--card); + color: var(--on_card); + display: flex; + flex-direction: column; + flex-grow: 1; + border-radius: var(--radius_large); + min-width: fit-content !important; + border: 1px; + border-color: var(--border); + box-shadow: var(--shadow); +} + +.boxed-list > * { + width: 100%; + display: inline; + border: none; + border-radius: 12px; +} + +.boxed-list > * > * { + align-self: center; +} + +.boxed-list > * > .title { + padding-right: 15px; + margin-left: 15px; + margin-right: auto; + color: var(--on_card); +} + +.boxed-list > * > .title > * { + color: color-mix(in srgb, var(--on_card) 50%, var(--card) 50%); +} + +.boxed-list > * > .prefix { + margin: 0px 0px 0px var(--base_margin); +} + +.boxed-list > * > .suffix { + margin: 0px var(--base_margin) 0px var(--base_margin); +} + +.boxed-list > *:not(:last-child):not(.expander), +.boxed-list > .expander:has(> .expander-state:checked), +.boxed-list + > .expander:not(:has(> .expander-state:checked)):has( + + .expander-content:not(:last-child) + ) { + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + border-style: solid; + border-width: 0px 0px 1px 0px; + border-color: var(--border); +} + +.boxed-list > *:not(:first-child) { + border-top-right-radius: 0px; + border-top-left-radius: 0px; +} + +.boxed-list > *:focus { + z-index: 1; + outline: solid blue 2px; +} + +.boxed-list > * { + height: 55px; + display: inline-flex; + vertical-align: middle; + justify-content: center; + align-content: center; + transition: background-color 0.15s; +} + +.boxed-list > .active:hover { + background-color: color-mix( + in srgb, + var(--card) 98%, + var(--brightness-mixin) 2% + ); +} + +.boxed-list > .active:active { + background-color: color-mix( + in srgb, + var(--card) 95%, + var(--brightness-mixin) 5% + ); +} + +.boxed-list > * > .prefix { + float: left; +} + +.round { + border-radius: 5px; +} + +.pill { + border-radius: 1000px; +} + +.raised { + background-color: var(--raised); + color: var(--on_raised); +} + +.flat { + background-color: inherit; + color: inherit; +} + +.suggested-action { + background-color: var(--blue-3); + color: #fff; +} + +.success { + background-color: color-mix(in srgb, currentColor 15%, transparent); + color: var(--success); +} + +.warning { + background-color: color-mix(in srgb, currentColor 15%, transparent); + color: var(--warning); +} + +input { + border: none; +} + +button { + height: var(--base_length); + width: inherit; + border: none; + font-weight: bold; + cursor: pointer; + transition: + filter 0.15s, + outline-offset 0.1s ease-in-out, + outline-color 0.1s ease-in-out; +} + +button:hover { + filter: brightness(var(--brightness-hover)); +} + +button:active { + filter: brightness(var(--brightness-active)); +} + +* { + outline-offset: 1px; + outline-style: solid; + outline-width: 2px; + outline-color: transparent; + transition: + outline-offset 0.1s ease-in-out, + outline-color 0.1s ease-in-out; +} + +*:focus-visible { + outline-offset: -1px; + outline-color: color-mix(in srgb, var(--accent) 50%, transparent); + z-index: 1; +} + +.adw-radio input[type="radio"] { + appearance: none; + height: calc(var(--base_length) * 0.6); + width: calc(var(--base_length) * 0.6); + border-radius: 50%; + border: 2px var(--raised) solid; + transition: box-shadow 0.3s ease; +} + +.adw-radio input[type="radio"]:checked { + border: 2px var(--blue-3) solid; + background-color: var(--blue-3); +} + +.adw-radio input[type="radio"]:before { + content: ""; + display: block; + width: 60%; + height: 60%; + margin: 20% auto; + border-radius: 50%; +} + +.adw-radio input[type="radio"]:checked:before { + background: #fff; +} + +.card { + overflow: hidden; + margin: var(--base_margin); + border-radius: var(--radius_large); + border: 1px solid var(--border); + color: var(--on_card); + background-color: var(--card); + box-shadow: var(--shadow); + margin: var(--base_length); +} + +.navigation { + min-width: fit-content; + max-height: calc(var(--base_length) * 1.5); + width: 80%; + border: 1px solid var(--border); + box-shadow: var(--shadow); + background-color: var(--background); + color: var(--on_background); + border-radius: var(--radius_large); + display: inline-flex; +} + +.navigation > * { + margin: 10px; + max-height: 32px; + align-self: center; +} + +.tag { + height: var(--base_length); + text-align: center; + align-content: center; + font-weight: bold; + font-size: 12px; +} + +.label { + margin: var(--default-margin); + font-size: var(--base_margin); +} + +.heading { + font-weight: bold; + font-size: var(--base_margin); +} + +.title-1 { + font-size: x-large; + font-weight: bold; +} + +.expander-state { + position: relative; + width: 0%; + height: 0%; + margin: 0%; + visibility: hidden; +} + +.boxed-list > .expander { + border-bottom-color: transparent !important; + transition: border-bottom 0.15s steps(1, end); +} + +.expander:has(> .expander-state:checked) { + border-bottom: 1px solid var(--border) !important; + transition: border-bottom 0.15s steps(1, start); +} + +.expander-content { + overflow-x: hidden; + overflow-y: scroll; + max-height: 0; + transition: max-height 0.15s ease-in-out; + box-sizing: border-box; + height: auto; + display: block; +} +.expander:has(> .expander-state:checked) + .expander-content { + max-height: 500px; +} + +.container-center { + display: inline-flex; + justify-content: center; + min-width: 100%; +} + +table { + margin: var(--default-margin); + width: 100%; +} + +th { + color: color-mix(in srgb, var(--on_card) 50%, var(--card) 50%); + font-size: var(--base_margin); +} + +.table-field-right { + text-align: right; +} + +.table-field-left { + text-align: left; +} + +.monospaced { + font-family: monospace; + font-size: var(--base_margin); +} + +.sans-serif { + font-family: sans-serif; +} + +.placeholder-image { + color: color-mix(in srgb, currentColor 60%, transparent); + width: 10em; + height: 10em; + mask-position: center; + background-color: currentColor; + mask-repeat: no-repeat; + mask-size: 100%; + margin: var(--base_margin); +} + +.placeholder-image.loupe { + mask-image: url("/ui/svg/loupe.svg"); +} + +.linked-horizontal > *:not(:first-child) { + margin-left: 0px; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; +} +.linked-horizontal > *:not(:last-child) { + margin-right: 0px; + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; +} + +input { + color: var(--on_background); + background-color: color-mix(in srgb, currentColor 10%, transparent); + min-height: var(--base_length); + padding-left: var(--base_margin); + margin: var(--default-margin); + font-size: 15px; + padding-top: 0px; + padding-bottom: 0px; + position: relative; +} + +button { + margin: var(--default-margin); + position: relative; +} diff --git a/cmd/ui/html/main.html b/cmd/ui/html/main.html new file mode 100644 index 0000000..e46fa45 --- /dev/null +++ b/cmd/ui/html/main.html @@ -0,0 +1,38 @@ +{{block "main" .}} + + + + + + SAP CDS-View Search + + + {{template "search-bar" .}} +
+ {{template "search-placeholder-info" . }} +
+ + +{{end}} {{block "search-bar" .}} +
+ + +
+{{end}} diff --git a/cmd/ui/html/search-result.html b/cmd/ui/html/search-result.html new file mode 100644 index 0000000..10a3bb3 --- /dev/null +++ b/cmd/ui/html/search-result.html @@ -0,0 +1,126 @@ +{{block "results" .}} +
+ {{template "paging" .}} +
+ {{range .Views}} {{template "result" .}} {{end}} +
+
+ +{{end}} {{block "result" .}} + +
+
Field Properties
+
{{template "fields" .Fields}}
+
+{{end}} {{block "fields" .}} + + + + + + + + {{range .}} {{template "field" .}} {{end}} +
NameDescriptionTypeLength
+{{end}} {{block "field" .}} + + {{.FieldName}} + {{.DescriptionOut}} + {{.DataTypeTitle}} + {{.FieldLengthOut}} + +{{end}} {{block "search-placeholder-no-result" .}} +
+
+
+
+
+
No Results Found
+
+
+
Try refining your search term
+
+
+{{end}} {{block "search-placeholder-info" .}} +
+
+
+
+
+
Search CDS-Views
+
+
+
Find CDS-Views from the Business Acceleator Hub
+
+
+{{end}} {{block "paging" .}} +
+
+ +
+ +
+
+
+{{end}} diff --git a/cmd/ui/svg/loupe.svg b/cmd/ui/svg/loupe.svg new file mode 100644 index 0000000..44d3875 --- /dev/null +++ b/cmd/ui/svg/loupe.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/cmd/ui/ui.go b/cmd/ui/ui.go new file mode 100644 index 0000000..667ff65 --- /dev/null +++ b/cmd/ui/ui.go @@ -0,0 +1,12 @@ +package ui + +import "text/template" + +var Template *template.Template + +func Load() { + Template = template.Must(template.New("ui").ParseFiles( + "./cmd/ui/html/main.html", + "./cmd/ui/html/search-result.html", + )) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..00ea93f --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module sap-cds-search + +go 1.23.7 + +require ( + github.com/glebarez/go-sqlite v1.22.0 + github.com/go-chi/chi v1.5.5 + github.com/lithammer/fuzzysearch v1.1.8 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.9.0 // indirect + modernc.org/libc v1.37.6 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.28.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b15d31a --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= +github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw= +modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ= +modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f6c6b55 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "net/http" + "sap-cds-search/cmd/database" + "sap-cds-search/cmd/router" + "sap-cds-search/cmd/search" + "sap-cds-search/cmd/ui" +) + +func main() { + database.Load() + + err := search.Load() + if err != nil { + panic(err) + } + ui.Load() + r := router.Load() + + fmt.Println("Listening on :8080") + http.ListenAndServe(":8080", r) +}