Initial Commit

This commit is contained in:
snoutie 2025-03-24 08:50:01 +01:00
commit 30611aa45d
26 changed files with 1477 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
_*
*.sqlite

83
cmd/api/api.go Normal file
View File

@ -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
}

36
cmd/database/database.go Normal file
View File

@ -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
}

View File

@ -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)
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,4 @@
INSERT
OR REPLACE INTO CDSViewField
VALUES
(?, ?, ?, ?, ?)

View File

@ -0,0 +1,4 @@
INSERT
OR REPLACE INTO Keyword
VALUES
(?, ?)

View File

@ -0,0 +1,4 @@
SELECT
TechnicalName
FROM
CDSView

View File

@ -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

View File

@ -0,0 +1,2 @@
SELECT * FROM CDSView
WHERE TechnicalName = ?

View File

@ -0,0 +1,2 @@
SELECT * FROM CDSViewField
WHERE CDSViewTechnicalName = ?

80
cmd/model/cdsView.go Normal file
View File

@ -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
}

12
cmd/model/keyword.go Normal file
View File

@ -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
}

20
cmd/model/results.go Normal file
View File

@ -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)
}

33
cmd/router/router.go Normal file
View File

@ -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
}
}

215
cmd/search/search.go Normal file
View File

@ -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()
}
}
}

511
cmd/ui/css/styles.css Normal file
View File

@ -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;
}

38
cmd/ui/html/main.html Normal file
View File

@ -0,0 +1,38 @@
{{block "main" .}}
<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" href="/ui/css/styles.css" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<title>SAP CDS-View Search</title>
</head>
<body>
{{template "search-bar" .}}
<div id="search-results" class="container-center">
{{template "search-placeholder-info" . }}
</div>
</body>
</html>
{{end}} {{block "search-bar" .}}
<form class="container-center linked-horizontal" style="margin-bottom: 30px">
<input
class="round min-w4 max-w21"
style="flex-grow: 1"
placeholder="Search here"
id="search-bar"
name="q"
/>
<button
type="submit"
class="round raised suggested-action max-w3"
style="flex-grow: 1"
hx-get="/search"
hx-params="*"
hx-include="#search-bar"
hx-target="#search-results"
hx-swap="innerHTML"
>
Search
</button>
</form>
{{end}}

View File

@ -0,0 +1,126 @@
{{block "results" .}}
<div>
{{template "paging" .}}
<div class="boxed-list max-w24">
{{range .Views}} {{template "result" .}} {{end}}
</div>
</div>
{{end}} {{block "result" .}}
<label class="active expander" for="{{.TechnicalName}}">
<input class="expander-state" type="checkbox" id="{{.TechnicalName}}" />
<div class="title sans-serif">
{{.DisplayName}}
<div class="monospaced">{{.TechnicalName}}</div>
</div>
<div
class='tag pill suffix {{if eq .State "RELEASED"}} success {{else}} warning {{end}} min-w3'
>
{{.StateTitle}}
</div>
</label>
<div class="expander-content">
<div class="label heading">Field Properties</div>
<div class="container-center">{{template "fields" .Fields}}</div>
</div>
{{end}} {{block "fields" .}}
<table>
<tr>
<th class="table-field-left">Name</th>
<th class="table-field-left">Description</th>
<th class="table-field-left">Type</th>
<th class="table-field-right">Length</th>
</tr>
{{range .}} {{template "field" .}} {{end}}
</table>
{{end}} {{block "field" .}}
<tr>
<td class="table-field-left monospaced">{{.FieldName}}</td>
<td class="table-field-left">{{.DescriptionOut}}</td>
<td class="table-field-left">{{.DataTypeTitle}}</td>
<td class="table-field-right monospaced">{{.FieldLengthOut}}</td>
</tr>
{{end}} {{block "search-placeholder-no-result" .}}
<div>
<div class="container-center">
<div class="placeholder-image loupe"></div>
</div>
<div class="container-center">
<div class="label title-1">No Results Found</div>
</div>
<div class="container-center">
<div class="label">Try refining your search term</div>
</div>
</div>
{{end}} {{block "search-placeholder-info" .}}
<div>
<div class="container-center">
<div class="placeholder-image loupe"></div>
</div>
<div class="container-center">
<div class="label title-1">Search CDS-Views</div>
</div>
<div class="container-center">
<div class="label">Find CDS-Views from the Business Acceleator Hub</div>
</div>
</div>
{{end}} {{block "paging" .}}
<div>
<form style="float: right">
<input name="q" disabled hidden value="{{.SearchTerm}}" />
<div class="linked-horizontal">
<!--button
type="submit"
class="raised round"
style="margin-top: 9px"
hx-get="/search"
hx-params="*"
hx-include="#search-bar"
hx-target="#search-results"
hx-swap="innerHTML"
{{if
eq
.CurrentPage
0}}
disabled
{{end}}
>
</button
--><input
class="round max-w2"
type="number"
name="p"
value="{{.CurrentPage}}"
min="0"
max="{{.MaxPage}}"
hx-get="/search"
hx-params="*"
hx-include="#search-bar"
hx-target="#search-results"
hx-swap="innerHTML"
/><input
class="round raised max-w2"
value="{{.MaxPage}}"
disabled
/><!--button
type="submit"
class="raised round"
style="margin-top: 9px"
hx-get="/search"
hx-params="*"
hx-include="#search-bar"
hx-target="#search-results"
hx-swap="innerHTML"
{{if
eq
.CurrentPage
.MaxPage}}
disabled
{{end}}
>
</button-->
</div>
</form>
</div>
{{end}}

2
cmd/ui/svg/loupe.svg Normal file
View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><g fill="#222222"><path d="m 10.875 10.0625 c -0.8125 0.148438 -1.105469 1.160156 -0.5 1.71875 l 3 3 c 0.957031 0.9375 2.363281 -0.5 1.40625 -1.4375 l -3 -3 c -0.234375 -0.238281 -0.574219 -0.347656 -0.90625 -0.28125 z m 0 0"/><path d="m 6.570312 0.0625 c -3.578124 0 -6.4999995 2.921875 -6.4999995 6.5 s 2.9218755 6.5 6.4999995 6.5 c 3.578126 0 6.5 -2.921875 6.5 -6.5 s -2.921874 -6.5 -6.5 -6.5 z m 0 2 c 2.5 0 4.5 2.003906 4.5 4.5 c 0 2.5 -2 4.5 -4.5 4.5 c -2.496093 0 -4.5 -2 -4.5 -4.5 c 0 -2.496094 2.003907 -4.5 4.5 -4.5 z m 0 0"/></g></svg>

After

Width:  |  Height:  |  Size: 672 B

12
cmd/ui/ui.go Normal file
View File

@ -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",
))
}

22
go.mod Normal file
View File

@ -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
)

59
go.sum Normal file
View File

@ -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=

24
main.go Normal file
View File

@ -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)
}