Initial Commit

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

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