Compare commits

...

29 Commits

Author SHA1 Message Date
348c737163 add host support 2024-09-25 00:48:47 +03:00
c3d6510a7d support metrics sending via grpc 2024-09-25 00:47:12 +03:00
c7fada2c54 update trace logic 2024-09-24 21:59:33 +03:00
c0f45d98c2 count courses by school 2024-06-16 23:55:49 +03:00
1a31006b21 Make filters by all options at once
Before script has been separated on two groups: for learning type and
course category and for school and sorting. It means when school is
choosen, learning type and course category resets on update.

TODO: need to add pagination
2024-05-02 23:24:41 +03:00
e8ac96a065 minor fix 2024-04-19 00:31:26 +03:00
3a9e01a683 rework list courses page to be flatten 2024-04-19 00:13:03 +03:00
035e9c848f minor web-view improvment 2024-04-11 10:33:14 +03:00
605e117586 add pagination 2024-04-07 23:49:06 +03:00
68810d93a7 add opentelemetry tracing 2024-04-02 15:23:22 +03:00
e7c2832865 Add command and query for organizations
* Added command and query for organizations
* Saving unknown organizations into database in `background` service
* Added `List` method in `OrganizationRepository`
2024-03-24 22:59:32 +03:00
9d2efcc1c4 implement organization repo 2024-03-24 16:51:39 +03:00
88a3cae4fa learning category repo 2024-03-16 17:44:43 +03:00
938d3cd307 add sqlite support 2024-03-12 14:44:22 +03:00
97986063df bootstrap migrate 2024-03-04 00:03:08 +03:00
251ecd94d4 minor fixes 2024-03-03 09:34:17 +03:00
6d1769ff24 make bootstrap site be more cool 2024-03-03 00:55:43 +03:00
af4a4f7840 bootstrap migrate 2024-03-02 19:34:33 +03:00
e5dfccabbf use tailwind 2024-02-10 20:22:30 +03:00
d5f76f4e07 add templ engine 2024-01-21 16:49:47 +03:00
d4974b30ec update tags 2024-01-11 19:45:02 +03:00
8b8a7618a2 finish breadcumbs and categories 2024-01-11 19:17:26 +03:00
067c63baa8 add favicon and breadcumbs 2024-01-11 11:55:37 +03:00
5fd0861e2d filter by learning_types and course_thematics 2024-01-10 00:02:40 +03:00
728c8fa59e embed assets and minor fixes 2024-01-09 15:19:53 +03:00
2c0564f68c add show course and map names 2024-01-08 18:31:35 +03:00
48f5d80f7a add more style 2024-01-05 23:03:15 +03:00
fbe9927ac3 able to update desc for course 2023-12-18 00:14:07 +03:00
1d4e8e10fb implement webserver 2023-12-17 21:21:13 +03:00
102 changed files with 9536 additions and 872 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
./assets/kurious binary
*/**/*_templ.go binary

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
*.json
bin
./tags
*.sqlite
.zed
*.log

View File

@ -1,3 +1,2 @@
with-expecter: true
keeptree: True

1
.task/checksum/generate Normal file
View File

@ -0,0 +1 @@
d65622032d35cb78ee1539f4ab4d875b

View File

@ -1,4 +1,4 @@
version: '3'
version: "3"
env:
CGO_ENABLED: 0
@ -10,34 +10,64 @@ vars:
GIT_VERSION:
sh: git tag | sort -r --version-sort | head -n1
BUILD_TIME:
sh: TZ=UTC date --iso-8601=seconds
sh: TZ=UTC date -u +"%Y-%m-%dT%H:%M:%SZ"
LDFLAGS:
sh: echo '-X "{{.PROJECT}}.buildTime={{.BUILD_TIME}}" -X "{{.PROJECT}}.commit={{.GIT_COMMIT}}" -X "{{.PROJECT}}.version={{.GIT_VERSION}}"'
tasks:
install_tools:
cmds:
- go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2
- "[[ ! -f $GOBIN/golangci-lint ]] && go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.55.2 || echo golang-ci lint installed"
- "rm -rf $GOBIN/templ && go install github.com/a-h/templ/cmd/templ@v0.2.707 || echo templ installed"
- "[[ ! -f $GOBIN/mockery ]] && go install github.com/vektra/mockery/v2@v2.42.1 || echo mockery installed"
generate:
run: once
cmds:
- "$GOBIN/templ generate"
sources:
- "internal/kurious/ports/http/templ/*.templ"
- "internal/kurious/ports/http/bootstrap/*.templ"
generates:
- "internal/kurious/ports/http/templ/*.go"
- "internal/kurious/ports/http/bootstrap/*.go"
deps:
- install_tools
mocks:
run: once
cmd: "go generate ./internal/..."
deps:
- install_tools
check:
run: once
cmds:
- "$GOBIN/golangci-lint run ./..."
deps:
- generate
test:
run: once
cmds:
- go test ./internal/...
deps:
- generate
build_web:
cmds:
- go build -o $GOBIN/kuriousweb -v -ldflags '{{.LDFLAGS}}' cmd/kuriweb/*.go
deps: [check, test]
build_background:
cmds:
- go build -o $GOBIN/sravnibackground -v -ldflags '{{.LDFLAGS}}' cmd/background/*.go
- go build -o $GOBIN/kuriousbg -v -ldflags '{{.LDFLAGS}}' cmd/background/*.go
deps: [check, test]
build_dev_cli:
cmds:
- go build -o $GOBIN/sravnicli -v -ldflags '{{.LDFLAGS}}' cmd/dev/sravnicli/*.go
- go build -o $GOBIN/sravnicli -v -ldflags '{{.LDFLAGS}}' cmd/dev/sravnicli/*.go
deps: [check, test]
build:
cmds:
- task: build_dev_cli
- task: build_background
- task: build_web
run:
deps: [build]
cmds:
- $GOBIN/sravnicli
- $GOBIN/kuriousweb

6
assets/kurious/about.txt Normal file
View File

@ -0,0 +1,6 @@
This favicon was generated using the following font:
- Font Title: Lemon
- Font Author: Copyright 2011 The Lemon Project Authors (https://github.com/etunni/lemon) with Reserved Font Name "Lemon"
- Font Source: http://fonts.gstatic.com/s/lemon/v17/HI_EiYEVKqRMq0jBSZXAQ4-d.ttf
- Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL))

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

13
assets/kurious/embed.go Normal file
View File

@ -0,0 +1,13 @@
package kurious
import (
"embed"
"net/http"
)
//go:embed *
var root embed.FS
func AsHTTPFileHandler() http.Handler {
return http.FileServer(http.FS(root))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 B

BIN
assets/kurious/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,2 @@
User-agent:
Disallow: /

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -0,0 +1,6 @@
.btn.btn-primary {
color: white;
background-color: black;
border: none;
border-radius: 4px;
}

View File

@ -8,10 +8,20 @@ import (
"git.loyso.art/frx/kurious/internal/common/config"
)
type dbEngine string
const (
DBEngineUnknown dbEngine = ""
DBEngineYDB dbEngine = "ydb"
DBEngineSqlite dbEngine = "sqlite"
)
type Config struct {
Log config.Log `json:"log"`
YDB config.YDB `json:"ydb"`
SyncSravniCron string `json:"sync_sravni_cron"`
Log config.Log `json:"log"`
YDB config.YDB `json:"ydb"`
Sqlite config.Sqlite `json:"sqlite"`
DBEngine dbEngine `json:"db_engine"`
SyncSravniCron string `json:"sync_sravni_cron"`
DebugHTTP bool `json:"debug_http"`
}
@ -37,5 +47,7 @@ func defaultConfig() Config {
Level: config.LogLevelInfo,
Format: config.LogFormatText,
},
// TODO: change to sqlite once it proven to be working
DBEngine: DBEngineYDB,
}
}

View File

@ -13,6 +13,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xlog"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
"git.loyso.art/frx/kurious/internal/kurious/ports"
"git.loyso.art/frx/kurious/internal/kurious/service"
)
@ -28,7 +29,13 @@ func main() {
}
}
const savingOrganizationIDInternalInsteadOfExternal = false
func app(ctx context.Context) error {
if !savingOrganizationIDInternalInsteadOfExternal {
panic("fix saving ogranization id as external id instead of internal")
}
var cfgpath string
if len(os.Args) > 1 {
cfgpath = os.Args[1]
@ -43,19 +50,49 @@ func app(ctx context.Context) error {
log := config.NewSLogger(cfg.Log)
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
})
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
sravniClient, err := sravni.NewClient(ctx, log, cfg.DebugHTTP)
if err != nil {
return fmt.Errorf("making sravni client: %w", err)
}
mainPageState, err := sravniClient.GetMainPageState()
if err != nil {
return fmt.Errorf("getting main page state: %w", err)
}
dictionaries := mainPageState.Props.InitialReduxState.Dictionaries.Data
dictFieldsAsMap := func(fields ...sravni.Field) map[string]string {
out := make(map[string]string, len(fields))
for _, field := range fields {
out[field.Value] = field.Name
}
return out
}
courseThematcisMapped := dictFieldsAsMap(dictionaries.CourseThematics.Fields...)
learningTypeMapped := dictFieldsAsMap(dictionaries.LearningType.Fields...)
mapper := adapters.NewMemoryMapper(courseThematcisMapped, learningTypeMapped)
var dbEngine service.RepositoryEngine
switch cfg.DBEngine {
case DBEngineSqlite:
dbEngine = service.RepositoryEngineSqlite
case DBEngineYDB:
dbEngine = service.RepositoryEngineYDB
}
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
Sqlite: cfg.Sqlite,
Engine: dbEngine,
}, mapper)
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
bgProcess := ports.NewBackgroundProcess(ctx, log)
err = bgProcess.RegisterSyncSravniHandler(ctx, app, sravniClient, cfg.SyncSravniCron)
if err != nil {
@ -72,13 +109,16 @@ func app(ctx context.Context) error {
defer xcontext.LogInfo(ctx, log, "finished bprocess")
bgProcess.Run()
return nil
})
eg.Go(func() error {
xcontext.LogInfo(ctx, log, "running cancelation waiter")
defer xcontext.LogInfo(ctx, log, "finished cancelation waiter")
<-egctx.Done()
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*15)
defer sdcancel()

View File

@ -1,5 +0,0 @@
package main
func main() {
println("oh well")
}

View File

@ -61,6 +61,7 @@ func setupCLI(ctx context.Context) cli.App {
case "courses":
out = state.Props.InitialReduxState.Dictionaries.Data.CourseThematics
}
log.InfoContext(ctx, "loaded state", slog.Any("state", out))
return 0

View File

@ -9,7 +9,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/client/sravni"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/common/xslices"
"github.com/teris-io/cli"
)
@ -44,7 +44,6 @@ func setupAPICommand(ctx context.Context) cli.Command {
WithOption(learningSelectionOpt).
WithAction(newProductsFilterCountAction(ctx))
})
apiEducation := cli.NewCommand("education", "Education related category").
WithCommand(apiEducationListProducts).
WithCommand(apiEducationFilterCount)
@ -136,11 +135,14 @@ func (a *listProductsAction) parse(args []string, options map[string]string) err
func (a *listProductsAction) handle() error {
params := sravni.ListEducationProductsParams{
LearningType: a.params.learningType,
CoursesThematics: []string{a.params.courseThematic},
Limit: a.params.limit,
Offset: a.params.offset,
LearningType: a.params.learningType,
Limit: a.params.limit,
Offset: a.params.offset,
}
if a.params.courseThematic != "" {
params.CoursesThematics = append(params.CoursesThematics, a.params.courseThematic)
}
result, err := a.client.ListEducationalProducts(a.ctx, params)
if err != nil {
return fmt.Errorf("listing education products: %w", err)
@ -188,7 +190,7 @@ func (a *productsFilterCountAction) parse(args []string, options map[string]stri
filterNotEmpty := func(value string) bool {
return value != ""
}
a.params.courseThematic = xslice.Filter(
a.params.courseThematic = xslices.Filter(
strings.Split(options[courseThematicOptName], ","),
filterNotEmpty,
)

42
cmd/kuriweb/config.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"encoding/json"
"fmt"
"os"
"git.loyso.art/frx/kurious/internal/common/config"
)
type Config struct {
Log config.Log `json:"log"`
YDB config.YDB `json:"ydb"`
Sqlite config.Sqlite `json:"sqlite"`
HTTP config.HTTP `json:"http"`
Tracing config.Trace `json:"tracing"`
DBEngine string `json:"db_engine"`
}
func readFromFile(path string, defaultConfigF func() Config) (Config, error) {
out := defaultConfigF()
payload, err := os.ReadFile(path)
if err != nil {
return out, fmt.Errorf("opening file: %w", err)
}
err = json.Unmarshal(payload, &out)
if err != nil {
return out, fmt.Errorf("decoding as json: %w", err)
}
return out, nil
}
func defaultConfig() Config {
return Config{
Log: config.Log{
Level: config.LogLevelInfo,
Format: config.LogFormatText,
},
}
}

273
cmd/kuriweb/http.go Normal file
View File

@ -0,0 +1,273 @@
package main
import (
"log/slog"
"net/http"
"strings"
"time"
"git.loyso.art/frx/kurious/assets/kurious"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/generator"
"git.loyso.art/frx/kurious/internal/common/xcontext"
xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http"
"git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/gorilla/mux"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
)
func makePathTemplate(params ...string) string {
var sb strings.Builder
for _, param := range params {
sb.Grow(len(param) + 3)
sb.WriteRune('/')
sb.WriteRune('{')
sb.WriteString(param)
sb.WriteRune('}')
}
return sb.String()
}
func setupCoursesHTTP(srv xhttp.Server, router *mux.Router, _ *slog.Logger) {
coursesAPI := srv.Courses()
router.Handle("/", http.RedirectHandler("/courses", http.StatusPermanentRedirect))
coursesRouter := router.PathPrefix("/courses").Subrouter().StrictSlash(true)
coursesListLearningOnlyPath := makePathTemplate(xhttp.LearningTypePathParam)
coursesListFullPath := makePathTemplate(xhttp.LearningTypePathParam, xhttp.ThematicTypePathParam)
muxHandleFunc(coursesRouter, "index", "/", coursesAPI.Index).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, "list_learning", coursesListLearningOnlyPath, coursesAPI.List).Methods(http.MethodGet)
muxHandleFunc(coursesRouter, "list_full", coursesListFullPath, coursesAPI.List).Methods(http.MethodGet)
}
func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server {
router := mux.NewRouter()
router.Use(
middlewareCustomWriterInjector(),
mux.CORSMethodMiddleware(router),
middlewareLogger(log),
middlewareTrace(),
middlewareMetrics(),
)
setupCoursesHTTP(srv, router, log)
if cfg.MountLive {
fs := http.FileServer(http.Dir("./assets/kurious/static/"))
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", fs)).Methods(http.MethodGet)
registerFile := func(filepath string) {
if !strings.HasPrefix(filepath, "/") {
filepath = "/" + filepath
}
relativePath := "./assets/kurious" + filepath
router.HandleFunc(filepath, func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, relativePath)
}).Methods(http.MethodGet)
}
for _, file := range []string{
"robots.txt",
"android-chrome-192x192.png",
"android-chrome-512x512.png",
"apple-touch-icon.png",
"favicon-16x16.png",
"favicon-32x32.png",
"favicon.ico",
"site.webmanifest",
} {
registerFile(file)
}
} else {
fs := kurious.AsHTTPFileHandler()
router.PathPrefix("/*").Handler(fs).Methods(http.MethodGet)
}
return &http.Server{
Addr: cfg.ListenAddr,
Handler: router,
}
}
func middlewareCustomWriterInjector() mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
wr := wrapWithCustomWriter(w)
next.ServeHTTP(wr, r)
})
}
}
type attributeStringKey string
func (k attributeStringKey) Value(value string) attribute.KeyValue {
return attribute.String(string(k), value)
}
func must[T any](value T, err error) T {
if err != nil {
panic(err.Error())
}
return value
}
func middlewareMetrics() mux.MiddlewareFunc {
requestDuration := must(webmetric.Float64Histogram(
semconv.HTTPServerRequestDurationName,
metric.WithUnit(semconv.HTTPServerRequestDurationUnit),
metric.WithDescription(semconv.HTTPServerRequestDurationDescription),
))
f := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
route := mux.CurrentRoute(r)
hpath, _ := route.GetPathTemplate()
attributes := make([]attribute.KeyValue, 0, 8)
attributes = append(
attributes,
semconv.HTTPRequestMethodOriginal(r.Method),
semconv.URLFull(hpath),
semconv.URLPath(r.URL.Path),
)
next.ServeHTTP(w, r)
if wr, ok := w.(*customResponseWriter); ok {
statusCode := xdefault.WithFallback(wr.statusCode, http.StatusOK)
attributes = append(attributes,
semconv.HTTPResponseStatusCode(statusCode),
semconv.HTTPResponseBodySize(wr.wroteBytes),
)
}
elapsed := time.Since(start).Truncate(time.Millisecond)
requestDuration.Record(
r.Context(), elapsed.Seconds(), metric.WithAttributes(attributes...))
})
}
return f
}
func middlewareTrace() mux.MiddlewareFunc {
reqidAttr := attributeStringKey("http.request_id")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
reqid := xcontext.GetRequestID(ctx)
var span trace.Span
route := mux.CurrentRoute(r)
hpath, _ := route.GetPathTemplate()
ctx, span = webtracer.Start(
ctx, r.Method+" "+hpath,
trace.WithAttributes(
reqidAttr.Value(reqid),
semconv.HTTPRequestMethodOriginal(r.Method),
semconv.URLFull(hpath),
semconv.URLPath(r.URL.Path),
semconv.URLQuery(r.URL.RawQuery),
semconv.UserAgentOriginal(r.UserAgent()),
),
trace.WithSpanKind(trace.SpanKindServer),
)
defer span.End()
next.ServeHTTP(w, r.WithContext(ctx))
if wr, ok := w.(*customResponseWriter); ok {
statusCode := xdefault.WithFallback(wr.statusCode, http.StatusOK)
span.SetAttributes(
semconv.HTTPResponseStatusCode(statusCode),
semconv.HTTPResponseBodySize(wr.wroteBytes),
)
if statusCode > 399 {
span.SetStatus(codes.Error, "error during request")
} else {
span.SetStatus(codes.Ok, "request completed")
}
}
})
}
}
func middlewareLogger(log *slog.Logger) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
requestID := r.Header.Get("x-request-id")
if requestID == "" {
requestID = generator.RandomInt64ID()
}
ctx = xcontext.WithLogFields(
ctx,
slog.String("request_id", requestID),
)
ctx = xcontext.WithRequestID(ctx, requestID)
xcontext.LogInfo(
ctx, log, "incoming request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
start := time.Now()
next.ServeHTTP(w, r.WithContext(ctx))
elapsed := slog.Duration("elapsed", time.Since(start).Truncate(time.Millisecond))
logfields := make([]slog.Attr, 0, 3)
logfields = append(logfields, elapsed)
if wr, ok := w.(*customResponseWriter); ok {
statusCode := xdefault.WithFallback(wr.statusCode, http.StatusOK)
logfields = append(
logfields,
slog.Int("status_code", statusCode),
slog.Int("bytes_wrote", wr.wroteBytes),
)
}
xcontext.LogInfo(ctx, log, "request processed", logfields...)
})
}
}
func wrapWithCustomWriter(origin http.ResponseWriter) *customResponseWriter {
return &customResponseWriter{
ResponseWriter: origin,
}
}
type customResponseWriter struct {
http.ResponseWriter
statusCode int
wroteBytes int
}
func (w *customResponseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *customResponseWriter) Write(data []byte) (n int, err error) {
n, err = w.ResponseWriter.Write(data)
w.wroteBytes += n
return n, err
}

147
cmd/kuriweb/main.go Normal file
View File

@ -0,0 +1,147 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"git.loyso.art/frx/kurious/internal/common/client/sravni"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
xhttp "git.loyso.art/frx/kurious/internal/kurious/ports/http"
"git.loyso.art/frx/kurious/internal/kurious/service"
"golang.org/x/sync/errgroup"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
err := app(ctx)
if err != nil {
println(err.Error())
os.Exit(1)
}
}
func app(ctx context.Context) error {
var cfgpath string
if len(os.Args) > 1 {
cfgpath = os.Args[1]
} else {
cfgpath = "config.json"
}
cfg, err := readFromFile(cfgpath, defaultConfig)
if err != nil {
return fmt.Errorf("reading config from file: %w", err)
}
log := config.NewSLogger(cfg.Log)
shutdownOtel, err := setupOtelSDK(ctx, cfg.Tracing)
if err != nil {
return fmt.Errorf("setting up otel sdk: %w", err)
}
defer func() {
err := shutdownOtel(ctx)
if err != nil {
xcontext.LogWithError(ctx, log, err, "shutting down sdk")
}
}()
sravniClient, err := sravni.NewClient(ctx, log, false)
if err != nil {
return fmt.Errorf("unable to make new sravni client: %w", err)
}
mainPageState, err := sravniClient.GetMainPageState()
if err != nil {
return fmt.Errorf("getting main page state: %w", err)
}
dictionaries := mainPageState.Props.InitialReduxState.Dictionaries.Data
dictFieldsAsMap := func(fields ...sravni.Field) map[string]string {
out := make(map[string]string, len(fields))
for _, field := range fields {
out[field.Value] = field.Name
}
return out
}
courseThematcisMapped := dictFieldsAsMap(dictionaries.CourseThematics.Fields...)
learningTypeMapped := dictFieldsAsMap(dictionaries.LearningType.Fields...)
mapper := adapters.NewMemoryMapper(courseThematcisMapped, learningTypeMapped)
var dbengine service.RepositoryEngine
switch cfg.DBEngine {
case "ydb":
dbengine = service.RepositoryEngineYDB
case "sqlite":
dbengine = service.RepositoryEngineSqlite
default:
dbengine = service.RepositoryEngineUnknown
}
app, err := service.NewApplication(ctx, service.ApplicationConfig{
LogConfig: cfg.Log,
YDB: cfg.YDB,
Sqlite: cfg.Sqlite,
Engine: dbengine,
}, mapper)
if err != nil {
return fmt.Errorf("making new application: %w", err)
}
httpAPI := xhttp.NewServer(app, log.With(slog.String("component", "http")))
httpServer := setupHTTP(cfg.HTTP, httpAPI, log)
eg, egctx := errgroup.WithContext(ctx)
eg.Go(func() error {
xcontext.LogInfo(
ctx, log, "serving http",
slog.String("addr", httpServer.Addr),
)
if err := httpServer.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("listening http: %w", err)
}
}
return nil
})
eg.Go(func() error {
<-egctx.Done()
xcontext.LogInfo(ctx, log, "trying to shutdown http")
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*10)
defer sdcancel()
err := httpServer.Shutdown(sdctx)
if err != nil {
return fmt.Errorf("shutting down the server: %w", err)
}
xcontext.LogInfo(ctx, log, "server closed successfuly")
err = shutdownOtel(sdctx)
if err != nil {
return fmt.Errorf("shutting down sdk: %w", err)
}
return nil
})
return eg.Wait()
}

243
cmd/kuriweb/trace.go Normal file
View File

@ -0,0 +1,243 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"google.golang.org/grpc/encoding/gzip"
"github.com/gorilla/mux"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
var (
webtracer = otel.Tracer("kuriweb.http")
webmetric = otel.Meter("kuriweb.http")
)
type shutdownFunc func(context.Context) error
func setupOtelSDK(ctx context.Context, cfg config.Trace) (shutdown shutdownFunc, err error) {
var shutdownFuncs []shutdownFunc
shutdown = func(ctx context.Context) error {
var err error
for _, f := range shutdownFuncs {
err = errors.Join(err, f(ctx))
}
shutdownFuncs = nil
return err
}
handleError := func(inErr error) error {
err = errors.Join(inErr, shutdown(ctx))
return err
}
resource, err := makeServiceResource(ctx)
if err != nil {
return shutdown, fmt.Errorf("making service resource: %w", err)
}
prop := newPropagator()
otel.SetTextMapPropagator(prop)
tracerProvider, err := newCommonTraceProvider(ctx, TraceProviderParams{
Endpoint: cfg.Endpoint,
Type: cfg.Type,
AuthHeader: cfg.APIHeader,
APIKey: cfg.APIKey,
}, resource)
if err != nil {
return nil, handleError(err)
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
otel.SetTracerProvider(tracerProvider)
if cfg.ShowMetrics {
meterProvider, err := newCommonMeterProvider(ctx, meterProviderParams{
Endpoint: cfg.Endpoint,
Type: cfg.Type,
AuthHeaderKey: cfg.APIHeader,
AuthHeaderValue: cfg.APIKey,
ReadInterval: time.Second * 15,
}, resource)
if err != nil {
return nil, handleError(err)
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
otel.SetMeterProvider(meterProvider)
}
return shutdown, nil
}
func newPropagator() propagation.TextMapPropagator {
return propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
}
type TraceProviderParams struct {
Endpoint string
APIKey string
AuthHeader string
Type config.TraceClientType
}
func makeServiceResource(ctx context.Context) (*resource.Resource, error) {
r, err := resource.New(
ctx,
resource.WithDetectors(
resource.StringDetector(semconv.SchemaURL, semconv.ServiceNameKey, func() (string, error) {
return "bigstats:kuriweb", nil
}),
),
resource.WithHost(),
resource.WithAttributes(
semconv.ServiceName("bigstats:kuriweb"),
semconv.DeploymentEnvironment("production"),
),
)
if err != nil {
return nil, fmt.Errorf("making new resource: %w", err)
}
return resource.Merge(resource.Default(), r)
}
func newCommonTraceProvider(ctx context.Context, params TraceProviderParams, r *resource.Resource) (tp *trace.TracerProvider, err error) {
opts := make([]trace.TracerProviderOption, 0, 4)
opts = append(
opts,
trace.WithSampler(trace.AlwaysSample()),
trace.WithResource(r),
)
if params.Type != config.TraceClientTypeUnset {
var spanExporter trace.SpanExporter
var headers map[string]string
if params.AuthHeader != "" {
headers = make(map[string]string, 1)
headers[params.AuthHeader] = params.APIKey
}
switch params.Type {
case config.TraceClientTypeGRPC:
spanExporter, err = otlptracegrpc.New(
ctx,
otlptracegrpc.WithEndpointURL(params.Endpoint),
otlptracegrpc.WithInsecure(),
otlptracegrpc.WithHeaders(headers),
otlptracegrpc.WithCompressor(gzip.Name),
)
case config.TraceClientTypeHTTP:
httpClient := otlptracehttp.NewClient(
otlptracehttp.WithEndpointURL(params.Endpoint),
otlptracehttp.WithHeaders(headers),
otlptracehttp.WithCompression(otlptracehttp.GzipCompression),
)
spanExporter, err = otlptrace.New(
ctx, httpClient,
)
case config.TraceClientTypeStdout:
spanExporter, err = stdouttrace.New(stdouttrace.WithPrettyPrint())
default:
return nil, fmt.Errorf("unsupported provider type")
}
if err != nil {
return nil, fmt.Errorf("making trace exporter: %w", err)
}
opts = append(opts, trace.WithBatcher(spanExporter))
}
tp = trace.NewTracerProvider(opts...)
return tp, nil
}
type meterProviderParams struct {
Endpoint string
AuthHeaderKey string
AuthHeaderValue string
ReadInterval time.Duration
Type config.TraceClientType
}
func newCommonMeterProvider(ctx context.Context, params meterProviderParams, r *resource.Resource) (*metric.MeterProvider, error) {
var exporter metric.Exporter
var err error
switch params.Type {
case config.TraceClientTypeGRPC:
headers := make(map[string]string, 1)
if params.AuthHeaderKey != "" {
headers[params.AuthHeaderKey] = params.AuthHeaderValue
}
exporter, err = otlpmetricgrpc.New(
ctx,
otlpmetricgrpc.WithEndpointURL(params.Endpoint),
otlpmetricgrpc.WithHeaders(headers),
otlpmetricgrpc.WithCompressor(gzip.Name),
otlpmetricgrpc.WithTemporalitySelector(preferDeltaTemporalitySelector),
otlpmetricgrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("making grpc exporter: %w", err)
}
case config.TraceClientTypeStdout:
exporter, err = stdoutmetric.New()
if err != nil {
return nil, fmt.Errorf("making stdout exporter: %w", err)
}
default:
return nil, nil
}
reader := metric.NewPeriodicReader(
exporter,
metric.WithInterval(params.ReadInterval),
)
provider := metric.NewMeterProvider(
metric.WithReader(reader),
metric.WithResource(r),
)
return provider, nil
}
func muxHandleFunc(router *mux.Router, name, path string, hf http.HandlerFunc) *mux.Route {
// h := otelhttp.WithRouteTag(path, hf)
return router.Handle(path, hf).Name(name)
}
func preferDeltaTemporalitySelector(kind metric.InstrumentKind) metricdata.Temporality {
switch kind {
case metric.InstrumentKindCounter,
metric.InstrumentKindObservableCounter,
metric.InstrumentKindHistogram:
return metricdata.DeltaTemporality
default:
return metricdata.CumulativeTemporality
}
}

72
go.mod
View File

@ -1,37 +1,65 @@
module git.loyso.art/frx/kurious
go 1.21
go 1.22
require (
github.com/go-resty/resty/v2 v2.10.0
github.com/stretchr/testify v1.8.4
github.com/a-h/templ v0.2.707
github.com/go-resty/resty/v2 v2.13.1
github.com/gorilla/mux v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.9.0
github.com/teris-io/cli v1.0.1
golang.org/x/net v0.18.0
github.com/ydb-platform/ydb-go-sdk/v3 v3.74.3
github.com/ydb-platform/ydb-go-yc v0.12.1
go.opentelemetry.io/otel v1.30.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.27.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.27.0
go.opentelemetry.io/otel/metric v1.30.0
go.opentelemetry.io/otel/sdk v1.30.0
go.opentelemetry.io/otel/sdk/metric v1.30.0
go.opentelemetry.io/otel/trace v1.30.0
golang.org/x/net v0.29.0
golang.org/x/sync v0.8.0
golang.org/x/time v0.5.0
google.golang.org/grpc v1.66.1
modernc.org/sqlite v1.30.1
)
require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20231120081503-a21e9fe75162 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd // indirect
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2 // indirect
github.com/ydb-platform/ydb-go-yc v0.12.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/yandex-cloud/go-genproto v0.0.0-20240529120826-df2b24336f42 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 // indirect
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f // indirect
google.golang.org/grpc v1.59.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b // indirect
modernc.org/libc v1.53.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

186
go.sum
View File

@ -509,12 +509,16 @@ cloud.google.com/go/workflows v1.8.0/go.mod h1:ysGhmEajwZxGn1OhGOGKsTXc5PyxOc0vf
cloud.google.com/go/workflows v1.9.0/go.mod h1:ZGkj1aFIOd9c8Gerkjjq7OW7I5+l6cSvT3ujaO/WwSA=
cloud.google.com/go/workflows v1.10.0/go.mod h1:fZ8LmRmZQWacon9UCX1r/g/DfAXx5VcPALq2CxzdePw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gioui.org v0.0.0-20210308172011-57750fc8a0a6/go.mod h1:RSH6KIUZ0p2xy5zHDxgAM4zumjgTw83q2ge/PI+yyw8=
git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
@ -525,6 +529,8 @@ github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0I
github.com/apache/thrift v0.16.0/go.mod h1:PHK3hniurgQaNMZYaCLEqXKsYK8upmhPbmdP2FXSqgU=
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
@ -554,6 +560,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -572,8 +580,6 @@ github.com/envoyproxy/protoc-gen-validate v0.10.0/go.mod h1:DRjgyB0I43LtJapqN6Ni
github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks=
github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY=
@ -584,12 +590,18 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-latex/latex v0.0.0-20210118124228-b3d85cf34e07/go.mod h1:CO1AlKB2CSIqUrmQPqA0gdRIlnLEY0gK5JGjh37zN5U=
github.com/go-latex/latex v0.0.0-20210823091927-c0d11ff05a81/go.mod h1:SX0U8uGpxhq9o2S/CELCSUxEWWAuoCUcVCQWv7G2OCk=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-pdf/fpdf v0.5.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M=
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g=
github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.9.11/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
@ -627,7 +639,6 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@ -649,6 +660,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -669,11 +682,13 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
@ -691,14 +706,22 @@ github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMd
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3/go.mod h1:o//XUCC/F+yRGJoPO/VU0GSB0f8Nhgmxx0VIRUvaC0w=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
@ -715,17 +738,28 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY=
github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
@ -741,13 +775,19 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/rekby/fixenv v0.3.2/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
github.com/rekby/fixenv v0.6.1 h1:jUFiSPpajT4WY2cYuc++7Y1zWrnCxnovGCIX72PZniM=
github.com/rekby/fixenv v0.6.1/go.mod h1:/b5LRc06BYJtslRtHKxsPWFT/ySpHV+rWvzTg+XWk4c=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
@ -756,8 +796,9 @@ github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z
github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
@ -767,21 +808,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/teris-io/cli v1.0.1 h1:J6jnVHC552uqx7zT+Ux0++tIvLmJQULqxVhCid2u/Gk=
github.com/teris-io/cli v1.0.1/go.mod h1:V9nVD5aZ873RU/tQXLSXO8FieVPQhQvuNohsdsKXsGw=
github.com/yandex-cloud/go-genproto v0.0.0-20211115083454-9ca41db5ed9e/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/yandex-cloud/go-genproto v0.0.0-20231120081503-a21e9fe75162 h1:xCzizLC090MiLWEV3aziL5YIKrSVTRXX2DXlRGeQ6sA=
github.com/yandex-cloud/go-genproto v0.0.0-20231120081503-a21e9fe75162/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/yandex-cloud/go-genproto v0.0.0-20240529120826-df2b24336f42 h1:l5Wu1kRcM34HqBR2FZI6tWc6QKyPziNj5fGZ4eXTCRI=
github.com/yandex-cloud/go-genproto v0.0.0-20240529120826-df2b24336f42/go.mod h1:HEUYX/p8966tMUHHT+TsS0hF/Ca/NYwqprC5WXSDMfE=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20221215182650-986f9d10542f/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20230528143953-42c825ace222/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd h1:dzWP1Lu+A40W883dK/Mr3xyDSM/2MggS8GtHT0qgAnE=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20231012155159-f85a672542fd/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7 h1:nL8XwD6fSst7xFUirkaWJmE7kM0CdWRYgu6+YQer1d4=
github.com/ydb-platform/ydb-go-genproto v0.0.0-20240528144234-5d5a685e41f7/go.mod h1:Er+FePu1dNUieD+XTMDduGpQuCPssK5Q4BjF+IIXJ3I=
github.com/ydb-platform/ydb-go-sdk/v3 v3.44.0/go.mod h1:oSLwnuilwIpaF5bJJMAofnGgzPJusoI3zWMNb8I+GnM=
github.com/ydb-platform/ydb-go-sdk/v3 v3.47.3/go.mod h1:bWnOIcUHd7+Sl7DN+yhyY1H/I61z53GczvwJgXMgvj0=
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2 h1:E0yUuuX7UmPxXm92+yQCjMveLFO3zfvYFIJVuAqsVRA=
github.com/ydb-platform/ydb-go-sdk/v3 v3.54.2/go.mod h1:fjBLQ2TdQNl4bMjuWl9adoTGBypwUTPoGC+EqYqiIcU=
github.com/ydb-platform/ydb-go-sdk/v3 v3.74.3 h1:QHjqt1GA/f/O6kF5MNjmnSRG/sK1fmQivIFs1PeUgeo=
github.com/ydb-platform/ydb-go-sdk/v3 v3.74.3/go.mod h1:ZXl2InwGFiZKojWCsj6tW/SGFbch7zaehQWX5CjXMEI=
github.com/ydb-platform/ydb-go-yc v0.12.1 h1:qw3Fa+T81+Kpu5Io2vYHJOwcrYrVjgJlT6t/0dOXJrA=
github.com/ydb-platform/ydb-go-yc v0.12.1/go.mod h1:t/ZA4ECdgPWjAb4jyDe8AzQZB5dhpGbi3iCahFaNwBY=
github.com/ydb-platform/ydb-go-yc-metadata v0.6.1 h1:9E5q8Nsy2RiJMZDNVy0A3KUrIMBPakJ2VgloeWbcI84=
@ -803,9 +844,37 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.30.0 h1:F2t8sK4qf1fAmY9ua4ohFS/K+FUuOPemHUIXHtktrts=
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0 h1:WypxHH02KX2poqqbaadmkMYalGyy/vil4HE4PM4nRJc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.30.0/go.mod h1:U79SV99vtvGSEBeeHnpgGJfTsnsdkWLpPN/CcHAzBSI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0 h1:qFffATk0X+HD+f1Z8lswGiOQYKHRlzfmdJm0wEaVrFA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.27.0 h1:/jlt1Y8gXWiHG9FBx6cJaIC5hYx5Fe64nC8w5Cylt/0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.27.0/go.mod h1:bmToOGOBZ4hA9ghphIc1PAf66VA8KOtsuy3+ScStG20=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.27.0 h1:/0YaXu3755A/cFbtXp+21lkXgI0QE5avTWA2HjU9/WE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.27.0/go.mod h1:m7SFxp0/7IxmJPLIY3JhOcU9CoFzDaCPL6xxQIxhA+o=
go.opentelemetry.io/otel/metric v1.30.0 h1:4xNulvn9gjzo4hjg+wzIKG7iNFEaBMX00Qd4QIZs7+w=
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
go.opentelemetry.io/otel/sdk v1.30.0 h1:cHdik6irO49R5IysVhdn8oaiR9m8XluDaJAs4DfOrYE=
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
go.opentelemetry.io/otel/sdk/metric v1.30.0 h1:QJLT8Pe11jyHBHfSAgYH7kEmT24eX792jZO1bo4BXkM=
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
go.opentelemetry.io/otel/trace v1.30.0 h1:7UBkkYzeg3C7kQX8VAidWh2biiQbtAKjyIML8dQ9wmc=
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -815,7 +884,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -873,6 +943,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -930,9 +1002,10 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -977,8 +1050,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -1056,9 +1129,10 @@ golang.org/x/sys v0.4.0/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.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.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@ -1067,7 +1141,8 @@ golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1084,16 +1159,15 @@ golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.1.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -1157,6 +1231,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -1360,12 +1436,10 @@ google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ
google.golang.org/genproto v0.0.0-20230216225411-c8e22ba71e44/go.mod h1:8B0gmkoRebU8ukX6HP+4wrVQUY1+6PkQ44BSyIlflHA=
google.golang.org/genproto v0.0.0-20230222225845-10f96fb3dbec/go.mod h1:3Dl5ZL0q0isWJt+FVcfpQyirqemEuLAK/iFvg1UP1Hw=
google.golang.org/genproto v0.0.0-20230306155012-7f2fa6fef1f4/go.mod h1:NWraEVixdDnqcqQ30jipen1STv2r/n24Wb7twVTGR4s=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f h1:Vn+VyHU5guc9KjB5KrjI2q0wCOWEOIh0OEsleqakHJg=
google.golang.org/genproto v0.0.0-20231120223509-83a465c0220f/go.mod h1:nWSwAFPb+qfNJXsoeO3Io7zf4tMSfN8EA8RlDA04GhY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f h1:2yNACc1O40tTnrsbk9Cv6oxiW8pxI/pXj0wRtdlYmgY=
google.golang.org/genproto/googleapis/api v0.0.0-20231120223509-83a465c0220f/go.mod h1:Uy9bTZJqmfrw2rIBxgGLnamc78euZULUBrLZ9XTITKI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f h1:ultW7fxlIvee4HYrtnaRPon9HpEgFk5zYpmfMgtKB5I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -1406,8 +1480,8 @@ google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsA
google.golang.org/grpc v1.52.0/go.mod h1:pu6fVzoFb+NBYNAvQL08ic+lvB2IojljRYuun5vorUY=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/grpc v1.55.0/go.mod h1:iYEXKGkEBhg1PjZQvoYEVPTDkHo1/bjTnfwTeGONTY8=
google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk=
google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98=
google.golang.org/grpc v1.66.1 h1:hO5qAXR19+/Z44hmvIM4dQFMSYX9XcWsByfoxutBpAM=
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@ -1425,9 +1499,8 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -1452,13 +1525,23 @@ lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v3 v3.36.3/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
modernc.org/cc/v4 v4.21.3 h1:2mhBdWKtivdFlLR1ecKXTljPG1mfvbByX7QKztAIJl8=
modernc.org/cc/v4 v4.21.3/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
modernc.org/ccgo/v4 v4.18.1 h1:1zF5kPBFq/ZVTulBOKgQPQITdOzzyBUfC51gVYP62E4=
modernc.org/ccgo/v4 v4.18.1/go.mod h1:ao1fAxf9a2KEOL15WY8+yP3wnpaOpP/QuyFOZ9HJolM=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b h1:BnN1t+pb1cy61zbvSUV7SeI0PwosMhlAEi/vBY4qxp8=
modernc.org/gc/v3 v3.0.0-20240304020402-f0dba7c97c2b/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
@ -1467,19 +1550,34 @@ modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
modernc.org/libc v1.17.1/go.mod h1:FZ23b+8LjxZs7XtFMbSzL/EhPxNbfZbErxEHc7cbD9s=
modernc.org/libc v1.53.3 h1:9O0aSLZuHPgp49we24NoFFteRgXNLGBAQ3TODrW3XLg=
modernc.org/libc v1.53.3/go.mod h1:kb+Erju4FfHNE59xd2fNpv5CBeAeej6fHbx8p8xaiyI=
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
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.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
modernc.org/memory v1.2.1/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.18.1/go.mod h1:6ho+Gow7oX5V+OiOQ6Tr4xeqbx13UZ6t+Fw9IRUG4d4=
modernc.org/sqlite v1.30.1 h1:YFhPVfu2iIgUf9kuA1CR7iiHdcEEsI2i+yjRYHscyxk=
modernc.org/sqlite v1.30.1/go.mod h1:DUmsiWQDaAvU4abhc/N+djlom/L2o8f7gZ95RCvyoLU=
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/tcl v1.13.1/go.mod h1:XOLfOwzhkljL4itZkK6T72ckMgvj0BDsnKNdZVUOecw=
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

152
htmlexamples/core.html Normal file
View File

@ -0,0 +1,152 @@
<!doctype html>
<html lang="en">
<head>
<title>Test page</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
</head>
<body data-bs-theme="dark">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Kurious</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarNavAltMarkup">
<div class="navbar-nav">
<a class="nav-link" aria-current="page" href="/index.html">Home</a>
<a class="nav-link" href="/courses.html">Courses</a>
<a class="nav-link active" href="/core.html">About us</a>
</div>
</div>
</div>
</nav>
<div class="container">
<nav style="--bs-breadcrumb-divider: '>'" aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">Home</a></li>
<li class="breadcrumb-item" aria-current="page">
<a href="#">Course</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Theme</li>
</ol>
</nav>
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
<div class="card">
<img src="https://placehold.co/128x128" class="card-img-top" alt="" />
<div class="card-body">
<h5 class="card-title">
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
</h5>
<p class="card-text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim
omnis vero, reiciendis obcaecati perferendis excepturi nostrum
nobis itaque modi dignissimos ...
</p>
<!-- <a href="#" class="btn btn-primary">Go somewhere</a> -->
</div>
<div class="list-group">
<a href="#" class="btn btn-primary">Buy for 399.99$</a>
<small class="text-body-secondary"></small>
</div>
<div class="card-footer text-end">
<small class="text-body-secondary col">399.99$</small>
</div>
</div>
</div>
<div class="col">
<div class="card">
<img src="https://placehold.co/128x128" class="card-img-top" alt="" />
<div class="card-body">
<h5 class="card-title">
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
</h5>
<p class="card-text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim
omnis vero, reiciendis obcaecati perferendis excepturi nostrum
nobis itaque modi dignissimos ...
</p>
<a href="#" class="btn btn-primary">Go somewhere</a>
</div>
<div class="card-footer text-end">
<small class="text-body-secondary">399.99$</small>
</div>
</div>
</div>
<div class="col">
<div class="card">
<img src="https://placehold.co/128x128" class="card-img-top" alt="" />
<div class="card-body">
<h5 class="card-title">
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
</h5>
<p class="card-text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim
omnis vero, reiciendis obcaecati perferendis excepturi nostrum
nobis itaque modi dignissimos ...
</p>
<a href="#" class="btn btn-primary">Go somewhere</a>
</div>
<div class="card-footer text-end">
<small class="text-body-secondary">399.99$</small>
</div>
</div>
</div>
<div class="col">
<div class="card">
<img src="https://placehold.co/128x128" class="card-img-top" alt="" />
<div class="card-body">
<h5 class="card-title">
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
</h5>
<p class="card-text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim
omnis vero, reiciendis obcaecati perferendis excepturi nostrum
nobis itaque modi dignissimos ...
</p>
<a href="#" class="btn btn-primary">Go somewhere</a>
</div>
<div class="card-footer text-end">
<small class="text-body-secondary">399.99$</small>
</div>
</div>
</div>
<div class="col">
<div class="card">
<img src="https://placehold.co/128x128" class="card-img-top" alt="" />
<div class="card-body">
<h5 class="card-title">
Lorem, ipsum dolor sit amet consectetur adipisicing elit.
</h5>
<p class="card-text">
Lorem ipsum dolor sit amet consectetur adipisicing elit. Enim
omnis vero, reiciendis obcaecati perferendis excepturi nostrum
nobis itaque modi dignissimos ...
</p>
<a href="#" class="btn btn-primary">Go somewhere</a>
</div>
<div class="card-footer text-end">
<small class="text-body-secondary">399.99$</small>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

277
htmlexamples/courses.html Normal file
View File

@ -0,0 +1,277 @@
<!doctype html>
<html lang="en">
<head>
<title>Test page</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous"
/>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"
></script>
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body data-bs-theme="dark">
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary w-auto">
<div class="container-fluid">
<a class="navbar-brand" href="/index.html">Kurious</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarSupportedContent">
<ul class="navbar-nav mb-lg-0 mb-2 me-auto">
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/index.html"
>Home</a
>
</li>
<li class="nav-item">
<a
class="nav-link active"
aria-current="page"
href="/courses.html"
>Courses</a
>
</li>
<li class="nav-item">
<a class="nav-link" href="/core.html">About us</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<section class="row header">
<nav
class="mt-4"
style="--bs-breadcrumb-divider: &quot;>&quot;"
aria-label="breadcrumb"
>
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="#">Main</a></li>
<li class="breadcrumb-item" aria-current="page">
<a href="#">Languages</a>
</li>
<li class="breadcrumb-item active" aria-current="page">Japanese</li>
</ol>
</nav>
</section>
<section class="row filters">
<div class="col-8">
<div class="input-group">
<span class="input-group-text">Filter categories</span>
<select
class="form-select"
id="inputGroupSelect04"
aria-label="Example select with button addon"
>
<option selected>All</option>
<option value="1">Programming</option>
<option value="2">Design</option>
<option value="3">Business</option>
</select>
<select
class="form-select"
id="inputGroupSelect04"
aria-label="Example select with button addon"
>
<option selected>All</option>
<option value="1">Web development</option>
<option value="2">Backend</option>
<option value="3">Frontend</option>
</select>
<button class="btn btn-outline-secondary" type="button">
> Go
</button>
</div>
</div>
</section>
<div class="filter-content d-flex mb-3">
<!-- School list -->
<div class="p-2 col-auto">
<select
class="form-select"
id="inputGroupSelect04"
aria-label="Example select with button addon"
>
<option selected>Pick a school</option>
<option value="1">First school in the row</option>
<option value="2">Second but not the shortest named school</option>
<option value="3">Third small</option>
</select>
</div>
<!-- Sort option -->
<div class="col-auto">
<div class="input-group flex-nowrap p-2">
<select
class="form-select"
id="inputGroupSelect04"
aria-label="Example select with button addon"
>
<option selected>Sort by</option>
<option value="1">One</option>
<option value="2">Two</option>
<option value="3">Three</option>
<option value="4">Threerrrrrrrrrrrrrrrrrr</option>
</select>
<input
type="radio"
class="btn-check p-2"
name="options-base"
id="option6"
autocomplete="off"
checked
/>
<label class="btn" for="option6">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-sort-down"
viewBox="0 0 16 16"
>
<path
d="M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5M7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"
/>
</svg>
</label>
<input
type="radio"
class="btn-check p-2"
name="options-base"
id="option5"
autocomplete="off"
checked
/>
<label class="btn" for="option5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-sort-up"
viewBox="0 0 16 16"
>
<path
d="M3.5 12.5a.5.5 0 0 1-1 0V3.707L1.354 4.854a.5.5 0 1 1-.708-.708l2-1.999.007-.007a.5.5 0 0 1 .7.006l2 2a.5.5 0 1 1-.707.708L3.5 3.707zm3.5-9a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5M7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"
/>
</svg>
</label>
</div>
</div>
<!-- Spacer -->
<div class="col-4"></div>
<!-- Promocodes button -->
<div class="col-auto ms-auto p-2">
<div class="btn btn-primary">Promocodes</div>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<img
src="https://placehold.co/128x128"
class="card-img-top"
alt="..."
/>
<div class="card-body">
<h5 class="card-title">Card title with a long naming</h5>
<div class="input-group d-flex">
<a href="#" class="btn text btn-outline-primary flex-grow-1"
>Open ></a
>
<span class="input-group-text justify-content-end flex-fill"
>500$</span
>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Maybe add spacing in a better way? -->
<p class="p-2"></p>
<footer class="text-center text-lg-start bg-body-tertiary text-muted">
<section class="p-2">
<div class="container text-center text-md-start mt-4">
<div class="row mt-3">
<div class="col-md-3 col-lg-4 col-xl-3 mx-auto mb-4">
<h6 class="text-uppercase fw-bold mb-4">
<i class="fas fa-gem me-3"></i>Courses
</h6>
<p>
Welcome to Courses, your gateway to learning! Explore a diverse
range of courses and advance your skills with us. Join our
community and transform your life through education.
</p>
</div>
<div class="col-md-3 col-lg-2 col-xl-2 mx-auto mb-4">
<h6 class="text-uppercase fw-bold mb-4">Useful links</h6>
<p>
<a href="#!" class="text-reset">Pricing</a>
</p>
<p>
<a href="#!" class="text-reset">Settings</a>
</p>
<p>
<a href="#!" class="text-reset">Orders</a>
</p>
<p>
<a href="#!" class="text-reset">Help</a>
</p>
</div>
<div class="col-md-4 col-lg-3 col-xl-3 mx-auto mb-md-0 mb-4">
<h6 class="text-uppercase fw-bold mb-4">Contact</h6>
<p><i class="fas fa-home me-3"></i> New York, NY 10012, US</p>
<p>
<i class="fas fa-envelope me-3"></i>
info@example.com
</p>
<p><i class="fas fa-phone me-3"></i> + 01 234 567 88</p>
<p><i class="fas fa-print me-3"></i> + 01 234 567 89</p>
</div>
</div>
</div>
</section>
<div
class="text-center p-4"
style="background-color: rgba(0, 0, 0, 0.05)"
>
© 2024 Copyright:
<a class="text-reset fw-bold" href="https://mdbootstrap.com/"
>kursov.net</a
>
</div>
</footer>
</body>
</html>

151
htmlexamples/index.html Normal file
View File

@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<title>Test page</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="/assets/style.css" />
</head>
<body data-bs-theme="dark" style="margin: 0">
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary w-auto">
<div class="container-fluid">
<a class="navbar-brand" href="/index.html">Kurious</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse" id="navbarSupportedContent">
<ul class="navbar-nav mb-lg-0 mb-2 me-auto">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/index.html">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" aria-current="page" href="/courses.html">Courses</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/core.html">About us</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container">
<div class="row upper mb-4 text-center" style="min-height: 4rem">
<p class="justify-content-center">Here you can find course for any taste</p>
</div>
<div class="container w-75 mb-4">
<div class="row g-4">
<div class="col-12 col-md-8 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Programming</h5>
<hr/>
<p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</p>
<ul>
<li><span class="d-inline-block text-truncate col-8">web-development</span></li>
<li>backend development</li>
<li>frontend development</li>
</ul>
<p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6">
Open
</a>
<small class="text-body-secondary">128 items</small>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Programming</h5>
<hr/>
<p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</p>
<p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6">
Open
</a>
<small class="text-body-secondary">128 items</small>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Programming</h5>
<hr/>
<p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</p>
<p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6">
Open
</a>
<small class="text-body-secondary">128 items</small>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Programming</h5>
<hr/>
<p>In this category you can find courses of types such as</p>
<p>web-development, backend development, frontend developent</p>
<p>This category contains <span>128</span> courses.</p>
<div class="d-flex justify-content-between align-items-center">
<a href="#" class="btn btn-sm btn-outline-primary col-6">
Open
</a>
<small class="text-body-secondary">128 items</small>
</div>
</div>
</div>
</div>
</div>
</div>
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center">
<li class="page-item disabled">
<a class="page-link">Previous</a>
</li>
<li class="page-item active"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item">
<a class="page-link" href="#">Next</a>
</li>
</ul>
</nav>
</div>
</body>
</html>

View File

@ -11,7 +11,7 @@ import (
"time"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/pkg/slices"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/go-resty/resty/v2"
@ -31,7 +31,7 @@ type Client interface {
ListEducationalProducts(
ctx context.Context,
params ListEducationProductsParams,
) (result listEducationProductsResponse, err error)
) (result ListEducationProductsResponse, err error)
ListEducationalProductsFilterCount(
ctx context.Context,
params ListEducationProductsParams,
@ -52,8 +52,8 @@ func NewClient(ctx context.Context, log *slog.Logger, debug bool) (c *client, er
return nil, err
}
getQuerySet := func(fields []field) querySet {
items := slices.Map(fields, func(f field) string {
getQuerySet := func(fields []Field) querySet {
items := xslices.Map(fields, func(f Field) string {
return f.Value
})
@ -177,7 +177,7 @@ type listEducationProductsRequest struct {
SortDirection string `json:"sortDirection"`
}
type listEducationProductsResponse struct {
type ListEducationProductsResponse struct {
Items []Course `json:"items"`
Organizations map[string]Organization `json:"organizations"`
@ -188,7 +188,7 @@ type listEducationProductsResponse struct {
func (c *client) ListEducationalProducts(
ctx context.Context,
params ListEducationProductsParams,
) (result listEducationProductsResponse, err error) {
) (result ListEducationProductsResponse, err error) {
const urlPath = "/v1/education/products"
const defaultLimit = 1
const defaultSortProp = "advertising.position"

View File

@ -39,7 +39,7 @@ type ReduxMetadata struct {
} `json:"data"`
}
type field struct {
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
}
@ -51,7 +51,7 @@ type ReduxDictionaryContainer struct {
UserID string `json:"userId"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Fields []field `json:"fields"`
Fields []Field `json:"fields"`
}
type ReduxDictionaries struct {

View File

@ -0,0 +1,208 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package mocks
import (
context "context"
sravni "git.loyso.art/frx/kurious/internal/common/client/sravni"
mock "github.com/stretchr/testify/mock"
)
// Client is an autogenerated mock type for the Client type
type Client struct {
mock.Mock
}
type Client_Expecter struct {
mock *mock.Mock
}
func (_m *Client) EXPECT() *Client_Expecter {
return &Client_Expecter{mock: &_m.Mock}
}
// GetMainPageState provides a mock function with given fields:
func (_m *Client) GetMainPageState() (*sravni.PageState, error) {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for GetMainPageState")
}
var r0 *sravni.PageState
var r1 error
if rf, ok := ret.Get(0).(func() (*sravni.PageState, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() *sravni.PageState); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*sravni.PageState)
}
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_GetMainPageState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMainPageState'
type Client_GetMainPageState_Call struct {
*mock.Call
}
// GetMainPageState is a helper method to define mock.On call
func (_e *Client_Expecter) GetMainPageState() *Client_GetMainPageState_Call {
return &Client_GetMainPageState_Call{Call: _e.mock.On("GetMainPageState")}
}
func (_c *Client_GetMainPageState_Call) Run(run func()) *Client_GetMainPageState_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *Client_GetMainPageState_Call) Return(_a0 *sravni.PageState, _a1 error) *Client_GetMainPageState_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *Client_GetMainPageState_Call) RunAndReturn(run func() (*sravni.PageState, error)) *Client_GetMainPageState_Call {
_c.Call.Return(run)
return _c
}
// ListEducationalProducts provides a mock function with given fields: ctx, params
func (_m *Client) ListEducationalProducts(ctx context.Context, params sravni.ListEducationProductsParams) (sravni.ListEducationProductsResponse, error) {
ret := _m.Called(ctx, params)
if len(ret) == 0 {
panic("no return value specified for ListEducationalProducts")
}
var r0 sravni.ListEducationProductsResponse
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, sravni.ListEducationProductsParams) (sravni.ListEducationProductsResponse, error)); ok {
return rf(ctx, params)
}
if rf, ok := ret.Get(0).(func(context.Context, sravni.ListEducationProductsParams) sravni.ListEducationProductsResponse); ok {
r0 = rf(ctx, params)
} else {
r0 = ret.Get(0).(sravni.ListEducationProductsResponse)
}
if rf, ok := ret.Get(1).(func(context.Context, sravni.ListEducationProductsParams) error); ok {
r1 = rf(ctx, params)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_ListEducationalProducts_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListEducationalProducts'
type Client_ListEducationalProducts_Call struct {
*mock.Call
}
// ListEducationalProducts is a helper method to define mock.On call
// - ctx context.Context
// - params sravni.ListEducationProductsParams
func (_e *Client_Expecter) ListEducationalProducts(ctx interface{}, params interface{}) *Client_ListEducationalProducts_Call {
return &Client_ListEducationalProducts_Call{Call: _e.mock.On("ListEducationalProducts", ctx, params)}
}
func (_c *Client_ListEducationalProducts_Call) Run(run func(ctx context.Context, params sravni.ListEducationProductsParams)) *Client_ListEducationalProducts_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(sravni.ListEducationProductsParams))
})
return _c
}
func (_c *Client_ListEducationalProducts_Call) Return(result sravni.ListEducationProductsResponse, err error) *Client_ListEducationalProducts_Call {
_c.Call.Return(result, err)
return _c
}
func (_c *Client_ListEducationalProducts_Call) RunAndReturn(run func(context.Context, sravni.ListEducationProductsParams) (sravni.ListEducationProductsResponse, error)) *Client_ListEducationalProducts_Call {
_c.Call.Return(run)
return _c
}
// ListEducationalProductsFilterCount provides a mock function with given fields: ctx, params
func (_m *Client) ListEducationalProductsFilterCount(ctx context.Context, params sravni.ListEducationProductsParams) (sravni.ProductsFilterCount, error) {
ret := _m.Called(ctx, params)
if len(ret) == 0 {
panic("no return value specified for ListEducationalProductsFilterCount")
}
var r0 sravni.ProductsFilterCount
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, sravni.ListEducationProductsParams) (sravni.ProductsFilterCount, error)); ok {
return rf(ctx, params)
}
if rf, ok := ret.Get(0).(func(context.Context, sravni.ListEducationProductsParams) sravni.ProductsFilterCount); ok {
r0 = rf(ctx, params)
} else {
r0 = ret.Get(0).(sravni.ProductsFilterCount)
}
if rf, ok := ret.Get(1).(func(context.Context, sravni.ListEducationProductsParams) error); ok {
r1 = rf(ctx, params)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_ListEducationalProductsFilterCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListEducationalProductsFilterCount'
type Client_ListEducationalProductsFilterCount_Call struct {
*mock.Call
}
// ListEducationalProductsFilterCount is a helper method to define mock.On call
// - ctx context.Context
// - params sravni.ListEducationProductsParams
func (_e *Client_Expecter) ListEducationalProductsFilterCount(ctx interface{}, params interface{}) *Client_ListEducationalProductsFilterCount_Call {
return &Client_ListEducationalProductsFilterCount_Call{Call: _e.mock.On("ListEducationalProductsFilterCount", ctx, params)}
}
func (_c *Client_ListEducationalProductsFilterCount_Call) Run(run func(ctx context.Context, params sravni.ListEducationProductsParams)) *Client_ListEducationalProductsFilterCount_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(sravni.ListEducationProductsParams))
})
return _c
}
func (_c *Client_ListEducationalProductsFilterCount_Call) Return(result sravni.ProductsFilterCount, err error) *Client_ListEducationalProductsFilterCount_Call {
_c.Call.Return(result, err)
return _c
}
func (_c *Client_ListEducationalProductsFilterCount_Call) RunAndReturn(run func(context.Context, sravni.ListEducationProductsParams) (sravni.ProductsFilterCount, error)) *Client_ListEducationalProductsFilterCount_Call {
_c.Call.Return(run)
return _c
}
// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewClient(t interface {
mock.TestingT
Cleanup(func())
}) *Client {
mock := &Client{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -12,6 +12,6 @@ func (NoopClient) GetMainPageState() (*PageState, error) {
return nil, errors.ErrNotImplemented
}
func (NoopClient) ListEducationalProducts(context.Context, ListEducationProductsParams) (listEducationProductsResponse, error) {
return listEducationProductsResponse{}, errors.ErrNotImplemented
func (NoopClient) ListEducationalProducts(context.Context, ListEducationProductsParams) (ListEducationProductsResponse, error) {
return ListEducationProductsResponse{}, errors.ErrNotImplemented
}

View File

@ -2,4 +2,5 @@ package config
type HTTP struct {
ListenAddr string `json:"listen_addr"`
MountLive bool `json:"mount_live"`
}

View File

@ -0,0 +1,8 @@
package config
import "time"
type Sqlite struct {
DSN string `json:"dsn"`
ShutdownTimeout time.Duration `json:"shutdown_timeout"`
}

View File

@ -0,0 +1,38 @@
package config
import "errors"
type TraceClientType uint8
const (
TraceClientTypeUnset TraceClientType = iota
TraceClientTypeHTTP
TraceClientTypeGRPC
TraceClientTypeStdout
)
func (t *TraceClientType) UnmarshalText(data []byte) error {
dataStr := string(data)
switch dataStr {
case "http":
*t = TraceClientTypeHTTP
case "grpc":
*t = TraceClientTypeGRPC
case "stdout":
*t = TraceClientTypeStdout
case "":
default:
return errors.New("unsupported value " + dataStr)
}
return nil
}
type Trace struct {
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"`
APIHeader string `json:"api_header"`
Type TraceClientType `json:"type"`
ShowMetrics bool `json:"show_metrics"`
}

View File

@ -27,6 +27,7 @@ type YDB struct {
DSN string
Auth YCAuth
ShutdownDuration time.Duration
DebugYDB bool
}
func (ydb *YDB) UnmarshalJSON(data []byte) error {

View File

@ -2,11 +2,24 @@ package decorator
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
var (
nameAttribute = attribute.Key("cq.name")
argsAttribute = attribute.Key("cq.args")
apiTracer = otel.Tracer("api")
)
type commandLoggingDecorator[T any] struct {
@ -18,6 +31,17 @@ func (c commandLoggingDecorator[T]) Handle(ctx context.Context, cmd T) (err erro
handlerName := getTypeName[T]()
ctx = xcontext.WithLogFields(ctx, slog.String("handler", handlerName))
var argsBuilder strings.Builder
_ = json.NewEncoder(&argsBuilder).Encode(cmd)
var span trace.Span
ctx, span = apiTracer.Start(ctx, "command "+handlerName)
span.SetAttributes(
nameAttribute.String(handlerName),
argsAttribute.String(argsBuilder.String()),
)
xcontext.LogDebug(ctx, c.log, "executing command")
start := time.Now()
@ -27,7 +51,9 @@ func (c commandLoggingDecorator[T]) Handle(ctx context.Context, cmd T) (err erro
xcontext.LogInfo(ctx, c.log, "command executed successfuly", elapsed)
} else {
xcontext.LogError(ctx, c.log, "command execution failed", elapsed, slog.Any("err", err))
span.RecordError(err)
}
span.End()
}()
return c.base.Handle(ctx, cmd)
@ -41,6 +67,17 @@ type queryLoggingDecorator[Q, U any] struct {
func (q queryLoggingDecorator[Q, U]) Handle(ctx context.Context, query Q) (entity U, err error) {
handlerName := getTypeName[Q]()
ctx = xcontext.WithLogFields(ctx, slog.String("handler", handlerName))
var argsBuilder strings.Builder
_ = json.NewEncoder(&argsBuilder).Encode(query)
var span trace.Span
ctx, span = apiTracer.Start(ctx, "query "+handlerName)
span.SetAttributes(
nameAttribute.String(handlerName),
argsAttribute.String(argsBuilder.String()),
)
xcontext.LogDebug(ctx, q.log, "executing command")
start := time.Now()
@ -50,7 +87,10 @@ func (q queryLoggingDecorator[Q, U]) Handle(ctx context.Context, query Q) (entit
xcontext.LogInfo(ctx, q.log, "command executed successfuly", elapsed)
} else {
xcontext.LogError(ctx, q.log, "command execution failed", elapsed, slog.Any("err", err))
span.RecordError(err)
}
now := time.Now()
span.End(trace.WithTimestamp(now))
}()
return q.base.Handle(ctx, query)

View File

@ -6,6 +6,16 @@ import (
)
type ctxLogKey struct{}
type ctxRequestID struct{}
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, ctxRequestID{}, requestID)
}
func GetRequestID(ctx context.Context) string {
reqid, _ := ctx.Value(ctxRequestID{}).(string)
return reqid
}
type ctxLogAttrStore struct {
attrs []slog.Attr
@ -19,19 +29,19 @@ func WithLogFields(ctx context.Context, fields ...slog.Attr) context.Context {
}
func LogDebug(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelDebug, msg, append(attrs, getLogFields(ctx)...)...)
log.LogAttrs(ctx, slog.LevelDebug, msg, append(getLogFields(ctx), attrs...)...)
}
func LogInfo(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelInfo, msg, append(attrs, getLogFields(ctx)...)...)
log.LogAttrs(ctx, slog.LevelInfo, msg, append(getLogFields(ctx), attrs...)...)
}
func LogWarn(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelWarn, msg, append(attrs, getLogFields(ctx)...)...)
log.LogAttrs(ctx, slog.LevelWarn, msg, append(getLogFields(ctx), attrs...)...)
}
func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) {
log.LogAttrs(ctx, slog.LevelError, msg, append(attrs, getLogFields(ctx)...)...)
log.LogAttrs(ctx, slog.LevelError, msg, append(getLogFields(ctx), attrs...)...)
}
func LogWithWarnError(ctx context.Context, log *slog.Logger, err error, msg string, attrs ...slog.Attr) {

View File

@ -1,7 +0,0 @@
package xslice
func ForEach[T any](items []T, f func(T)) {
for _, item := range items {
f(item)
}
}

View File

@ -1,4 +1,4 @@
package xslice
package xslices
func Filter[T any](values []T, f func(T) bool) []T {
out := make([]T, 0, len(values))

View File

@ -1,9 +1,9 @@
package xslice_test
package xslices_test
import (
"testing"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/common/xslices"
)
func TestFilterInplace(t *testing.T) {
@ -43,7 +43,7 @@ func TestFilterInplace(t *testing.T) {
for _, tc := range tt {
tc := tc
t.Run(tc.name, func(t *testing.T) {
gotLen := xslice.FilterInplace(tc.in, tc.check)
gotLen := xslices.FilterInplace(tc.in, tc.check)
if gotLen != tc.expLen {
t.Errorf("exp %d got %d", tc.expLen, gotLen)
}

View File

@ -0,0 +1,29 @@
package xslices
import (
"crypto/rand"
"math/big"
)
func ForEach[T any](items []T, f func(T)) {
for _, item := range items {
f(item)
}
}
func AsMap[T any, U comparable](items []T, f func(T) U) map[U]struct{} {
out := make(map[U]struct{}, len(items))
ForEach(items, func(in T) {
out[f(in)] = struct{}{}
})
return out
}
func Shuffle[T any](items []T) {
maxnum := big.NewInt(int64(len(items)))
for i := range items {
swapWith, _ := rand.Int(rand.Reader, maxnum)
swapWithIdx := int(swapWith.Int64())
items[i], items[swapWithIdx] = items[swapWithIdx], items[i]
}
}

View File

@ -0,0 +1,103 @@
package xslices
import (
"sync"
)
func NewLRU[K comparable, T any](capacity int) *lru[K, T] {
return &lru[K, T]{
items: make(map[K]*lruNode[K, T], capacity),
capacity: capacity,
}
}
type lruNode[K comparable, T any] struct {
key K
value T
next *lruNode[K, T]
prev *lruNode[K, T]
}
type lru[K comparable, T any] struct {
items map[K]*lruNode[K, T]
length int
capacity int
first *lruNode[K, T]
last *lruNode[K, T]
mu sync.RWMutex
}
func (l *lru[K, T]) Push(key K, value T) {
l.mu.Lock()
defer l.mu.Unlock()
node, ok := l.items[key]
if ok {
l.bumpUnsafe(node)
return
}
node = &lruNode[K, T]{
key: key,
value: value,
next: l.first,
}
if l.first != nil {
l.first.prev = node
}
if l.last == nil {
l.last = node
}
l.first = node
l.items[key] = node
if l.length == l.capacity && l.last != nil {
deletedNode := l.last
delete(l.items, deletedNode.key)
l.last = l.last.prev
return
}
l.length++
}
func (l *lru[K, T]) Get(key K) (T, bool) {
l.mu.Lock()
defer l.mu.Unlock()
node, ok := l.items[key]
if !ok {
var t T
return t, false
}
out := node.value
l.bumpUnsafe(node)
return out, true
}
func (l *lru[K, T]) bumpUnsafe(node *lruNode[K, T]) {
if l.first == node {
return
}
if node.next != nil {
node.next.prev = node.prev
}
if node.prev != nil {
node.prev.next = node.next
}
node.next = l.first
l.first.prev = node
l.first = node
node.prev = nil
}

View File

@ -0,0 +1,23 @@
package xslices
import "testing"
func TestLRU(t *testing.T) {
lru := NewLRU[int, string](3)
for i := 0; i < 4; i++ {
lru.Push(i, "v")
}
for i := 0; i < 4; i++ {
_, found := lru.Get(i)
if i == 0 {
if found {
t.Error("expected value to be flushed out of cache")
}
continue
}
if !found {
t.Errorf("expected value %d to be found", i)
}
}
}

View File

@ -1,4 +1,4 @@
package xslice
package xslices
func Map[T, U any](in []T, f func(T) U) []U {
out := make([]U, len(in))

View File

@ -0,0 +1,40 @@
package adapters
import (
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
)
var dbTracer = otel.Tracer("adapters.db")
var (
dbSystemAttr = attribute.Key("db.system")
dbNameAttr = attribute.Key("db.name")
dbStatementAttr = attribute.Key("db.statement")
dbOperationAttr = attribute.Key("db.operation")
dbTableAttr = attribute.Key("db.sql.table")
dbArgumentsAttr = attribute.Key("db.arguments")
)
type domainer[T any] interface {
AsDomain() T
}
func asDomainFunc[T any, U domainer[T]](in U) (out T) {
return in.AsDomain()
}
func joinColumns(columns []string) string {
return strings.Join(columns, ",")
}
func namedArgColumns(columns []string) string {
out := make([]string, len(columns))
for i, col := range columns {
out[i] = ":" + col
}
return joinColumns(out)
}

View File

@ -0,0 +1,111 @@
package adapters
import (
"context"
"fmt"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type inMemoryMapper struct {
courseThematicsByID map[string]string
learningTypeByID map[string]string
stats map[string]domain.LearningTypeStat
courseThematicByLearningType map[string]string
totalCount int
}
func NewMemoryMapper(courseThematics, learningType map[string]string) *inMemoryMapper {
return &inMemoryMapper{
courseThematicsByID: courseThematics,
learningTypeByID: learningType,
}
}
func (m *inMemoryMapper) CollectCounts(ctx context.Context, cr domain.CourseRepository) error {
const batchSize = 1000
m.stats = map[string]domain.LearningTypeStat{}
m.courseThematicByLearningType = map[string]string{}
var offset int
for {
result, err := cr.List(ctx, domain.ListCoursesParams{
LearningType: "",
CourseThematic: "",
Limit: batchSize + 1,
Offset: offset,
})
if err != nil {
return fmt.Errorf("listing courses: %w", err)
}
courses := result.Courses
if len(result.Courses) >= batchSize {
courses = result.Courses[:batchSize]
}
m.totalCount += len(courses)
xslices.ForEach(courses, func(course domain.Course) {
stat, ok := m.stats[course.LearningTypeID]
stat.Count++
if !ok {
stat.CourseThematic = map[string]int{}
}
stat.CourseThematic[course.ThematicID]++
m.stats[course.LearningTypeID] = stat
m.courseThematicByLearningType[course.ThematicID] = course.LearningTypeID
})
if len(result.Courses) != batchSize+1 {
break
}
offset += batchSize
}
return nil
}
func (m *inMemoryMapper) GetCounts(byCourseThematic, byLearningType string) int {
if byCourseThematic != "" && byLearningType == "" {
byLearningType = m.courseThematicByLearningType[byCourseThematic]
}
if byLearningType != "" {
stat := m.stats[byLearningType]
if byCourseThematic != "" {
return stat.CourseThematic[byCourseThematic]
}
return stat.Count
}
return m.totalCount
}
func (m *inMemoryMapper) GetStats(copyMap bool) map[string]domain.LearningTypeStat {
if !copyMap {
return m.stats
}
out := make(map[string]domain.LearningTypeStat, len(m.stats))
for learningType, stats := range m.stats {
copiedStats := make(map[string]int, len(stats.CourseThematic))
for courseThematic, count := range stats.CourseThematic {
copiedStats[courseThematic] = count
}
out[learningType] = domain.LearningTypeStat{
Count: stats.Count,
CourseThematic: copiedStats,
}
}
return out
}
func (m *inMemoryMapper) CourseThematicNameByID(id string) string {
return m.courseThematicsByID[id]
}
func (m *inMemoryMapper) LearningTypeNameByID(id string) string {
return m.learningTypeByID[id]
}

View File

@ -0,0 +1,60 @@
package adapters
import (
"context"
"log/slog"
"os"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/migrations/sqlite"
"github.com/stretchr/testify/suite"
)
type sqliteBaseSuite struct {
suite.Suite
// TODO: make baseTestSuite that provides this kind of things
ctx context.Context
cancel context.CancelFunc
log *slog.Logger
connection *sqliteConnection
}
func (s *sqliteBaseSuite) SetupSuite() {
s.ctx, s.cancel = context.WithCancel(context.Background())
s.log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}))
connection, err := NewSqliteConnection(s.ctx, config.Sqlite{
DSN: ":memory:",
ShutdownTimeout: time.Second * 3,
}, s.log)
s.Require().NoError(err)
s.connection = connection
db := s.connection.db
err = sqlite.RunMigrations(s.ctx, db.DB, s.log.With(slog.String("component", "migrator")))
s.Require().NoError(err)
}
func (s *sqliteBaseSuite) TearDownSuite() {
s.cancel()
err := s.connection.Close()
s.Require().NoError(err)
}
func (s *sqliteBaseSuite) TearDownTest() {
db := s.connection.db
for _, query := range []string{
"DELETE FROM learning_categories",
"DELETE FROM courses",
} {
_, err := db.ExecContext(s.ctx, query)
s.Require().NoError(err, "cleaning up database")
}
}

View File

@ -0,0 +1,54 @@
package adapters
import (
"context"
"fmt"
"log/slog"
"time"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/migrations/sqlite"
"git.loyso.art/frx/kurious/pkg/xdefault"
"go.opentelemetry.io/otel/attribute"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
)
type sqliteConnection struct {
db *sqlx.DB
shutdownTimeout time.Duration
log *slog.Logger
}
func NewSqliteConnection(ctx context.Context, cfg config.Sqlite, log *slog.Logger) (*sqliteConnection, error) {
conn, err := sqlx.Open("sqlite", cfg.DSN)
if err != nil {
return nil, fmt.Errorf("openning db connection: %w", err)
}
err = sqlite.RunMigrations(ctx, conn.DB, log)
if err != nil {
return nil, fmt.Errorf("running migrations: %w", err)
}
return &sqliteConnection{
db: conn,
log: log,
shutdownTimeout: xdefault.WithFallback(cfg.ShutdownTimeout, defaultShutdownTimeout),
}, nil
}
func (c *sqliteConnection) Close() error {
_, cancel := context.WithTimeout(context.Background(), c.shutdownTimeout)
defer cancel()
return c.db.Close()
}
func getSqliteBaseAttributes() []attribute.KeyValue {
return []attribute.KeyValue{
dbSystemAttr.String("sqlite"),
dbNameAttr.String("courses"),
}
}

View File

@ -0,0 +1,565 @@
package adapters
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func (c *sqliteConnection) CourseRepository() *sqliteCourseRepository {
return &sqliteCourseRepository{
db: c.db,
log: c.log.With(slog.String("repository", "course")),
}
}
type sqliteCourseRepository struct {
db *sqlx.DB
log *slog.Logger
}
func (r *sqliteCourseRepository) List(
ctx context.Context,
params domain.ListCoursesParams,
) (result domain.ListCoursesResult, err error) {
const queryTemplate = `SELECT %s from courses WHERE 1=1`
ctx, span := dbTracer.Start(
ctx, "list courses.courses",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
query := fmt.Sprintf(queryTemplate, coursesFieldsStr)
args := make([]any, 0, 6)
if params.LearningType != "" {
args = append(args, params.LearningType)
query += " AND learning_type = ?"
}
if params.CourseThematic != "" {
args = append(args, params.CourseThematic)
query += " AND course_thematic = ?"
}
if params.OrganizationID != "" {
args = append(args, params.OrganizationID)
query += " AND organization_id = ?"
}
if params.OrderBy == "" {
params.OrderBy = "id"
}
direction := "ASC"
if !params.Ascending {
direction = "DESC"
}
query += " ORDER BY " + params.OrderBy + " " + direction
if params.Limit > 0 {
query += " LIMIT ?"
args = append(args, params.Limit)
}
if params.Offset > 0 {
query += " OFFSET ?"
args = append(args, params.Offset)
}
span.SetAttributes(dbStatementAttr.String(query))
scanF := func(s rowsScanner) (err error) {
var cdb sqliteCourseDB
err = s.StructScan(&cdb)
if err != nil {
return err
}
result.Courses = append(result.Courses, cdb.AsDomain())
return nil
}
err = scanRows(ctx, r.db, scanF, query, args...)
if err != nil {
return result, err
}
if params.Limit > 0 && len(result.Courses) == params.Limit {
lastIDx := len(result.Courses) - 1
result.NextPageToken = result.Courses[lastIDx].ID
}
result.Count, err = r.listCount(ctx, params)
if err != nil {
xcontext.LogWithWarnError(ctx, r.log, err, "unable to list count")
}
span.SetAttributes(
attribute.Int("db.items_count", len(result.Courses)),
attribute.Int("db.total_items", result.Count),
)
return result, nil
}
func (r *sqliteCourseRepository) ListLearningTypes(
ctx context.Context,
) (result domain.ListLearningTypeResult, err error) {
const query = "SELECT DISTINCT learning_type FROM courses"
ctx, span := dbTracer.Start(
ctx, "list_learning_types courses.courses",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
err = r.db.SelectContext(ctx, &result.LearningTypeIDs, query)
if err != nil {
return result, fmt.Errorf("executing query: %w", err)
}
return result, nil
}
func (r *sqliteCourseRepository) ListCourseThematics(
ctx context.Context,
params domain.ListCourseThematicsParams,
) (result domain.ListCourseThematicsResult, err error) {
const queryTemplate = "SELECT DISTINCT course_thematic FROM courses WHERE 1=1"
query := queryTemplate
args := make([]any, 0, 1)
if params.LearningTypeID != "" {
args = append(args, params.LearningTypeID)
query += " AND learning_type = ?"
}
ctx, span := dbTracer.Start(
ctx, "list courses.course_thematic",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
err = r.db.SelectContext(ctx, &result.CourseThematicIDs, query, args...)
if err != nil {
return result, fmt.Errorf("executing query: %w", err)
}
return result, nil
}
func (r *sqliteCourseRepository) ListStatistics(
ctx context.Context,
params domain.ListStatisticsParams,
) (result domain.ListStatisticsResult, err error) {
const queryTemplate = `SELECT learning_type, course_thematic, organization_id, count(id) as count` +
` FROM courses` +
` WHERE 1=1`
query := queryTemplate
args := make([]any, 0, 3)
if params.LearningTypeID != "" {
query += ` AND learning_type = ?`
args = append(args, params.LearningTypeID)
}
if params.CourseThematicID != "" {
query += ` AND course_thematic = ?`
args = append(args, params.CourseThematicID)
}
if params.OrganizaitonID != "" {
query += ` AND organization_id = ?`
args = append(args, params.OrganizaitonID)
}
query += ` GROUP BY learning_type, course_thematic, organization_id`
query += ` ORDER BY count(id) DESC`
ctx, span := dbTracer.Start(
ctx, "list courses.statistics",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
dbArgumentsAttr.StringSlice(argumentsAsStrings(args...)),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "finished")
}
span.End()
}()
var stats []sqliteCourseStatistic
err = r.db.SelectContext(ctx, &stats, query, args...)
if err != nil {
return result, fmt.Errorf("executing query: %w", err)
}
result.LearningTypeStatistics = xslices.Map(stats, asDomainFunc)
return result, nil
}
func (r *sqliteCourseRepository) Get(
ctx context.Context,
id string,
) (course domain.Course, err error) {
const queryTemplate = `SELECT %s FROM courses WHERE id = ?`
query := fmt.Sprintf(queryTemplate, coursesFieldsStr)
ctx, span := dbTracer.Start(
ctx, "get courses.courses",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
var courseDB sqliteCourseDB
err = r.db.GetContext(ctx, &courseDB, query, id)
if err != nil {
return course, fmt.Errorf("executing query: %w", err)
}
return courseDB.AsDomain(), nil
}
func (r *sqliteCourseRepository) GetByExternalID(
ctx context.Context, id string,
) (course domain.Course, err error) {
return course, errors.New("not implemented")
}
func (r *sqliteCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) error {
tx, err := r.db.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelDefault})
if err != nil {
return fmt.Errorf("beginning tx: %w", err)
}
defer func() {
var errTx error
if err != nil {
errTx = tx.Rollback()
} else {
errTx = tx.Commit()
}
err = errors.Join(err, errTx)
}()
const queryTempalate = `INSERT INTO courses` +
` (%s) VALUES (%s)`
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(coursesFields)), ",")
query := fmt.Sprintf(queryTempalate, coursesFieldsStr, placeholders)
stmt, err := tx.PrepareContext(ctx, query)
if err != nil {
return fmt.Errorf("preparing statement: %w", err)
}
for _, param := range params {
_, err := stmt.ExecContext(ctx, createCourseParamsAsValues(param)...)
if err != nil {
return fmt.Errorf("executing statement query: %w", err)
}
}
return nil
}
func (r *sqliteCourseRepository) Create(ctx context.Context, params domain.CreateCourseParams) (domain.Course, error) {
err := r.CreateBatch(ctx, params)
return domain.Course{}, err
}
func (r *sqliteCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error {
return errors.New("unimplemented")
}
func (r *sqliteCourseRepository) Delete(ctx context.Context, id string) error {
return errors.New("unimplemented")
}
func (r *sqliteCourseRepository) listCount(ctx context.Context, params domain.ListCoursesParams) (count int, err error) {
const queryTemplate = `SELECT COUNT(id) FROM courses WHERE 1=1`
query := queryTemplate
args := make([]any, 0, 6)
if params.LearningType != "" {
args = append(args, params.LearningType)
query += " AND learning_type = ?"
}
if params.CourseThematic != "" {
args = append(args, params.CourseThematic)
query += " AND course_thematic = ?"
}
if params.OrganizationID != "" {
args = append(args, params.OrganizationID)
query += " AND organization_id = ?"
}
ctx, span := dbTracer.Start(
ctx, "list_count courses.courses",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
err = r.db.GetContext(ctx, &count, query, args...)
if err != nil {
return count, fmt.Errorf("sending query: %w", err)
}
return count, nil
}
type rowsScanner interface {
sqlx.ColScanner
StructScan(dest any) error
}
func scanRows(ctx context.Context, db *sqlx.DB, f func(rowsScanner) error, query string, args ...any) error {
rows, err := db.QueryxContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("querying rows: %w", err)
}
defer func() {
err = errors.Join(err, rows.Close())
}()
for rows.Next() {
err = f(rows)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
}
if err = rows.Err(); err != nil {
return fmt.Errorf("checking rows for errors: %w", err)
}
return nil
}
func createCourseParamsAsValues(params domain.CreateCourseParams) []any {
now := time.Now()
return []any{
params.ID,
nullableValueAsString(params.ExternalID),
mapSourceTypeFromDomain(params.SourceType),
nullableValueAsString(params.SourceName),
params.CourseThematic,
params.LearningType,
params.OrganizationID,
params.OriginLink,
params.ImageLink,
params.Name,
params.Description,
params.FullPrice,
params.Discount,
params.Duration.Truncate(time.Second).Milliseconds() / 1000,
params.StartsAt,
now,
now,
sql.NullTime{},
}
}
type sqliteCourseDB struct {
ID string `db:"id"`
ExternalID sql.NullString `db:"external_id"`
SourceType string `db:"source_type"`
SourceName sql.NullString `db:"source_name"`
ThematicID string `db:"course_thematic"`
LearningTypeID string `db:"learning_type"`
OrganizationID string `db:"organization_id"`
OriginLink string `db:"origin_link"`
ImageLink string `db:"image_link"`
Name string `db:"name"`
Description string `db:"description"`
FullPrice float64 `db:"full_price"`
Discount float64 `db:"discount"`
Duration int64 `db:"duration"`
CreatedAt time.Time `db:"created_at"`
StartsAt sql.NullTime `db:"starts_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt sql.NullTime `db:"deleted_at"`
}
type sqliteCourseStatistic struct {
LearningTypeID string `db:"learning_type"`
CourseThematicID string `db:"course_thematic"`
OrganizationID string `db:"organization_id"`
Count int `db:"count"`
}
func (s sqliteCourseStatistic) AsDomain() domain.StatisticUnit {
return domain.StatisticUnit(s)
}
func nullStringAsDomain(s sql.NullString) nullable.Value[string] {
if s.Valid {
return nullable.NewValue(s.String)
}
return nullable.Value[string]{}
}
func nullTimeAsDomain(s sql.NullTime) nullable.Value[time.Time] {
if s.Valid {
return nullable.NewValue(s.Time)
}
return nullable.Value[time.Time]{}
}
func nullableValueAsString(v nullable.Value[string]) sql.NullString {
return sql.NullString{
Valid: v.Valid(),
String: v.Value(),
}
}
func nullableValueAsTime(v nullable.Value[time.Time]) sql.NullTime {
return sql.NullTime{
Valid: v.Valid(),
Time: v.Value(),
}
}
func (c sqliteCourseDB) AsDomain() domain.Course {
return domain.Course{
ID: c.ID,
OrganizationID: c.OrganizationID,
OriginLink: c.OriginLink,
ImageLink: c.ImageLink,
Name: c.Name,
Description: c.Description,
FullPrice: c.FullPrice,
Discount: c.Discount,
ThematicID: c.ThematicID,
LearningTypeID: c.LearningTypeID,
Duration: time.Second * time.Duration(c.Duration),
StartsAt: c.StartsAt.Time,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
ExternalID: nullStringAsDomain(c.ExternalID),
SourceType: mapSourceTypeToDomain(c.SourceType),
SourceName: nullStringAsDomain(c.SourceName),
DeletedAt: nullTimeAsDomain(c.DeletedAt),
}
}
func (c *sqliteCourseDB) FromDomain(d domain.Course) {
*c = sqliteCourseDB{
ID: d.ID,
OrganizationID: d.OrganizationID,
OriginLink: d.OriginLink,
ImageLink: d.ImageLink,
Name: d.Name,
Description: d.Description,
FullPrice: d.FullPrice,
Discount: d.Discount,
ThematicID: d.ThematicID,
LearningTypeID: d.LearningTypeID,
SourceType: mapSourceTypeFromDomain(d.SourceType),
Duration: d.Duration.Truncate(time.Second).Milliseconds() / 1000,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
ExternalID: nullableValueAsString(d.ExternalID),
SourceName: nullableValueAsString(d.SourceName),
DeletedAt: nullableValueAsTime(d.DeletedAt),
StartsAt: sql.NullTime{
Time: d.StartsAt,
Valid: true,
},
}
}
func (c *sqliteCourseRepository) mergeAttributes(custom ...attribute.KeyValue) []attribute.KeyValue {
outbase := append(
getSqliteBaseAttributes(),
dbTableAttr.String("courses"),
)
return append(outbase, custom...)
}
func argumentsAsStrings(args ...any) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
out = append(out, fmt.Sprintf("%v", arg))
}
return out
}

View File

@ -0,0 +1,107 @@
package adapters
import (
"strconv"
"testing"
"time"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/stretchr/testify/suite"
)
func TestSqliteCourseRepository(t *testing.T) {
suite.Run(t, new(sqliteCourseRepositorySuite))
}
type sqliteCourseRepositorySuite struct {
sqliteBaseSuite
}
func (s *sqliteCourseRepositorySuite) TestCreateCourse() {
expcourse := domain.Course{
ID: "test-id",
ExternalID: nullable.NewValue("ext-id"),
Name: "test-name",
SourceType: domain.SourceTypeParsed,
SourceName: nullable.NewValue("test-source"),
ThematicID: "test-thematic",
LearningTypeID: "test-learning",
OrganizationID: "test-org-id",
OriginLink: "test-link",
ImageLink: "test-image-link",
Description: "description",
FullPrice: 123,
Discount: 321,
Duration: time.Second * 360,
StartsAt: time.Date(2020, 10, 01, 11, 22, 33, 0, time.UTC),
Thematic: "",
LearningType: "",
CreatedAt: time.Time{},
UpdatedAt: time.Time{},
DeletedAt: nullable.Value[time.Time]{},
}
cr := s.connection.CourseRepository()
_, err := cr.Create(s.ctx, domain.CreateCourseParams{
ID: "test-id",
ExternalID: nullable.NewValue("ext-id"),
Name: "test-name",
SourceType: domain.SourceTypeParsed,
SourceName: nullable.NewValue("test-source"),
CourseThematic: "test-thematic",
LearningType: "test-learning",
OrganizationID: "test-org-id",
OriginLink: "test-link",
ImageLink: "test-image-link",
Description: "description",
FullPrice: 123,
Discount: 321,
Duration: time.Second * 360,
StartsAt: time.Date(2020, 10, 01, 11, 22, 33, 0, time.UTC),
})
s.Require().NoError(err)
gotCourse, err := cr.Get(s.ctx, expcourse.ID)
s.Require().NoError(err)
s.Require().NotEmpty(gotCourse.CreatedAt)
s.Require().NotEmpty(gotCourse.UpdatedAt)
s.Require().Empty(gotCourse.DeletedAt)
expcourse.CreatedAt = gotCourse.CreatedAt
expcourse.UpdatedAt = gotCourse.UpdatedAt
s.Require().Equal(expcourse, gotCourse)
}
func (s *sqliteCourseRepositorySuite) TestListLimitOffset() {
const coursecount = 9
const listparts = 3
basecourse := domain.CreateCourseParams{
SourceType: domain.SourceTypeManual,
}
cr := s.connection.CourseRepository()
for i := 0; i < coursecount; i++ {
basecourse.ID = strconv.Itoa(i)
_, err := cr.Create(s.ctx, basecourse)
s.NoError(err)
}
params := domain.ListCoursesParams{
Limit: coursecount / listparts,
}
for i := 0; i < listparts; i++ {
result, err := cr.List(s.ctx, params)
s.NoError(err)
s.Len(result.Courses, listparts)
params.Offset += listparts
}
result, err := cr.List(s.ctx, params)
s.NoError(err)
s.Empty(result.Courses)
}

View File

@ -0,0 +1,174 @@
package adapters
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
var (
learningCategoryColumns = []string{
"id",
"logo",
"courses_count",
}
learningCategoryColumnsStr = joinColumns(learningCategoryColumns)
)
type learningCategoryDB struct {
ID string `db:"id"`
Logo sql.NullString `db:"logo"`
CoursesCount int `db:"courses_count"`
}
func (l learningCategoryDB) AsDomain() domain.LearningCategory {
return domain.LearningCategory{
ID: l.ID,
Logo: nullStringAsDomain(l.Logo),
CoursesCount: l.CoursesCount,
}
}
func (c *sqliteConnection) LearningCategory() *sqliteLearingCategoryRepository {
return &sqliteLearingCategoryRepository{
db: c.db,
log: c.log.With("repository", "learning_categories"),
}
}
type sqliteLearingCategoryRepository struct {
db *sqlx.DB
log *slog.Logger
}
func (r *sqliteLearingCategoryRepository) Upsert(ctx context.Context, c domain.LearningCategory) (err error) {
const queryTemplate = "INSERT INTO learning_categories (%s)" +
" VALUES (%s)" +
" ON CONFLICT(id) DO UPDATE" +
" SET id = excluded.id" +
", logo = excluded.logo" +
", courses_count = excluded.courses_count"
query := fmt.Sprintf(
queryTemplate,
learningCategoryColumnsStr,
strings.TrimSuffix(strings.Repeat("?,", len(learningCategoryColumns)), ","),
)
ctx, span := dbTracer.Start(
ctx, "upsert courses.learning_categories",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("INSERT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
_, err = r.db.ExecContext(
ctx, query,
c.ID,
nullableValueAsString(c.Logo),
c.CoursesCount,
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
}
func (r *sqliteLearingCategoryRepository) List(ctx context.Context) (out []domain.LearningCategory, err error) {
const queryTemplate = "SELECT %s FROM learning_categories;"
query := fmt.Sprintf(queryTemplate, learningCategoryColumnsStr)
ctx, span := dbTracer.Start(
ctx, "list courses.learning_categories",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
var categories []learningCategoryDB
err = r.db.SelectContext(ctx, &categories, query)
if err != nil {
return nil, fmt.Errorf("executing query: %w", err)
}
out = xslices.Map(categories, asDomainFunc)
return out, nil
}
func (r *sqliteLearingCategoryRepository) Get(ctx context.Context, id string) (category domain.LearningCategory, err error) {
const queryTemplate = "SELECT %s FROM learning_categories WHERE id = ?;"
query := fmt.Sprintf(queryTemplate, learningCategoryColumnsStr)
ctx, span := dbTracer.Start(
ctx, "get courses.learning_categories",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
var cdb learningCategoryDB
err = r.db.GetContext(ctx, &cdb, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.LearningCategory{}, domain.ErrNotFound
}
return domain.LearningCategory{}, fmt.Errorf("executing query: %w", err)
}
return cdb.AsDomain(), nil
}
func (c *sqliteLearingCategoryRepository) mergeAttributes(custom ...attribute.KeyValue) []attribute.KeyValue {
outbase := append(
getSqliteBaseAttributes(),
dbTableAttr.String("learning_categories"),
)
return append(outbase, custom...)
}

View File

@ -0,0 +1,101 @@
package adapters
import (
"testing"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/stretchr/testify/suite"
)
func TestSqliteLearningCategories(t *testing.T) {
suite.Run(t, new(sqliteLearningCategoriesRepositorySuite))
}
type sqliteLearningCategoriesRepositorySuite struct {
sqliteBaseSuite
}
func (s *sqliteLearningCategoriesRepositorySuite) TestGet() {
var cdb learningCategoryDB
err := s.connection.db.GetContext(
s.ctx, &cdb,
"INSERT INTO learning_categories (id, logo, courses_count) VALUES (?,?,?) RETURNING *",
"test-id", "test-url-logo", 42,
)
s.Require().NoError(err)
expectedCategory := cdb.AsDomain()
lr := s.connection.LearningCategory()
got, err := lr.Get(s.ctx, expectedCategory.ID)
s.NoError(err)
s.Equal(expectedCategory, got)
}
func (s *sqliteLearningCategoriesRepositorySuite) TestList() {
stmt, err := s.connection.db.PrepareContext(s.ctx, "INSERT INTO learning_categories (id, logo, courses_count) VALUES (?,?,?)")
s.NoError(err)
for _, args := range [][]any{
{"test-id-1", "test-url-1", 1},
{"test-id-2", "test-url-2", 2},
} {
_, err = stmt.ExecContext(s.ctx, args...)
s.NoError(err)
}
gotCategories, err := s.connection.LearningCategory().List(s.ctx)
s.NoError(err)
s.Len(gotCategories, 2)
expCategories := []domain.LearningCategory{
{
ID: "test-id-1",
Logo: nullable.NewValue("test-url-1"),
CoursesCount: 1,
},
{
ID: "test-id-2",
Logo: nullable.NewValue("test-url-2"),
CoursesCount: 2,
},
}
if gotCategories[0].ID != "test-id-1" {
gotCategories[0], gotCategories[1] = gotCategories[1], gotCategories[0]
}
s.ElementsMatch(expCategories, gotCategories)
}
func (s *sqliteLearningCategoriesRepositorySuite) TestUpsert() {
const categoryID = "test-id-1"
repo := s.connection.LearningCategory()
gotCategory, err := repo.Get(s.ctx, categoryID)
s.ErrorIs(err, domain.ErrNotFound)
s.Empty(gotCategory)
createdCategory := domain.LearningCategory{
ID: categoryID,
Logo: nullable.NewValue("test-url-1"),
CoursesCount: 1,
}
err = repo.Upsert(s.ctx, createdCategory)
s.NoError(err)
gotCategory, err = repo.Get(s.ctx, categoryID)
s.NoError(err)
s.Equal(createdCategory, gotCategory)
createdCategory.Logo = nullable.NewValue("test-url-2")
err = repo.Upsert(s.ctx, createdCategory)
s.NoError(err)
gotCategory, err = repo.Get(s.ctx, categoryID)
s.NoError(err)
s.Equal(createdCategory, gotCategory)
}

View File

@ -0,0 +1,329 @@
package adapters
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/jmoiron/sqlx"
)
var (
organizationColumns = []string{
"id",
"external_id",
"alias",
"name",
"site",
"logo",
"created_at",
"updated_at",
"deleted_at",
}
organizationColumnsStr = joinColumns(organizationColumns)
organizationColumnsArgsStr = namedArgColumns(organizationColumns)
)
type organizationStatDB struct {
ID string `db:"id"`
ExternalID sql.NullString `db:"external_id"`
Name string `db:"name"`
CoursesCount uint64 `db:"courses_count"`
}
func (s organizationStatDB) AsDomain() domain.OrganizationStat {
return domain.OrganizationStat{
ID: s.ID,
ExternalID: nullStringAsDomain(s.ExternalID),
Name: s.Name,
CoursesCount: s.CoursesCount,
}
}
type organizationDB struct {
ID string `db:"id"`
ExternalID sql.NullString `db:"external_id"`
Alias string `db:"alias"`
Name string `db:"name"`
Site string `db:"site"`
Logo string `db:"logo"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt sql.NullTime `db:"deleted_at"`
}
func (o organizationDB) AsDomain() domain.Organization {
return domain.Organization{
ID: o.ID,
ExternalID: nullStringAsDomain(o.ExternalID),
Alias: o.Alias,
Name: o.Name,
Site: o.Site,
LogoLink: o.Logo,
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
DeletedAt: nullTimeAsDomain(o.DeletedAt),
}
}
func (o *organizationDB) FromDomain(in domain.Organization) {
*o = organizationDB{
ID: in.ID,
ExternalID: nullableValueAsString(in.ExternalID),
Alias: in.Alias,
Name: in.Name,
Site: in.Site,
Logo: in.LogoLink,
CreatedAt: in.CreatedAt,
UpdatedAt: in.UpdatedAt,
DeletedAt: nullableValueAsTime(in.DeletedAt),
}
}
func (c *sqliteConnection) Organization() domain.OrganizationRepository {
return &sqliteOrganizationRepository{
db: c.db,
log: c.log.With("repository", "organization"),
}
}
type sqliteOrganizationRepository struct {
db *sqlx.DB
log *slog.Logger
}
func (r *sqliteOrganizationRepository) ListStats(
ctx context.Context,
params domain.ListOrganizationsParams,
) (out []domain.OrganizationStat, err error) {
const queryTemplate = `WITH cte as (
SELECT learning_type, course_thematic, organization_id, count(id) as courses_count
FROM courses
WHERE 1=1 {whereSuffix}
GROUP BY learning_type, course_thematic, organization_id
) SELECT o.id as id, o.external_id as external_id, o.name as name, cte.courses_count as courses_count
FROM cte
INNER JOIN organizations o ON o.id = cte.organization_id`
whereSuffix := ""
args := make([]any, 0, 3)
if params.LearningTypeID != "" {
whereSuffix += " AND learning_type = ?"
args = append(args, params.LearningTypeID)
}
if params.CourseThematicID != "" {
whereSuffix += " AND course_thematic = ?"
args = append(args, params.CourseThematicID)
}
query := strings.Replace(queryTemplate, "{whereSuffix}", whereSuffix, 1)
ctx, span := dbTracer.Start(
ctx, "list_stats courses",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
var stats []organizationStatDB
err = r.db.SelectContext(ctx, &stats, query, args...)
if err != nil {
return nil, fmt.Errorf("executing query: %w", err)
}
return xslices.Map(stats, asDomainFunc), nil
}
func (r *sqliteOrganizationRepository) List(ctx context.Context, params domain.ListOrganizationsParams) (out []domain.Organization, err error) {
const queryTemplate = `SELECT %s FROM organizations WHERE 1=1`
query := fmt.Sprintf(queryTemplate, organizationColumnsStr)
args := make([]any, 0, len(params.IDs))
if len(params.IDs) > 0 {
args = append(
args,
xslices.Map(params.IDs, func(t string) any { return t })...,
)
queryParam := strings.TrimSuffix(strings.Repeat("?,", len(args)), ",")
query += " AND id IN (" + queryParam + ")"
}
ctx, span := dbTracer.Start(
ctx, "list courses.organizations",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
organizations := make([]organizationDB, 0, 1<<8)
err = r.db.SelectContext(ctx, &organizations, query)
if err != nil {
return nil, fmt.Errorf("executing query: %w", err)
}
return xslices.Map(organizations, asDomainFunc), nil
}
func (r *sqliteOrganizationRepository) Get(ctx context.Context, params domain.GetOrganizationParams) (out domain.Organization, err error) {
const queryTemplate = "SELECT %s FROM organizations WHERE 1=1"
query := fmt.Sprintf(queryTemplate, organizationColumnsStr)
args := make([]any, 0, 2)
if params.ID.Valid() {
args = append(args, params.ID.Value())
query += " AND id = ?"
}
if params.ExternalID.Valid() {
args = append(args, params.ExternalID.Value())
query += " AND external_id = ?"
}
ctx, span := dbTracer.Start(
ctx, "get courses.organizations",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("SELECT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
var orgdb organizationDB
err = r.db.GetContext(ctx, &orgdb, query, args...)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return out, domain.ErrNotFound
}
return out, fmt.Errorf("executing query: %w", err)
}
return orgdb.AsDomain(), nil
}
func (r *sqliteOrganizationRepository) Create(ctx context.Context, params domain.CreateOrganizationParams) (out domain.Organization, err error) {
const queryTemplate = `INSERT INTO organizations (%[1]s) VALUES (%[2]s) RETURNING %[1]s`
query := fmt.Sprintf(queryTemplate, organizationColumnsStr, organizationColumnsArgsStr)
ctx, span := dbTracer.Start(
ctx, "create courses.organizations",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("INSERT"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
stmt, err := r.db.PrepareNamedContext(ctx, query)
if err != nil {
return out, fmt.Errorf("preparing statement: %w", err)
}
var orgdb organizationDB
now := time.Now().UTC().Truncate(time.Second)
err = stmt.GetContext(ctx, &orgdb, organizationDB{
ID: params.ID,
ExternalID: nullableValueAsString(params.ExternalID),
Alias: params.Alias,
Name: params.Name,
Site: params.Site,
Logo: params.LogoLink,
CreatedAt: now,
UpdatedAt: now,
DeletedAt: sql.NullTime{},
})
if err != nil {
return out, fmt.Errorf("executing query: %w", err)
}
return orgdb.AsDomain(), nil
}
func (r *sqliteOrganizationRepository) Delete(ctx context.Context, id string) (err error) {
const query = `DELETE FROM organizations WHERE id = ?`
ctx, span := dbTracer.Start(
ctx, "delete courses.organizations",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
r.mergeAttributes(
dbOperationAttr.String("DELETE"),
dbStatementAttr.String(query),
)...,
),
)
defer func() {
if err != nil {
span.RecordError(err)
}
span.End()
}()
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrNotFound
}
return fmt.Errorf("executing query: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return domain.ErrNotFound
}
return nil
}
func (c *sqliteOrganizationRepository) mergeAttributes(custom ...attribute.KeyValue) []attribute.KeyValue {
outbase := append(
getSqliteBaseAttributes(),
dbTableAttr.String("organizaitons"),
)
return append(outbase, custom...)
}

View File

@ -0,0 +1,128 @@
package adapters
import (
"slices"
"strconv"
"testing"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"github.com/stretchr/testify/suite"
)
func TestSqilteOrganizations(t *testing.T) {
suite.Run(t, new(sqliteOrganzationRepositorySuite))
}
type sqliteOrganzationRepositorySuite struct {
sqliteBaseSuite
}
func (s *sqliteOrganzationRepositorySuite) TearDownTest() {
_ = s.connection.db.MustExecContext(s.ctx, "DELETE FROM organizations")
}
func (s *sqliteOrganzationRepositorySuite) TestList() {
const itemscount = 3
orgsdb := make([]domain.Organization, 0, itemscount)
baseOrg := domain.Organization{
Alias: "test-alias",
Name: "test-name",
Site: "test-site",
LogoLink: "test-logo",
}
for i := 0; i < itemscount; i++ {
nextitem := baseOrg
iStr := strconv.Itoa(i)
nextitem.ID = "test-id-" + iStr
nextitem.ExternalID.Set("test-ext-id-" + iStr)
gotOrg, err := s.connection.Organization().Create(s.ctx, domain.CreateOrganizationParams{
ID: nextitem.ID,
ExternalID: nextitem.ExternalID,
Alias: nextitem.Alias,
Name: nextitem.Name,
Site: nextitem.Site,
LogoLink: nextitem.LogoLink,
})
s.NoError(err)
orgsdb = append(orgsdb, gotOrg)
}
gotOrgs, err := s.connection.Organization().List(s.ctx, domain.ListOrganizationsParams{})
s.NoError(err)
compareF := func(lhs, rhs domain.Organization) int {
if lhs.ID < rhs.ID {
return -1
} else if lhs.ID > rhs.ID {
return 1
} else {
return 0
}
}
slices.SortFunc(gotOrgs, compareF)
for i := range gotOrgs {
s.NotEmpty(gotOrgs[i].CreatedAt)
s.NotEmpty(gotOrgs[i].UpdatedAt)
s.Empty(gotOrgs[i].DeletedAt)
orgsdb[i].CreatedAt = gotOrgs[i].CreatedAt
orgsdb[i].UpdatedAt = gotOrgs[i].UpdatedAt
orgsdb[i].DeletedAt = gotOrgs[i].DeletedAt
}
s.ElementsMatch(orgsdb, gotOrgs)
}
func (s *sqliteOrganzationRepositorySuite) TestGet() {
var orgdb organizationDB
err := s.connection.db.GetContext(
s.ctx, &orgdb,
`INSERT INTO organizations (`+organizationColumnsStr+`)`+
` VALUES ("test-id", "test-ext-id", "test-alias", "test-name", "test-site", "test-logo", CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)`+
` RETURNING `+organizationColumnsStr,
)
s.NoError(err)
expectedOrganization := orgdb.AsDomain()
getParams := domain.GetOrganizationParams{
ID: nullable.NewValue(orgdb.ID),
}
gotOrganization, err := s.connection.Organization().Get(s.ctx, getParams)
s.NoError(err)
s.Equal(expectedOrganization, gotOrganization)
}
func (s *sqliteOrganzationRepositorySuite) TestCreate() {
expectedOrganization := domain.Organization{
ID: "test-id",
ExternalID: nullable.NewValue("test-ext-id"),
Alias: "test-alias",
Name: "test-name",
Site: "test-site",
LogoLink: "test-logo",
}
gotOrganization, err := s.connection.Organization().Create(s.ctx, domain.CreateOrganizationParams{
ID: expectedOrganization.ID,
ExternalID: expectedOrganization.ExternalID,
Alias: expectedOrganization.Alias,
Name: expectedOrganization.Name,
Site: expectedOrganization.Site,
LogoLink: expectedOrganization.LogoLink,
})
s.NoError(err)
s.NotEmpty(gotOrganization.CreatedAt)
s.NotEmpty(gotOrganization.UpdatedAt)
s.Empty(gotOrganization.DeletedAt)
expectedOrganization.CreatedAt = gotOrganization.CreatedAt
expectedOrganization.UpdatedAt = gotOrganization.UpdatedAt
s.Equal(expectedOrganization, gotOrganization)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"os"
"path"
"strings"
"text/template"
@ -17,13 +18,38 @@ import (
"git.loyso.art/frx/kurious/pkg/xdefault"
"github.com/ydb-platform/ydb-go-sdk/v3"
ydblog "github.com/ydb-platform/ydb-go-sdk/v3/log"
"github.com/ydb-platform/ydb-go-sdk/v3/table"
"github.com/ydb-platform/ydb-go-sdk/v3/table/options"
"github.com/ydb-platform/ydb-go-sdk/v3/table/result/named"
"github.com/ydb-platform/ydb-go-sdk/v3/table/types"
"github.com/ydb-platform/ydb-go-sdk/v3/trace"
yc "github.com/ydb-platform/ydb-go-yc"
)
var coursesFields = []string{
"id",
"external_id",
"source_type",
"source_name",
"course_thematic",
"learning_type",
"organization_id",
"origin_link",
"image_link",
"name",
"description",
"full_price",
"discount",
"duration",
"starts_at",
"created_at",
"updated_at",
"deleted_at",
}
var coursesFieldsStr = strings.Join(coursesFields, ",")
const (
defaultShutdownTimeout = time.Second * 10
)
@ -36,7 +62,7 @@ type YDBConnection struct {
}
func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*YDBConnection, error) {
opts := make([]ydb.Option, 0, 2)
opts := make([]ydb.Option, 0, 3)
switch auth := cfg.Auth.(type) {
case config.YCAuthIAMToken:
opts = append(opts, ydb.WithAccessTokenCredentials(auth.Token))
@ -46,6 +72,12 @@ func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*Y
yc.WithServiceAccountKeyFileCredentials(auth.Path),
)
}
if cfg.DebugYDB {
opts = append(opts,
ydb.WithLogger(ydblog.Default(os.Stdout, ydblog.WithMinLevel(ydblog.DEBUG)), trace.DetailsAll),
)
}
db, err := ydb.Open(
ctx,
cfg.DSN,
@ -54,6 +86,14 @@ func NewYDBConnection(ctx context.Context, cfg config.YDB, log *slog.Logger) (*Y
if err != nil {
return nil, fmt.Errorf("opening connection: %w", err)
}
endpoints, err := db.Discovery().Discover(ctx)
if err != nil {
return nil, fmt.Errorf("discovering endpoints: %w", err)
}
for _, endpoint := range endpoints {
xcontext.LogInfo(ctx, log, "discovered endpoint", slog.String("value", endpoint.Address()))
}
return &YDBConnection{
Driver: db,
@ -69,6 +109,14 @@ func (conn *YDBConnection) Close() error {
return conn.Driver.Close(ctx)
}
func (conn *YDBConnection) Organization() domain.OrganizationRepository {
return domain.NotImplementedOrganizationRepository{}
}
func (conn *YDBConnection) LearningCategory() domain.LearningCategoryRepository {
return domain.NotImplementedLearningCategory{}
}
func (conn *YDBConnection) CourseRepository() *ydbCourseRepository {
return &ydbCourseRepository{
db: conn.Driver,
@ -87,59 +135,31 @@ func (r *ydbCourseRepository) List(
) (result domain.ListCoursesResult, err error) {
const limit = 1000
const queryName = "list"
// const query = `
// DECLARE $limit AS Int32;
// DECLARE $id AS Text;
// SELECT
// id,
// external_id,
// source_type,
// source_name,
// course_thematic,
// learning_type,
// organization_id,
// origin_link,
// image_link,
// name,
// description,
// full_price,
// discount,
// duration,
// starts_at,
// created_at,
// updated_at,
// deleted_at
// FROM
// courses
// WHERE
// id > $id
// ORDER BY id
// LIMIT $limit;`
//
const fields = `id, external_id, source_type, source_name, course_thematic, learning_type, organization_id, origin_link, image_link, name, description, full_price, discount, duration, starts_at, created_at, updated_at, deleted_at`
if params.Limit == 0 {
params.Limit = limit
}
qtParams := queryTemplateParams{
Fields: fields,
Fields: coursesFieldsStr,
Table: "courses",
Suffix: "ORDER BY id\nLIMIT $limit",
Declares: []queryTemplateDeclaration{{
Name: "limit",
Type: "Int32",
}, {
Name: "id",
Type: "Text",
}},
Suffix: "ORDER BY learning_type,course_thematic,id\nLIMIT $limit",
Declares: []queryTemplateDeclaration{
{
Name: "limit",
Type: "Int32",
},
{
Name: "id",
Type: "Text",
},
},
Conditions: []string{
"id > $id",
},
}
options := make([]table.ParameterOption, 0, 4)
appendParams := func(name string, value string) {
opts := make([]table.ParameterOption, 0, 4)
appendTextParam := func(name string, value string) {
if value == "" {
return
}
@ -151,18 +171,22 @@ func (r *ydbCourseRepository) List(
}
qtParams.Declares = append(qtParams.Declares, d)
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
options = append(options, table.ValueParam(d.Arg(), ydbvalue))
opts = append(opts, table.ValueParam(d.Arg(), ydbvalue))
}
appendParams("course_thematic", params.CourseThematic)
appendParams("learning_type", params.LearningType)
appendTextParam("course_thematic", params.CourseThematic)
appendTextParam("learning_type", params.LearningType)
var sb strings.Builder
err = template.Must(template.New("").Parse(queryTemplateSelect)).Execute(&sb, qtParams)
opts = append(
opts,
table.ValueParam("$limit", types.Int32Value(int32(params.Limit))),
)
query, err := qtParams.render()
if err != nil {
return result, fmt.Errorf("executing template: %w", err)
return result, fmt.Errorf("rendering query params: %w", err)
}
query := sb.String()
xcontext.LogInfo(ctx, r.log, "query prepared", slog.String("query", query), slog.String("args", tableParamOptsToString(opts...)))
courses := make([]domain.Course, 0, 1_000)
readTx := table.TxControl(
@ -171,12 +195,13 @@ func (r *ydbCourseRepository) List(
),
table.CommitTx(),
)
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
start := time.Now()
defer func() {
since := time.Since(start)
since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, r.log,
"executed query",
@ -185,53 +210,227 @@ func (r *ydbCourseRepository) List(
)
}()
var lastKnownID string
for {
queryParams := table.NewQueryParameters(
table.ValueParam("$limit", types.Int32Value(limit)),
table.ValueParam("$id", types.TextValue(lastKnownID)),
)
_, res, err := s.Execute(
ctx, readTx, query, queryParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
}
queryParams := table.NewQueryParameters(opts...)
if !res.NextResultSet(ctx) || !res.HasNextRow() {
break
}
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
lastKnownID = courses[len(courses)-1].ID
_, res, err := s.Execute(
ctx, readTx, query, queryParams,
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
courses = append(courses, mapCourseDB(cdb))
}
if err = res.Err(); err != nil {
return err
}
result.NextPageToken = courses[len(courses)-1].ID
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(courses)))
return nil
},
table.WithIdempotent())
if err != nil {
return nil, err
return domain.ListCoursesResult{}, err
}
return courses, err
result.Courses = courses
return result, err
}
func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain.Course, err error) {
const queryName = "get"
func (r *ydbCourseRepository) ListLearningTypes(
ctx context.Context,
) (result domain.ListLearningTypeResult, err error) {
const queryName = "list_learning_type"
const querySelect = `SELECT DISTINCT learning_type FROM courses;`
readTx := table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
),
table.CommitTx(),
)
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
start := time.Now()
defer func() {
since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, r.log,
"executed query",
slog.String("name", queryName),
slog.Duration("elapsed", since),
)
}()
_, res, err := s.Execute(
ctx, readTx, querySelect, table.NewQueryParameters(),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var learningTypeID string
if err = res.Scan(&learningTypeID); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.LearningTypeIDs = append(result.LearningTypeIDs, learningTypeID)
}
if err = res.Err(); err != nil {
return err
}
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.LearningTypeIDs)))
return nil
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
func (r *ydbCourseRepository) ListCourseThematics(
ctx context.Context,
params domain.ListCourseThematicsParams,
) (result domain.ListCourseThematicsResult, err error) {
const queryName = "list_course_thematics"
qtParams := queryTemplateParams{
Fields: "DISTINCT course_thematic",
Table: "courses",
Declares: []queryTemplateDeclaration{},
Conditions: []string{},
}
learningTypeValue := types.TextValue(params.LearningTypeID)
d := queryTemplateDeclaration{
Name: "learning_type",
Type: learningTypeValue.Type().String(),
}
qtParams.Declares = append(qtParams.Declares, d)
qtParams.Conditions = append(qtParams.Conditions, d.Name+"="+d.Arg())
opts := []table.ParameterOption{
table.ValueParam(d.Arg(), learningTypeValue),
}
query, err := qtParams.render()
if err != nil {
return result, fmt.Errorf("rendering query params: %w", err)
}
readTx := table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
),
table.CommitTx(),
)
err = r.db.Table().Do(
ctx,
func(ctx context.Context, s table.Session) error {
start := time.Now()
defer func() {
since := time.Since(start).Truncate(time.Millisecond)
xcontext.LogInfo(
ctx, r.log,
"executed query",
slog.String("name", queryName),
slog.Duration("elapsed", since),
)
}()
_, res, err := s.Execute(
ctx, readTx, query, table.NewQueryParameters(opts...),
options.WithCollectStatsModeNone(),
)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return nil
}
for res.NextRow() {
var courseThematicID string
if err = res.Scan(&courseThematicID); err != nil {
return fmt.Errorf("scanning row: %w", err)
}
result.CourseThematicIDs = append(result.CourseThematicIDs, courseThematicID)
}
if err = res.Err(); err != nil {
return err
}
xcontext.LogDebug(ctx, r.log, "scanned rows", slog.Int("count", len(result.CourseThematicIDs)))
return nil
},
table.WithIdempotent(),
)
if err != nil {
return result, err
}
return result, nil
}
func (r *ydbCourseRepository) Get(
ctx context.Context,
id string,
) (course domain.Course, err error) {
const queryName = "get"
const querySelect = `DECLARE $id AS Text;
SELECT
id,
external_id,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
name,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM
courses
WHERE
id = $id;`
courses := make([]domain.Course, 0, 1)
readTx := table.TxControl(
table.BeginTx(
table.WithOnlineReadOnly(),
@ -255,73 +454,79 @@ func (r *ydbCourseRepository) Get(ctx context.Context, id string) (course domain
_, res, err := s.Execute(
ctx,
readTx,
`
DECLARE $id AS Text;
SELECT
id,
external_id,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
name,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM
courses
WHERE
id = $id;
`,
querySelect,
table.NewQueryParameters(
table.ValueParam("$id", types.TextValue(id)),
),
options.WithCollectStatsModeBasic(),
)
if err != nil {
return fmt.Errorf("executing: %w", err)
return fmt.Errorf("executing query: %w", err)
}
for res.NextResultSet(ctx) {
for res.NextRow() {
var cdb courseDB
_ = res.ScanNamed(cdb.getNamedValues()...)
courses = append(courses, mapCourseDB(cdb))
if !res.NextResultSet(ctx) || !res.HasNextRow() {
return errors.ErrNotFound
}
for res.NextRow() {
var cdb courseDB
err = res.ScanNamed(cdb.getNamedValues()...)
if err != nil {
return fmt.Errorf("scanning row: %w", err)
}
course = mapCourseDB(cdb)
}
if err = res.Err(); err != nil {
return err
}
stats := res.Stats()
xcontext.LogInfo(
ctx, r.log, "query stats",
slog.String("ast", stats.QueryAST()),
slog.String("plan", stats.QueryPlan()),
slog.Duration("total_cpu_time", stats.TotalCPUTime()),
slog.Duration("total_duration", stats.TotalDuration()),
slog.Duration("process_cpu_time", stats.ProcessCPUTime()),
)
return nil
},
table.WithIdempotent())
if err != nil {
return domain.Course{}, err
}
if len(courses) == 0 {
return course, errors.ErrNotFound
}
return courses[0], err
table.WithIdempotent(),
)
return course, err
}
func (r *ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {
return domain.Course{}, nil
}
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
st := mapSourceTypeFromDomain(params.SourceType)
type updateCourseParams struct {
domain.CreateCourseParams
CreatedAt time.Time
DeletedAt nullable.Value[time.Time]
}
func updateCourseParamsAsStruct(params updateCourseParams) types.Value {
opts := createCourseParamsAsStructValues(params.CreateCourseParams)
now := time.Now()
return types.StructValue(
append(
opts[:len(opts)-3],
types.StructFieldValue("created_at", types.DatetimeValueFromTime(params.CreatedAt)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
)...,
)
}
func createCourseParamsAsStructValues(params domain.CreateCourseParams) []types.StructValueOption {
st := mapSourceTypeFromDomain(params.SourceType)
now := time.Now()
return []types.StructValueOption{
types.StructFieldValue("id", types.TextValue(params.ID)),
types.StructFieldValue("name", types.TextValue(params.Name)),
types.StructFieldValue("source_type", types.TextValue(st)),
@ -340,11 +545,16 @@ func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
types.StructFieldValue("created_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("updated_at", types.DatetimeValueFromTime(now)),
types.StructFieldValue("deleted_at", types.NullableDatetimeValue(nil)),
}
}
func createCourseParamsAsStruct(params domain.CreateCourseParams) types.Value {
return types.StructValue(
createCourseParamsAsStructValues(params)...,
)
}
func (r *ydbCourseRepository) CreateBatch(ctx context.Context, params ...domain.CreateCourseParams) error {
// -- PRAGMA TablePathPrefix("courses");
const upsertQuery = `DECLARE $courseData AS List<Struct<
id: Text,
external_id: Optional<Text>,
@ -423,6 +633,100 @@ func (r *ydbCourseRepository) Delete(ctx context.Context, id string) error {
return nil
}
func (r *ydbCourseRepository) UpdateCourseDescription(ctx context.Context, id, description string) error {
course, err := r.Get(ctx, id)
if err != nil {
return fmt.Errorf("getting course: %w", err)
}
params := updateCourseParams{
CreateCourseParams: domain.CreateCourseParams{
ID: course.ID,
ExternalID: course.ExternalID,
Name: course.Name,
SourceType: course.SourceType,
SourceName: course.SourceName,
CourseThematic: course.Thematic,
LearningType: course.LearningType,
OrganizationID: course.OrganizationID,
OriginLink: course.OriginLink,
ImageLink: course.ImageLink,
Description: description,
FullPrice: course.FullPrice,
Discount: course.Discount,
Duration: course.Duration,
StartsAt: course.StartsAt,
},
CreatedAt: course.CreatedAt,
DeletedAt: course.DeletedAt,
}
updateStruct := updateCourseParamsAsStruct(params)
const upsertQuery = `DECLARE $courseData AS List<Struct<
id: Text,
external_id: Optional<Text>,
name: Text,
source_type: Text,
source_name: Optional<Text>,
course_thematic: Text,
learning_type: Text,
organization_id: Text,
origin_link: Text,
image_link: Text,
description: Text,
full_price: Double,
discount: Double,
duration: Interval,
starts_at: Datetime,
created_at: Datetime,
updated_at: Datetime,
deleted_at: Optional<Datetime>>>;
REPLACE INTO
courses
SELECT
id,
external_id,
name,
source_type,
source_name,
course_thematic,
learning_type,
organization_id,
origin_link,
image_link,
description,
full_price,
discount,
duration,
starts_at,
created_at,
updated_at,
deleted_at
FROM AS_TABLE($courseData);`
writeTx := table.TxControl(
table.BeginTx(
table.WithSerializableReadWrite(),
),
table.CommitTx(),
)
err = r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
queryParams := table.NewQueryParameters(
table.ValueParam("$courseData", types.ListValue(updateStruct)),
)
_, _, err := s.Execute(ctx, writeTx, upsertQuery, queryParams)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
})
return err
}
func (r *ydbCourseRepository) CreateCourseTable(ctx context.Context) error {
return r.db.Table().Do(ctx, func(ctx context.Context, s table.Session) error {
return s.CreateTable(
@ -537,8 +841,8 @@ func mapCourseDB(cdb courseDB) domain.Course {
Name: cdb.Name,
SourceType: st,
SourceName: nullable.NewValuePtr(cdb.SourceName),
Thematic: cdb.CourseThematic,
LearningType: cdb.LearningType,
ThematicID: cdb.CourseThematic,
LearningTypeID: cdb.LearningType,
OrganizationID: cdb.OrganizationID,
OriginLink: cdb.OriginLink,
ImageLink: cdb.ImageLink,
@ -579,9 +883,27 @@ type queryTemplateParams struct {
Suffix string
}
const queryTemplateSelect = `
{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}}\n{{end}}
func (p queryTemplateParams) render() (string, error) {
var sb strings.Builder
sb.Grow(len(queryTemplateSelect) * 3)
err := querySelect.Execute(&sb, p)
return sb.String(), err
}
const queryTemplateSelect = `{{ range .Declares }}DECLARE ${{.Name}} AS {{.Type}};{{end}}
SELECT {{.Fields}}
FROM {{.Table}}
WHERE {{ range .Conditions }}{{.}}\n{{end}}
WHERE 1=1 {{ range .Conditions }} AND {{.}} {{ end }}
{{.Suffix}}`
var querySelect = template.Must(template.New("").Parse(queryTemplateSelect))
func tableParamOptsToString(in ...table.ParameterOption) string {
var sb strings.Builder
for _, opt := range in {
sb.WriteString(opt.Name() + ":" + opt.Value().Yql() + ";")
// sb.WriteString(opt.Name() + " (" + opt.Value().Type().String() + "); ")
}
return sb.String()
}

View File

@ -6,14 +6,24 @@ import (
)
type Commands struct {
InsertCourses command.CreateCoursesHandler
InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler
InsertCourses command.CreateCoursesHandler
InsertCourse command.CreateCourseHandler
DeleteCourse command.DeleteCourseHandler
UpdateCourseDescription command.UpdateCourseDescriptionHandler
InsertOrganization command.CreateOrganizationHandler
}
type Queries struct {
GetCourse query.GetCourseHandler
ListCourses query.ListCourseHandler
GetCourse query.GetCourseHandler
ListCourses query.ListCourseHandler
ListLearningTypes query.ListLearningTypesHandler
ListCourseThematics query.ListCourseThematicsHandler
ListCourseStatistics query.ListCoursesStatsHandler
ListOrganzations query.ListOrganizationsHandler
ListOrganizationsStats query.ListOrganizationsStatsHandler
GetOrganization query.GetOrganizationHandler
}
type Application struct {

View File

@ -8,7 +8,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
@ -78,7 +78,7 @@ func NewCreateCoursesHandler(
}
func (h createCoursesHandler) Handle(ctx context.Context, cmd CreateCourses) error {
params := xslice.Map(cmd.Courses, func(in CreateCourse) (out domain.CreateCourseParams) {
params := xslices.Map(cmd.Courses, func(in CreateCourse) (out domain.CreateCourseParams) {
return domain.CreateCourseParams(in)
})
err := h.repo.CreateBatch(ctx, params...)

View File

@ -0,0 +1,53 @@
package command
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type CreateOrganization struct {
ID string
ExternalID nullable.Value[string]
Alias string
Name string
Site string
Logo string
}
type CreateOrganizationHandler decorator.CommandHandler[CreateOrganization]
type createOrganizationHandler struct {
repo domain.OrganizationRepository
}
func NewCreateOrganizationHandler(
repo domain.OrganizationRepository,
log *slog.Logger,
) CreateOrganizationHandler {
h := createOrganizationHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h createOrganizationHandler) Handle(ctx context.Context, cmd CreateOrganization) error {
_, err := h.repo.Create(ctx, domain.CreateOrganizationParams{
ID: cmd.ID,
ExternalID: cmd.ExternalID,
Alias: cmd.Alias,
Name: cmd.Name,
Site: cmd.Site,
LogoLink: cmd.Logo,
})
if err != nil {
return fmt.Errorf("creating organization: %w", err)
}
return nil
}

View File

@ -0,0 +1,35 @@
package command
import (
"context"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type UpdateCourseDescription struct {
ID string
Description string
}
type UpdateCourseDescriptionHandler decorator.CommandHandler[UpdateCourseDescription]
type updateCourseDescriptionHandler struct {
repo domain.CourseRepository
}
func NewUpdateCourseDescriptionHandler(
repo domain.CourseRepository,
log *slog.Logger,
) UpdateCourseDescriptionHandler {
h := updateCourseDescriptionHandler{
repo: repo,
}
return decorator.ApplyCommandDecorators(h, log)
}
func (h updateCourseDescriptionHandler) Handle(ctx context.Context, cmd UpdateCourseDescription) error {
return h.repo.UpdateCourseDescription(ctx, cmd.ID, cmd.Description)
}

View File

@ -16,15 +16,18 @@ type GetCourse struct {
type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course]
type getCourseHandler struct {
repo domain.CourseRepository
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewGetCourseHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) GetCourseHandler {
h := getCourseHandler{
repo: repo,
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
@ -35,5 +38,8 @@ func (h getCourseHandler) Handle(ctx context.Context, query GetCourse) (domain.C
return domain.Course{}, fmt.Errorf("getting course: %w", err)
}
course.LearningType = h.mapper.LearningTypeNameByID(course.LearningTypeID)
course.Thematic = h.mapper.CourseThematicNameByID(course.ThematicID)
return course, nil
}

View File

@ -0,0 +1,45 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type GetOrganization struct {
ID nullable.Value[string]
ExternalID nullable.Value[string]
}
type GetOrganizationHandler decorator.QueryHandler[GetOrganization, domain.Organization]
type getOrganizationHandler struct {
repo domain.OrganizationRepository
}
func NewGetOrganizationHandler(
repo domain.OrganizationRepository,
log *slog.Logger,
) GetOrganizationHandler {
h := getOrganizationHandler{
repo: repo,
}
return decorator.AddQueryDecorators(h, log)
}
func (h getOrganizationHandler) Handle(ctx context.Context, query GetOrganization) (domain.Organization, error) {
organization, err := h.repo.Get(ctx, domain.GetOrganizationParams{
ID: query.ID,
ExternalID: query.ExternalID,
})
if err != nil {
return domain.Organization{}, fmt.Errorf("getting organization: %w", err)
}
return organization, nil
}

View File

@ -6,6 +6,7 @@ import (
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
@ -16,37 +17,73 @@ type ListCourse struct {
OrganizationID string
Keyword string
Limit int
Offset int
OrderBy string
Ascending bool
Limit int
Offset int
NextPageToken string
}
type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course]
type ListCourseHandler decorator.QueryHandler[ListCourse, domain.ListCoursesResult]
type listCourseHandler struct {
repo domain.CourseRepository
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListCourseHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) ListCourseHandler {
h := listCourseHandler{
repo: repo,
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) ([]domain.Course, error) {
courses, err := h.repo.List(ctx, domain.ListCoursesParams{
CourseThematic: query.CourseThematic,
LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
Offset: query.Offset,
})
if err != nil {
return nil, fmt.Errorf("listing courses: %w", err)
func (h listCourseHandler) Handle(ctx context.Context, query ListCourse) (out domain.ListCoursesResult, err error) {
const defaultBatchSize = 1_000
drainFull := query.Limit == 0
if !drainFull {
out.Courses = make([]domain.Course, 0, query.Limit)
} else {
query.Limit = defaultBatchSize
}
for {
result, err := h.repo.List(ctx, domain.ListCoursesParams{
CourseThematic: query.CourseThematic,
LearningType: query.LearningType,
OrganizationID: query.OrganizationID,
Limit: query.Limit,
Offset: query.Offset,
OrderBy: query.OrderBy,
Ascending: query.Ascending,
})
if err != nil {
return out, fmt.Errorf("listing courses: %w", err)
}
result.Courses = xslices.Map(result.Courses, func(in domain.Course) domain.Course {
in.LearningType = h.mapper.LearningTypeNameByID(in.LearningTypeID)
in.Thematic = h.mapper.CourseThematicNameByID(in.ThematicID)
return in
})
out.Courses = append(out.Courses, result.Courses...)
out.Count = result.Count
if drainFull && len(result.Courses) == query.Limit {
query.Offset += query.Limit
continue
}
break
}
return courses, nil
return out, nil
}

View File

@ -0,0 +1,69 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCoursesStats struct {
LearningTypeID string
CourseThematicsID string
OrganizationID string
}
type ListCoursesStatsResult struct{}
type ListCoursesStatsHandler decorator.QueryHandler[ListCoursesStats, domain.ListCoursesStatsResult]
type listCoursesStatsHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListCoursesStatsHandler(
mapper domain.CourseMapper,
repo domain.CourseRepository,
log *slog.Logger,
) ListCoursesStatsHandler {
h := listCoursesStatsHandler{
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listCoursesStatsHandler) Handle(
ctx context.Context,
query ListCoursesStats,
) (out domain.ListCoursesStatsResult, err error) {
if query.OrganizationID != "" {
statistics, err := h.repo.ListStatistics(ctx, domain.ListStatisticsParams{
LearningTypeID: query.LearningTypeID,
CourseThematicID: query.CourseThematicsID,
OrganizaitonID: query.OrganizationID,
})
if err != nil {
return out, fmt.Errorf("listing statistics: %w", err)
}
out.StatsByLearningType = make(map[string]domain.LearningTypeStat, len(statistics.LearningTypeStatistics))
for _, unit := range statistics.LearningTypeStatistics {
stats, ok := out.StatsByLearningType[unit.LearningTypeID]
stats.Count++
if !ok {
stats.CourseThematic = make(map[string]int)
}
stats.CourseThematic[unit.CourseThematicID]++
out.StatsByLearningType[unit.LearningTypeID] = stats
}
} else {
stats := h.mapper.GetStats(false)
out.StatsByLearningType = stats
}
return out, nil
}

View File

@ -0,0 +1,62 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListCourseThematics struct {
LearningTypeID string
}
type CourseThematic struct {
ID string
Name string
}
type ListCourseThematicsResult struct {
CourseThematics []CourseThematic
}
type ListCourseThematicsHandler decorator.QueryHandler[ListCourseThematics, ListCourseThematicsResult]
type listCourseThematicsHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListCourseThematicsHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) ListCourseThematicsHandler {
h := listCourseThematicsHandler{
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listCourseThematicsHandler) Handle(ctx context.Context, query ListCourseThematics) (out ListCourseThematicsResult, err error) {
result, err := h.repo.ListCourseThematics(ctx, domain.ListCourseThematicsParams{
LearningTypeID: query.LearningTypeID,
})
if err != nil {
return out, fmt.Errorf("listing course thematics from repo: %w", err)
}
out.CourseThematics = make([]CourseThematic, 0, len(result.CourseThematicIDs))
for _, ct := range result.CourseThematicIDs {
var item CourseThematic
item.ID = ct
item.Name = h.mapper.CourseThematicNameByID(ct)
out.CourseThematics = append(out.CourseThematics, item)
}
return out, nil
}

View File

@ -0,0 +1,58 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListLearningTypes struct{}
type LearningType struct {
ID string
Name string
}
type ListLearningTypesResult struct {
LearningTypes []LearningType
}
type ListLearningTypesHandler decorator.QueryHandler[ListLearningTypes, ListLearningTypesResult]
type listLearningTypesHandler struct {
repo domain.CourseRepository
mapper domain.CourseMapper
}
func NewListLearningTypesHandler(
repo domain.CourseRepository,
mapper domain.CourseMapper,
log *slog.Logger,
) ListLearningTypesHandler {
h := listLearningTypesHandler{
repo: repo,
mapper: mapper,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listLearningTypesHandler) Handle(ctx context.Context, query ListLearningTypes) (out ListLearningTypesResult, err error) {
result, err := h.repo.ListLearningTypes(ctx)
if err != nil {
return out, fmt.Errorf("listing learning types from repo: %w", err)
}
out.LearningTypes = make([]LearningType, 0, len(result.LearningTypeIDs))
for _, lt := range result.LearningTypeIDs {
var item LearningType
item.ID = lt
item.Name = h.mapper.LearningTypeNameByID(lt)
out.LearningTypes = append(out.LearningTypes, item)
}
return out, nil
}

View File

@ -0,0 +1,42 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListOrganizations struct {
IDs []string
}
type ListOrganizationsHandler decorator.QueryHandler[ListOrganizations, []domain.Organization]
type listOrganizationsHandler struct {
repo domain.OrganizationRepository
}
func NewListOrganizationsHandler(
repo domain.OrganizationRepository,
log *slog.Logger,
) ListOrganizationsHandler {
h := listOrganizationsHandler{
repo: repo,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listOrganizationsHandler) Handle(ctx context.Context, query ListOrganizations) ([]domain.Organization, error) {
organizations, err := h.repo.List(ctx, domain.ListOrganizationsParams{
IDs: query.IDs,
})
if err != nil {
return nil, fmt.Errorf("listing organizations: %w", err)
}
return organizations, nil
}

View File

@ -0,0 +1,52 @@
package query
import (
"context"
"fmt"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/decorator"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type ListOrganizationsStats struct {
LearningTypeID string
CourseThematicID string
IDs []string
}
type ListOrganizationsStatsHandler decorator.QueryHandler[
ListOrganizationsStats,
[]domain.OrganizationStat,
]
type listOrganizationsStatsHandler struct {
repo domain.OrganizationRepository
}
func NewListOrganizationsStatsHandler(
repo domain.OrganizationRepository,
log *slog.Logger,
) ListOrganizationsStatsHandler {
h := listOrganizationsStatsHandler{
repo: repo,
}
return decorator.AddQueryDecorators(h, log)
}
func (h listOrganizationsStatsHandler) Handle(
ctx context.Context,
query ListOrganizationsStats,
) ([]domain.OrganizationStat, error) {
stats, err := h.repo.ListStats(ctx, domain.ListOrganizationsParams{
LearningTypeID: query.LearningTypeID,
CourseThematicID: query.CourseThematicID,
IDs: query.IDs,
})
if err != nil {
return nil, fmt.Errorf("listing stats: %w", err)
}
return stats, nil
}

View File

@ -0,0 +1,9 @@
package domain
import "git.loyso.art/frx/kurious/internal/common/nullable"
type LearningCategory struct {
ID string
Logo nullable.Value[string]
CoursesCount int
}

View File

@ -27,9 +27,13 @@ type Course struct {
// FullPrice is a course full price without discount.
FullPrice float64
// Discount for the course.
Discount float64
Thematic string
LearningType string
Discount float64
Thematic string
ThematicID string
LearningType string
LearningTypeID string
// Duration for the course. It will be splitted in values like:
// full month / full day / full hour.

View File

@ -0,0 +1,12 @@
package domain
const (
ErrNotFound PlainError = "not found"
ErrNotImplemented PlainError = "not implemented"
)
type PlainError string
func (err PlainError) Error() string {
return string(err)
}

View File

@ -0,0 +1,12 @@
package domain
import "context"
type CourseMapper interface {
CourseThematicNameByID(string) string
LearningTypeNameByID(string) string
CollectCounts(context.Context, CourseRepository) error
GetCounts(byCourseThematic, byLearningType string) int
GetStats(copyMap bool) map[string]LearningTypeStat
}

View File

@ -0,0 +1,534 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package mocks
import (
context "context"
domain "git.loyso.art/frx/kurious/internal/kurious/domain"
mock "github.com/stretchr/testify/mock"
)
// CourseRepository is an autogenerated mock type for the CourseRepository type
type CourseRepository struct {
mock.Mock
}
type CourseRepository_Expecter struct {
mock *mock.Mock
}
func (_m *CourseRepository) EXPECT() *CourseRepository_Expecter {
return &CourseRepository_Expecter{mock: &_m.Mock}
}
// Create provides a mock function with given fields: _a0, _a1
func (_m *CourseRepository) Create(_a0 context.Context, _a1 domain.CreateCourseParams) (domain.Course, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for Create")
}
var r0 domain.Course
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, domain.CreateCourseParams) (domain.Course, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, domain.CreateCourseParams) domain.Course); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.Course)
}
if rf, ok := ret.Get(1).(func(context.Context, domain.CreateCourseParams) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
type CourseRepository_Create_Call struct {
*mock.Call
}
// Create is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.CreateCourseParams
func (_e *CourseRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *CourseRepository_Create_Call {
return &CourseRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)}
}
func (_c *CourseRepository_Create_Call) Run(run func(_a0 context.Context, _a1 domain.CreateCourseParams)) *CourseRepository_Create_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.CreateCourseParams))
})
return _c
}
func (_c *CourseRepository_Create_Call) Return(_a0 domain.Course, _a1 error) *CourseRepository_Create_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_Create_Call) RunAndReturn(run func(context.Context, domain.CreateCourseParams) (domain.Course, error)) *CourseRepository_Create_Call {
_c.Call.Return(run)
return _c
}
// CreateBatch provides a mock function with given fields: _a0, _a1
func (_m *CourseRepository) CreateBatch(_a0 context.Context, _a1 ...domain.CreateCourseParams) error {
_va := make([]interface{}, len(_a1))
for _i := range _a1 {
_va[_i] = _a1[_i]
}
var _ca []interface{}
_ca = append(_ca, _a0)
_ca = append(_ca, _va...)
ret := _m.Called(_ca...)
if len(ret) == 0 {
panic("no return value specified for CreateBatch")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, ...domain.CreateCourseParams) error); ok {
r0 = rf(_a0, _a1...)
} else {
r0 = ret.Error(0)
}
return r0
}
// CourseRepository_CreateBatch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateBatch'
type CourseRepository_CreateBatch_Call struct {
*mock.Call
}
// CreateBatch is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 ...domain.CreateCourseParams
func (_e *CourseRepository_Expecter) CreateBatch(_a0 interface{}, _a1 ...interface{}) *CourseRepository_CreateBatch_Call {
return &CourseRepository_CreateBatch_Call{Call: _e.mock.On("CreateBatch",
append([]interface{}{_a0}, _a1...)...)}
}
func (_c *CourseRepository_CreateBatch_Call) Run(run func(_a0 context.Context, _a1 ...domain.CreateCourseParams)) *CourseRepository_CreateBatch_Call {
_c.Call.Run(func(args mock.Arguments) {
variadicArgs := make([]domain.CreateCourseParams, len(args)-1)
for i, a := range args[1:] {
if a != nil {
variadicArgs[i] = a.(domain.CreateCourseParams)
}
}
run(args[0].(context.Context), variadicArgs...)
})
return _c
}
func (_c *CourseRepository_CreateBatch_Call) Return(_a0 error) *CourseRepository_CreateBatch_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *CourseRepository_CreateBatch_Call) RunAndReturn(run func(context.Context, ...domain.CreateCourseParams) error) *CourseRepository_CreateBatch_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, id
func (_m *CourseRepository) Delete(ctx context.Context, id string) error {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// CourseRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type CourseRepository_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *CourseRepository_Expecter) Delete(ctx interface{}, id interface{}) *CourseRepository_Delete_Call {
return &CourseRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
}
func (_c *CourseRepository_Delete_Call) Run(run func(ctx context.Context, id string)) *CourseRepository_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *CourseRepository_Delete_Call) Return(_a0 error) *CourseRepository_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *CourseRepository_Delete_Call) RunAndReturn(run func(context.Context, string) error) *CourseRepository_Delete_Call {
_c.Call.Return(run)
return _c
}
// Get provides a mock function with given fields: ctx, id
func (_m *CourseRepository) Get(ctx context.Context, id string) (domain.Course, error) {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 domain.Course
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (domain.Course, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) domain.Course); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Get(0).(domain.Course)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
type CourseRepository_Get_Call struct {
*mock.Call
}
// Get is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *CourseRepository_Expecter) Get(ctx interface{}, id interface{}) *CourseRepository_Get_Call {
return &CourseRepository_Get_Call{Call: _e.mock.On("Get", ctx, id)}
}
func (_c *CourseRepository_Get_Call) Run(run func(ctx context.Context, id string)) *CourseRepository_Get_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *CourseRepository_Get_Call) Return(_a0 domain.Course, _a1 error) *CourseRepository_Get_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_Get_Call) RunAndReturn(run func(context.Context, string) (domain.Course, error)) *CourseRepository_Get_Call {
_c.Call.Return(run)
return _c
}
// GetByExternalID provides a mock function with given fields: ctx, id
func (_m *CourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for GetByExternalID")
}
var r0 domain.Course
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (domain.Course, error)); ok {
return rf(ctx, id)
}
if rf, ok := ret.Get(0).(func(context.Context, string) domain.Course); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Get(0).(domain.Course)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_GetByExternalID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByExternalID'
type CourseRepository_GetByExternalID_Call struct {
*mock.Call
}
// GetByExternalID is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *CourseRepository_Expecter) GetByExternalID(ctx interface{}, id interface{}) *CourseRepository_GetByExternalID_Call {
return &CourseRepository_GetByExternalID_Call{Call: _e.mock.On("GetByExternalID", ctx, id)}
}
func (_c *CourseRepository_GetByExternalID_Call) Run(run func(ctx context.Context, id string)) *CourseRepository_GetByExternalID_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *CourseRepository_GetByExternalID_Call) Return(_a0 domain.Course, _a1 error) *CourseRepository_GetByExternalID_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_GetByExternalID_Call) RunAndReturn(run func(context.Context, string) (domain.Course, error)) *CourseRepository_GetByExternalID_Call {
_c.Call.Return(run)
return _c
}
// List provides a mock function with given fields: _a0, _a1
func (_m *CourseRepository) List(_a0 context.Context, _a1 domain.ListCoursesParams) (domain.ListCoursesResult, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 domain.ListCoursesResult
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, domain.ListCoursesParams) (domain.ListCoursesResult, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, domain.ListCoursesParams) domain.ListCoursesResult); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.ListCoursesResult)
}
if rf, ok := ret.Get(1).(func(context.Context, domain.ListCoursesParams) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
type CourseRepository_List_Call struct {
*mock.Call
}
// List is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.ListCoursesParams
func (_e *CourseRepository_Expecter) List(_a0 interface{}, _a1 interface{}) *CourseRepository_List_Call {
return &CourseRepository_List_Call{Call: _e.mock.On("List", _a0, _a1)}
}
func (_c *CourseRepository_List_Call) Run(run func(_a0 context.Context, _a1 domain.ListCoursesParams)) *CourseRepository_List_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.ListCoursesParams))
})
return _c
}
func (_c *CourseRepository_List_Call) Return(_a0 domain.ListCoursesResult, _a1 error) *CourseRepository_List_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_List_Call) RunAndReturn(run func(context.Context, domain.ListCoursesParams) (domain.ListCoursesResult, error)) *CourseRepository_List_Call {
_c.Call.Return(run)
return _c
}
// ListCourseThematics provides a mock function with given fields: _a0, _a1
func (_m *CourseRepository) ListCourseThematics(_a0 context.Context, _a1 domain.ListCourseThematicsParams) (domain.ListCourseThematicsResult, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for ListCourseThematics")
}
var r0 domain.ListCourseThematicsResult
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, domain.ListCourseThematicsParams) (domain.ListCourseThematicsResult, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, domain.ListCourseThematicsParams) domain.ListCourseThematicsResult); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.ListCourseThematicsResult)
}
if rf, ok := ret.Get(1).(func(context.Context, domain.ListCourseThematicsParams) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_ListCourseThematics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCourseThematics'
type CourseRepository_ListCourseThematics_Call struct {
*mock.Call
}
// ListCourseThematics is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.ListCourseThematicsParams
func (_e *CourseRepository_Expecter) ListCourseThematics(_a0 interface{}, _a1 interface{}) *CourseRepository_ListCourseThematics_Call {
return &CourseRepository_ListCourseThematics_Call{Call: _e.mock.On("ListCourseThematics", _a0, _a1)}
}
func (_c *CourseRepository_ListCourseThematics_Call) Run(run func(_a0 context.Context, _a1 domain.ListCourseThematicsParams)) *CourseRepository_ListCourseThematics_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.ListCourseThematicsParams))
})
return _c
}
func (_c *CourseRepository_ListCourseThematics_Call) Return(_a0 domain.ListCourseThematicsResult, _a1 error) *CourseRepository_ListCourseThematics_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_ListCourseThematics_Call) RunAndReturn(run func(context.Context, domain.ListCourseThematicsParams) (domain.ListCourseThematicsResult, error)) *CourseRepository_ListCourseThematics_Call {
_c.Call.Return(run)
return _c
}
// ListLearningTypes provides a mock function with given fields: _a0
func (_m *CourseRepository) ListLearningTypes(_a0 context.Context) (domain.ListLearningTypeResult, error) {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for ListLearningTypes")
}
var r0 domain.ListLearningTypeResult
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) (domain.ListLearningTypeResult, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(context.Context) domain.ListLearningTypeResult); ok {
r0 = rf(_a0)
} else {
r0 = ret.Get(0).(domain.ListLearningTypeResult)
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CourseRepository_ListLearningTypes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListLearningTypes'
type CourseRepository_ListLearningTypes_Call struct {
*mock.Call
}
// ListLearningTypes is a helper method to define mock.On call
// - _a0 context.Context
func (_e *CourseRepository_Expecter) ListLearningTypes(_a0 interface{}) *CourseRepository_ListLearningTypes_Call {
return &CourseRepository_ListLearningTypes_Call{Call: _e.mock.On("ListLearningTypes", _a0)}
}
func (_c *CourseRepository_ListLearningTypes_Call) Run(run func(_a0 context.Context)) *CourseRepository_ListLearningTypes_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *CourseRepository_ListLearningTypes_Call) Return(_a0 domain.ListLearningTypeResult, _a1 error) *CourseRepository_ListLearningTypes_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *CourseRepository_ListLearningTypes_Call) RunAndReturn(run func(context.Context) (domain.ListLearningTypeResult, error)) *CourseRepository_ListLearningTypes_Call {
_c.Call.Return(run)
return _c
}
// UpdateCourseDescription provides a mock function with given fields: ctx, id, description
func (_m *CourseRepository) UpdateCourseDescription(ctx context.Context, id string, description string) error {
ret := _m.Called(ctx, id, description)
if len(ret) == 0 {
panic("no return value specified for UpdateCourseDescription")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
r0 = rf(ctx, id, description)
} else {
r0 = ret.Error(0)
}
return r0
}
// CourseRepository_UpdateCourseDescription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateCourseDescription'
type CourseRepository_UpdateCourseDescription_Call struct {
*mock.Call
}
// UpdateCourseDescription is a helper method to define mock.On call
// - ctx context.Context
// - id string
// - description string
func (_e *CourseRepository_Expecter) UpdateCourseDescription(ctx interface{}, id interface{}, description interface{}) *CourseRepository_UpdateCourseDescription_Call {
return &CourseRepository_UpdateCourseDescription_Call{Call: _e.mock.On("UpdateCourseDescription", ctx, id, description)}
}
func (_c *CourseRepository_UpdateCourseDescription_Call) Run(run func(ctx context.Context, id string, description string)) *CourseRepository_UpdateCourseDescription_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(string))
})
return _c
}
func (_c *CourseRepository_UpdateCourseDescription_Call) Return(_a0 error) *CourseRepository_UpdateCourseDescription_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *CourseRepository_UpdateCourseDescription_Call) RunAndReturn(run func(context.Context, string, string) error) *CourseRepository_UpdateCourseDescription_Call {
_c.Call.Return(run)
return _c
}
// NewCourseRepository creates a new instance of CourseRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewCourseRepository(t interface {
mock.TestingT
Cleanup(func())
}) *CourseRepository {
mock := &CourseRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,199 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package mocks
import (
context "context"
domain "git.loyso.art/frx/kurious/internal/kurious/domain"
mock "github.com/stretchr/testify/mock"
)
// LearningCategoryRepository is an autogenerated mock type for the LearningCategoryRepository type
type LearningCategoryRepository struct {
mock.Mock
}
type LearningCategoryRepository_Expecter struct {
mock *mock.Mock
}
func (_m *LearningCategoryRepository) EXPECT() *LearningCategoryRepository_Expecter {
return &LearningCategoryRepository_Expecter{mock: &_m.Mock}
}
// Get provides a mock function with given fields: _a0, _a1
func (_m *LearningCategoryRepository) Get(_a0 context.Context, _a1 string) (domain.LearningCategory, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 domain.LearningCategory
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) (domain.LearningCategory, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, string) domain.LearningCategory); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.LearningCategory)
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// LearningCategoryRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
type LearningCategoryRepository_Get_Call struct {
*mock.Call
}
// Get is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 string
func (_e *LearningCategoryRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *LearningCategoryRepository_Get_Call {
return &LearningCategoryRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)}
}
func (_c *LearningCategoryRepository_Get_Call) Run(run func(_a0 context.Context, _a1 string)) *LearningCategoryRepository_Get_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *LearningCategoryRepository_Get_Call) Return(_a0 domain.LearningCategory, _a1 error) *LearningCategoryRepository_Get_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *LearningCategoryRepository_Get_Call) RunAndReturn(run func(context.Context, string) (domain.LearningCategory, error)) *LearningCategoryRepository_Get_Call {
_c.Call.Return(run)
return _c
}
// List provides a mock function with given fields: _a0
func (_m *LearningCategoryRepository) List(_a0 context.Context) ([]domain.LearningCategory, error) {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 []domain.LearningCategory
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]domain.LearningCategory, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(context.Context) []domain.LearningCategory); ok {
r0 = rf(_a0)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]domain.LearningCategory)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// LearningCategoryRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
type LearningCategoryRepository_List_Call struct {
*mock.Call
}
// List is a helper method to define mock.On call
// - _a0 context.Context
func (_e *LearningCategoryRepository_Expecter) List(_a0 interface{}) *LearningCategoryRepository_List_Call {
return &LearningCategoryRepository_List_Call{Call: _e.mock.On("List", _a0)}
}
func (_c *LearningCategoryRepository_List_Call) Run(run func(_a0 context.Context)) *LearningCategoryRepository_List_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *LearningCategoryRepository_List_Call) Return(_a0 []domain.LearningCategory, _a1 error) *LearningCategoryRepository_List_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *LearningCategoryRepository_List_Call) RunAndReturn(run func(context.Context) ([]domain.LearningCategory, error)) *LearningCategoryRepository_List_Call {
_c.Call.Return(run)
return _c
}
// Upsert provides a mock function with given fields: _a0, _a1
func (_m *LearningCategoryRepository) Upsert(_a0 context.Context, _a1 domain.LearningCategory) error {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for Upsert")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, domain.LearningCategory) error); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Error(0)
}
return r0
}
// LearningCategoryRepository_Upsert_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upsert'
type LearningCategoryRepository_Upsert_Call struct {
*mock.Call
}
// Upsert is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.LearningCategory
func (_e *LearningCategoryRepository_Expecter) Upsert(_a0 interface{}, _a1 interface{}) *LearningCategoryRepository_Upsert_Call {
return &LearningCategoryRepository_Upsert_Call{Call: _e.mock.On("Upsert", _a0, _a1)}
}
func (_c *LearningCategoryRepository_Upsert_Call) Run(run func(_a0 context.Context, _a1 domain.LearningCategory)) *LearningCategoryRepository_Upsert_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.LearningCategory))
})
return _c
}
func (_c *LearningCategoryRepository_Upsert_Call) Return(_a0 error) *LearningCategoryRepository_Upsert_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *LearningCategoryRepository_Upsert_Call) RunAndReturn(run func(context.Context, domain.LearningCategory) error) *LearningCategoryRepository_Upsert_Call {
_c.Call.Return(run)
return _c
}
// NewLearningCategoryRepository creates a new instance of LearningCategoryRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewLearningCategoryRepository(t interface {
mock.TestingT
Cleanup(func())
}) *LearningCategoryRepository {
mock := &LearningCategoryRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -0,0 +1,256 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package mocks
import (
context "context"
domain "git.loyso.art/frx/kurious/internal/kurious/domain"
mock "github.com/stretchr/testify/mock"
)
// OrganizationRepository is an autogenerated mock type for the OrganizationRepository type
type OrganizationRepository struct {
mock.Mock
}
type OrganizationRepository_Expecter struct {
mock *mock.Mock
}
func (_m *OrganizationRepository) EXPECT() *OrganizationRepository_Expecter {
return &OrganizationRepository_Expecter{mock: &_m.Mock}
}
// Create provides a mock function with given fields: _a0, _a1
func (_m *OrganizationRepository) Create(_a0 context.Context, _a1 domain.CreateOrganizationParams) (domain.Organization, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for Create")
}
var r0 domain.Organization
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, domain.CreateOrganizationParams) (domain.Organization, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, domain.CreateOrganizationParams) domain.Organization); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.Organization)
}
if rf, ok := ret.Get(1).(func(context.Context, domain.CreateOrganizationParams) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrganizationRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create'
type OrganizationRepository_Create_Call struct {
*mock.Call
}
// Create is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.CreateOrganizationParams
func (_e *OrganizationRepository_Expecter) Create(_a0 interface{}, _a1 interface{}) *OrganizationRepository_Create_Call {
return &OrganizationRepository_Create_Call{Call: _e.mock.On("Create", _a0, _a1)}
}
func (_c *OrganizationRepository_Create_Call) Run(run func(_a0 context.Context, _a1 domain.CreateOrganizationParams)) *OrganizationRepository_Create_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.CreateOrganizationParams))
})
return _c
}
func (_c *OrganizationRepository_Create_Call) Return(_a0 domain.Organization, _a1 error) *OrganizationRepository_Create_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *OrganizationRepository_Create_Call) RunAndReturn(run func(context.Context, domain.CreateOrganizationParams) (domain.Organization, error)) *OrganizationRepository_Create_Call {
_c.Call.Return(run)
return _c
}
// Delete provides a mock function with given fields: ctx, id
func (_m *OrganizationRepository) Delete(ctx context.Context, id string) error {
ret := _m.Called(ctx, id)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// OrganizationRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
type OrganizationRepository_Delete_Call struct {
*mock.Call
}
// Delete is a helper method to define mock.On call
// - ctx context.Context
// - id string
func (_e *OrganizationRepository_Expecter) Delete(ctx interface{}, id interface{}) *OrganizationRepository_Delete_Call {
return &OrganizationRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, id)}
}
func (_c *OrganizationRepository_Delete_Call) Run(run func(ctx context.Context, id string)) *OrganizationRepository_Delete_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *OrganizationRepository_Delete_Call) Return(_a0 error) *OrganizationRepository_Delete_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *OrganizationRepository_Delete_Call) RunAndReturn(run func(context.Context, string) error) *OrganizationRepository_Delete_Call {
_c.Call.Return(run)
return _c
}
// Get provides a mock function with given fields: _a0, _a1
func (_m *OrganizationRepository) Get(_a0 context.Context, _a1 domain.GetOrganizationParams) (domain.Organization, error) {
ret := _m.Called(_a0, _a1)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 domain.Organization
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, domain.GetOrganizationParams) (domain.Organization, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(context.Context, domain.GetOrganizationParams) domain.Organization); ok {
r0 = rf(_a0, _a1)
} else {
r0 = ret.Get(0).(domain.Organization)
}
if rf, ok := ret.Get(1).(func(context.Context, domain.GetOrganizationParams) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrganizationRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
type OrganizationRepository_Get_Call struct {
*mock.Call
}
// Get is a helper method to define mock.On call
// - _a0 context.Context
// - _a1 domain.GetOrganizationParams
func (_e *OrganizationRepository_Expecter) Get(_a0 interface{}, _a1 interface{}) *OrganizationRepository_Get_Call {
return &OrganizationRepository_Get_Call{Call: _e.mock.On("Get", _a0, _a1)}
}
func (_c *OrganizationRepository_Get_Call) Run(run func(_a0 context.Context, _a1 domain.GetOrganizationParams)) *OrganizationRepository_Get_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(domain.GetOrganizationParams))
})
return _c
}
func (_c *OrganizationRepository_Get_Call) Return(_a0 domain.Organization, _a1 error) *OrganizationRepository_Get_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *OrganizationRepository_Get_Call) RunAndReturn(run func(context.Context, domain.GetOrganizationParams) (domain.Organization, error)) *OrganizationRepository_Get_Call {
_c.Call.Return(run)
return _c
}
// List provides a mock function with given fields: _a0
func (_m *OrganizationRepository) List(_a0 context.Context) ([]domain.Organization, error) {
ret := _m.Called(_a0)
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 []domain.Organization
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]domain.Organization, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(context.Context) []domain.Organization); ok {
r0 = rf(_a0)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]domain.Organization)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrganizationRepository_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List'
type OrganizationRepository_List_Call struct {
*mock.Call
}
// List is a helper method to define mock.On call
// - _a0 context.Context
func (_e *OrganizationRepository_Expecter) List(_a0 interface{}) *OrganizationRepository_List_Call {
return &OrganizationRepository_List_Call{Call: _e.mock.On("List", _a0)}
}
func (_c *OrganizationRepository_List_Call) Run(run func(_a0 context.Context)) *OrganizationRepository_List_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *OrganizationRepository_List_Call) Return(_a0 []domain.Organization, _a1 error) *OrganizationRepository_List_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *OrganizationRepository_List_Call) RunAndReturn(run func(context.Context) ([]domain.Organization, error)) *OrganizationRepository_List_Call {
_c.Call.Return(run)
return _c
}
// NewOrganizationRepository creates a new instance of OrganizationRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewOrganizationRepository(t interface {
mock.TestingT
Cleanup(func())
}) *OrganizationRepository {
mock := &OrganizationRepository{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -20,3 +20,11 @@ type Organization struct {
UpdatedAt time.Time
DeletedAt nullable.Value[time.Time]
}
type OrganizationStat struct {
ID string
ExternalID nullable.Value[string]
Name string
CoursesCount uint64
}

View File

@ -12,9 +12,16 @@ type ListCoursesParams struct {
CourseThematic string
OrganizationID string
NextPageToken string
Limit int
Offset int
Limit int
Offset int
OrderBy string
Ascending bool
}
type ListStatisticsParams struct {
LearningTypeID string
CourseThematicID string
OrganizaitonID string
}
type CreateCourseParams struct {
@ -35,15 +42,51 @@ type CreateCourseParams struct {
StartsAt time.Time
}
type LearningTypeStat struct {
Count int
CourseThematic map[string]int
}
type ListCoursesStatsResult struct {
StatsByLearningType map[string]LearningTypeStat
}
type ListCoursesResult struct {
Courses []Course
NextPageToken string
Count int
}
type ListLearningTypeResult struct {
LearningTypeIDs []string
}
type ListCourseThematicsParams struct {
LearningTypeID string
}
type ListCourseThematicsResult struct {
CourseThematicIDs []string
}
type StatisticUnit struct {
LearningTypeID string
CourseThematicID string
OrganizationID string
Count int
}
type ListStatisticsResult struct {
LearningTypeStatistics []StatisticUnit
}
//go:generate mockery --name CourseRepository
type CourseRepository interface {
// List courses by specifid parameters.
List(ctx context.Context, params ListCoursesParams) (ListCoursesResult, error)
List(context.Context, ListCoursesParams) (ListCoursesResult, error)
ListLearningTypes(context.Context) (ListLearningTypeResult, error)
ListCourseThematics(context.Context, ListCourseThematicsParams) (ListCourseThematicsResult, error)
ListStatistics(context.Context, ListStatisticsParams) (ListStatisticsResult, error)
// Get course by id.
// Should return ErrNotFound in case course not found.
Get(ctx context.Context, id string) (Course, error)
@ -57,6 +100,14 @@ type CourseRepository interface {
Create(context.Context, CreateCourseParams) (Course, error)
// Delete course by id.
Delete(ctx context.Context, id string) error
// UpdateCourseDescription is a temporary method to udpate description field
UpdateCourseDescription(ctx context.Context, id string, description string) error
}
type GetOrganizationParams struct {
ID nullable.Value[string]
ExternalID nullable.Value[string]
}
type CreateOrganizationParams struct {
@ -69,9 +120,59 @@ type CreateOrganizationParams struct {
LogoLink string
}
type ListOrganizationsParams struct {
LearningTypeID string
CourseThematicID string
IDs []string
}
//go:generate mockery --name OrganizationRepository
type OrganizationRepository interface {
Get(ctx context.Context) (Organization, error)
ListStats(context.Context, ListOrganizationsParams) ([]OrganizationStat, error)
List(context.Context, ListOrganizationsParams) ([]Organization, error)
Get(context.Context, GetOrganizationParams) (Organization, error)
Create(context.Context, CreateOrganizationParams) (Organization, error)
Delete(ctx context.Context, id string) error
}
type NotImplementedOrganizationRepository struct{}
func (NotImplementedOrganizationRepository) ListStats(
context.Context,
ListOrganizationsParams,
) ([]OrganizationStat, error) {
return nil, ErrNotImplemented
}
func (NotImplementedOrganizationRepository) List(context.Context, ListOrganizationsParams) ([]Organization, error) {
return nil, ErrNotImplemented
}
func (NotImplementedOrganizationRepository) Get(context.Context, GetOrganizationParams) (Organization, error) {
return Organization{}, ErrNotImplemented
}
func (NotImplementedOrganizationRepository) Create(context.Context, CreateOrganizationParams) (Organization, error) {
return Organization{}, ErrNotImplemented
}
func (NotImplementedOrganizationRepository) Delete(ctx context.Context, id string) error {
return ErrNotImplemented
}
//go:generate mockery --name LearningCategoryRepository
type LearningCategoryRepository interface {
Upsert(context.Context, LearningCategory) error
List(context.Context) ([]LearningCategory, error)
Get(context.Context, string) (LearningCategory, error)
}
type NotImplementedLearningCategory struct{}
func (NotImplementedLearningCategory) Upsert(context.Context, LearningCategory) error {
return ErrNotImplemented
}
func (NotImplementedLearningCategory) List(context.Context) ([]LearningCategory, error) {
return nil, ErrNotImplemented
}
func (NotImplementedLearningCategory) Get(context.Context, string) (LearningCategory, error) {
return LearningCategory{}, ErrNotImplemented
}

View File

@ -14,7 +14,7 @@ import (
"git.loyso.art/frx/kurious/internal/common/generator"
"git.loyso.art/frx/kurious/internal/common/nullable"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
@ -34,8 +34,9 @@ type syncSravniHandler struct {
client sravni.Client
log *slog.Logger
knownExternalIDs map[string]struct{}
isRunning uint32
knownExternalIDs map[string]struct{}
knownOrganizationsByExternalID map[string]struct{}
isRunning uint32
}
func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
@ -83,6 +84,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
learningTypes := state.Props.InitialReduxState.Dictionaries.Data.LearningType
courses := make([]sravni.Course, 0, 1024)
buffer := make([]sravni.Course, 0, 512)
organizations := make([]sravni.Organization, 0, 256)
for _, learningType := range learningTypes.Fields {
select {
case <-ctx.Done():
@ -115,61 +117,98 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
// since count is known it might be optimized to allocate slice once per request.
for _, courseThematic := range thematics {
var filteredCourses int
var filteredOrgs int
var orgsByID map[string]sravni.Organization
buffer = buffer[:0]
buffer, err = h.loadEducationalProducts(lctx, learningType.Value, courseThematic, buffer)
buffer, orgsByID, err = h.loadEducationalProducts(lctx, learningType.Value, courseThematic, buffer)
if err != nil {
if !errors.Is(err, context.Canceled) {
xcontext.LogWithWarnError(lctx, h.log, err, "unable to load educational products", slog.Int("count", len(thematics)))
continue
if errors.Is(err, context.Canceled) {
return fmt.Errorf("loading educational products: %w", err)
}
return fmt.Errorf("loading educational products: %w", err)
xcontext.LogWithWarnError(lctx, h.log, err, "unable to load educational products", slog.Int("count", len(thematics)))
continue
}
xslice.ForEach(buffer, func(c sravni.Course) {
xslices.ForEach(buffer, func(c sravni.Course) {
// TODO: if the same course appears in different categories, it should be handled
if !h.setCourseIfNotKnown(c) {
return
}
c.Learningtype = []string{learningType.Value}
c.CourseThematics = []string{courseThematic}
courses = append(courses, c)
filteredCourses++
})
xcontext.LogInfo(lctx, h.log, "parsed subitems", slog.String("course_thematic", courseThematic), slog.Int("amount", len(buffer)))
for _, org := range orgsByID {
if !h.setOrganizationIfNotKnown(org) {
continue
}
organizations = append(organizations, org)
filteredOrgs++
}
xcontext.LogInfo(
lctx, h.log, "parsed subitems",
slog.String("course_thematic", courseThematic),
slog.Int("amount", len(buffer)),
slog.Int("new_courses", filteredCourses),
slog.Int("new_organizations", filteredOrgs),
)
}
elapsed := time.Since(start)
xcontext.LogDebug(lctx, h.log, "parsed items", slog.Duration("elapsed", elapsed), slog.Int("amount", len(courses)))
// TODO: if the same course appears in different categories, it should be handled
courses = h.filterByCache(courses)
if len(courses) == 0 {
xcontext.LogInfo(lctx, h.log, "all courses were filtered out")
continue
xcontext.LogDebug(
lctx, h.log, "filtered items",
slog.Int("courses", len(courses)),
slog.Int("organizations", len(organizations)),
)
var insertCourseSuccess bool
if len(courses) > 0 {
err = h.insertCourses(lctx, courses)
if err != nil {
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
}
insertCourseSuccess = err == nil
}
xcontext.LogDebug(lctx, h.log, "filtered items", slog.Int("amount", len(courses)))
var insertOrgsSuccess bool
if len(organizations) > 0 {
err = h.insertOrganizations(lctx, organizations)
if err != nil {
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
}
insertOrgsSuccess = err == nil
}
err = h.insertValues(lctx, courses)
elapsed = time.Since(start) - elapsed
elapsedField := slog.Duration("elapsed", elapsed)
if err != nil {
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses", elapsedField)
continue
}
xslice.ForEach(courses, func(c sravni.Course) {
h.knownExternalIDs[c.ID] = struct{}{}
})
xcontext.LogInfo(
lctx, h.log, "processed items",
lctx, h.log, "inserting finished",
elapsedField,
slog.Int("count", len(courses)),
slog.Bool("courses_insert_success", insertCourseSuccess),
slog.Bool("organization_insert_success", insertOrgsSuccess),
slog.Int("courses_count", len(courses)),
slog.Int("organizations_count", len(organizations)),
)
}
return nil
}
func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learningType, courseThematic string, buf []sravni.Course) ([]sravni.Course, error) {
func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learningType, courseThematic string, buf []sravni.Course) ([]sravni.Course, map[string]sravni.Organization, error) {
const maxDeepIteration = 10
const defaultLimit = 50
@ -177,6 +216,7 @@ func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learnin
rateLimit := rate.NewLimiter(rateStrategy, 1)
var courses []sravni.Course
var organizationsByID = make(map[string]sravni.Organization)
if buf == nil || cap(buf) == 0 {
courses = make([]sravni.Course, 0, 256)
} else {
@ -193,36 +233,50 @@ func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learnin
params.Offset = offset
response, err := h.client.ListEducationalProducts(ctx, params)
if err != nil {
return nil, fmt.Errorf("listing educational products: %w", err)
return nil, nil, fmt.Errorf("listing educational products: %w", err)
}
offset += defaultLimit
courses = append(courses, response.Items...)
for oid, org := range response.Organizations {
organizationsByID[oid] = org
}
if len(response.Items) < defaultLimit {
break
}
err = rateLimit.Wait(ctx)
if err != nil {
return courses, fmt.Errorf("waiting for limit: %w", err)
return courses, organizationsByID, fmt.Errorf("waiting for limit: %w", err)
}
}
return courses, nil
return courses, organizationsByID, nil
}
func (h *syncSravniHandler) filterByCache(courses []sravni.Course) (toInsert []sravni.Course) {
toCut := xslice.FilterInplace(courses, xslice.Not(h.isCached))
return courses[:toCut]
}
func (h *syncSravniHandler) isCached(course sravni.Course) bool {
func (h *syncSravniHandler) setCourseIfNotKnown(course sravni.Course) (set bool) {
_, ok := h.knownExternalIDs[course.ID]
return ok
if !ok {
h.knownExternalIDs[course.ID] = struct{}{}
}
return !ok
}
func (h *syncSravniHandler) insertValues(ctx context.Context, courses []sravni.Course) error {
courseParams := xslice.Map(courses, courseAsCreateCourseParams)
func (h *syncSravniHandler) setOrganizationIfNotKnown(organization sravni.Organization) bool {
_, ok := h.knownOrganizationsByExternalID[organization.ID]
if !ok {
h.knownOrganizationsByExternalID[organization.ID] = struct{}{}
}
return !ok
}
func (h *syncSravniHandler) insertCourses(ctx context.Context, courses []sravni.Course) error {
courseParams := xslices.Map(courses, courseAsCreateCourseParams)
err := h.svc.Commands.InsertCourses.Handle(ctx, command.CreateCourses{
Courses: courseParams,
})
@ -233,28 +287,92 @@ func (h *syncSravniHandler) insertValues(ctx context.Context, courses []sravni.C
return nil
}
func (h *syncSravniHandler) insertOrganizations(ctx context.Context, organizations []sravni.Organization) error {
organizationParams := xslices.Map(organizations, func(in sravni.Organization) command.CreateOrganization {
return command.CreateOrganization{
ID: generator.RandomInt64ID(),
ExternalID: nullable.NewValue(in.ID),
Alias: in.Alias,
Name: in.Name.Short,
Site: "",
Logo: in.Logotypes.Web,
}
})
for _, params := range organizationParams {
err := h.svc.Commands.InsertOrganization.Handle(ctx, params)
if err != nil {
return fmt.Errorf("inserting organization: %w", err)
}
}
return nil
}
func (h *syncSravniHandler) fillOrganizaionCaches(ctx context.Context) error {
if h.knownOrganizationsByExternalID != nil {
xcontext.LogDebug(ctx, h.log, "organization cache already filled")
return nil
}
organizations, err := h.svc.Queries.ListOrganzations.Handle(ctx, query.ListOrganizations{})
if err != nil {
return fmt.Errorf("listing organizations: %w", err)
}
withExternalID := func(in domain.Organization) bool {
return in.ExternalID.Valid()
}
getExtID := func(in domain.Organization) string {
return in.ExternalID.Value()
}
h.knownOrganizationsByExternalID = xslices.AsMap(xslices.Filter(organizations, withExternalID), getExtID)
xcontext.LogInfo(ctx, h.log, "cache filled", slog.String("kind", "organizations_by_external_id"), slog.Int("count", len(organizations)))
return nil
}
func (h *syncSravniHandler) fillCaches(ctx context.Context) error {
err := h.fillOrganizaionCaches(ctx)
if err != nil {
return err
}
err = h.fillKnownExternalIDsCache(ctx)
if err != nil {
return err
}
return nil
}
func (h *syncSravniHandler) fillKnownExternalIDsCache(ctx context.Context) error {
if h.knownExternalIDs != nil {
xcontext.LogInfo(ctx, h.log, "cache already filled")
return nil
}
courses, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{})
result, err := h.svc.Queries.ListCourses.Handle(ctx, query.ListCourse{})
if err != nil {
return fmt.Errorf("listing courses: %w", err)
}
courses := result.Courses
h.knownExternalIDs = make(map[string]struct{}, len(courses))
xslice.ForEach(courses, func(c domain.Course) {
xslices.ForEach(courses, func(c domain.Course) {
if !c.ExternalID.Valid() {
return
}
h.knownExternalIDs[c.ExternalID.Value()] = struct{}{}
})
xcontext.LogInfo(ctx, h.log, "cache filled", slog.Int("count", len(courses)))
xcontext.LogInfo(ctx, h.log, "cache filled", slog.String("kind", "courses_by_external_id"), slog.Int("count", len(courses)))
return nil
}

View File

@ -0,0 +1,21 @@
package bootstrap
templ button(title string, attributes templ.Attributes) {
<button class="button" { attributes... }>{ title }</button>
}
templ buttonRedirect(id, title string, linkTo string) {
<button
class="button"
id={ "origin-link-" + id }
>
{ title }
</button>
@onclickRedirect("origin-link-" + id, linkTo)
}
script onclickRedirect(id, to string) {
document.getElementById(id).onclick = () => {
location.href = to
}
}

View File

@ -0,0 +1,122 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.707
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func button(title string, attributes templ.Attributes) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"button\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.RenderAttributes(ctx, templ_7745c5c3_Buffer, attributes)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/common.templ`, Line: 4, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func buttonRedirect(id, title string, linkTo string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<button class=\"button\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("origin-link-" + id)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/common.templ`, Line: 10, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/common.templ`, Line: 12, Col: 9}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = onclickRedirect("origin-link-"+id, linkTo).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func onclickRedirect(id, to string) templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_onclickRedirect_5c43`,
Function: `function __templ_onclickRedirect_5c43(id, to){document.getElementById(id).onclick = () => {
location.href = to
}
}`,
Call: templ.SafeScript(`__templ_onclickRedirect_5c43`, id, to),
CallInline: templ.SafeScriptInline(`__templ_onclickRedirect_5c43`, id, to),
}
}

View File

@ -0,0 +1,236 @@
package bootstrap
templ head(title string) {
<head>
<title>{ title }</title>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous">
</script>
</head>
}
templ headerNavbar(page PageKind) {
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary w-auto">
<div class="container-fluid">
<a class="navbar-brand" href="/htmlexamples/index.html">Kurious</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a
class={ "nav-link", templ.KV("active", page == PageIndex) }
aria-current="page"
href="/"
>
Home
</a>
</li>
<li class="nav-item">
<a
class={ "nav-link", templ.KV("active", page == PageCourses) }
aria-current="page"
href="/courses"
>
Courses
</a>
</li>
<li class="nav-item">
<a
class={ "nav-link", templ.KV("active", page == PageAbout) }
href="/about"
>
About us
</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
}
templ footer() {
<footer class="text-center text-lg-start bg-body-tertiary text-muted">
<section class="p-2">
<div class="container text-center text-md-start mt-5">
<div class="row mt-3">
<div class="col-md-3 col-lg-4 col-xl-3 mx-auto mb-4">
<h6 class="text-uppercase fw-bold mb-4">
<i class="fas fa-gem me-3"></i>Courses
</h6>
<p>
Welcome to Courses, your gateway to learning! Explore a diverse
range of courses and advance your skills with us. Join our
community and transform your life through education.
</p>
</div>
<div class="col-md-3 col-lg-2 col-xl-2 mx-auto mb-4">
<h6 class="text-uppercase fw-bold mb-4">Useful links</h6>
<p>
<a href="#!" class="text-reset">Pricing</a>
</p>
<p>
<a href="#!" class="text-reset">Settings</a>
</p>
<p>
<a href="#!" class="text-reset">Orders</a>
</p>
<p>
<a href="#!" class="text-reset">Help</a>
</p>
</div>
<div class="col-md-4 col-lg-3 col-xl-3 mx-auto mb-md-0 mb-4">
<h6 class="text-uppercase fw-bold mb-4">Contact</h6>
<p><i class="fas fa-home me-3"></i> New York, NY 10012, US</p>
<p>
<i class="fas fa-envelope me-3"></i>
info@example.com
</p>
<p><i class="fas fa-phone me-3"></i> + 01 234 567 88</p>
<p><i class="fas fa-print me-3"></i> + 01 234 567 89</p>
</div>
</div>
</div>
</section>
<div class="text-center p-4" style="background-color: rgba(0, 0, 0, 0.05)">
© 2024 Copyright:
<a class="text-reset fw-bold" href="https://mdbootstrap.com/">kursov.net</a>
</div>
</footer>
}
script elementScriptsLoad() {
const loadInputValues = () => {
const lt = document.getElementById('learning-type-filter');
const ct = document.getElementById('course-thematic-filter');
const prefix = (lt !== null && lt.value !== '') ? `/courses/${lt.value}` : `/courses`;
const base_url = (ct !== null && ct.value !== '') ? `${prefix}/${ct.value}` : prefix;
const school_selector = document.getElementById('schoolSelect');
const school_id = (school_selector !== null && school_selector.value !== '') ? school_selector.value : '';
const order_by = document.getElementById('sortBySelect');
const order_by_value = (order_by !== null && order_by.value !== '') ? order_by.value : '';
const ascending = document.getElementById('sortByOrder');
params = [];
if (school_id) {
params.push(`school_id=${school_id}`);
};
if (order_by_value) {
params.push(`order_by=${order_by_value}`);
};
if (ascending && ascending.checked) {
params.push(`asc=true`);
};
const final_url = base_url + "?" + params.join("&");
return {
base_url: base_url,
final_url: final_url,
school_id: school_id,
order_by: order_by_value,
ascending: ascending,
}
};
const formFilterOnSubmit = event => {
event.preventDefault();
// const lt = document.getElementById('learning-type-filter');
// const ct = document.getElementById('course-thematic-filter');
// const prefix = (lt !== null && lt.value !== '') ? `/courses/${lt.value}` : `/courses`;
// const out = (ct !== null && ct.value !== '') ? `${prefix}/${ct.value}` : prefix;
values = loadInputValues();
const out = values.final_url;
document.location.assign(out);
return false;
};
const filterBySchool = () => {
/*
const school_selector = document.getElementById('schoolSelect');
const school_id = (school_selector !== null && school_selector.value !== '') ? school_selector.value : '';
const order_by = document.getElementById('sortBySelect');
const order_by_value = (order_by !== null && order_by.value !== '') ? order_by.value : '';
const ascending = document.getElementById('sortByOrder');
const baseUrl = `${window.location.pathname}?`;
params = [];
if (school_id) {
params.push(`school_id=${school_id}`);
};
if (order_by_value) {
params.push(`order_by=${order_by_value}`);
};
if (ascending && ascending.checked) {
params.push(`asc=true`);
};
const finalUrl = baseUrl + params.join("&");
*/
values = loadInputValues();
const finalUrl = values.final_url;
if (history.pushState) {
// history.pushState(null, null, finalUrl);
// TODO: remove once htmx implemented
window.location.assign(finalUrl);
} else {
window.location.assign(finalUrl);
};
}
document.addEventListener('DOMContentLoaded', () => {
const ff = document.getElementById('filter-form');
if (ff) ff.addEventListener('submit', formFilterOnSubmit);
const fs = document.getElementById('schoolSelect');
if (fs) fs.onchange = filterBySchool;
const ob = document.getElementById('sortBySelect');
if (ob) ob.onchange = filterBySchool;
});
}
templ root(page PageKind, _ stats) {
<!DOCTYPE html>
<html lang="ru">
@head(string(page))
<body>
@headerNavbar(page)
<div class="container">
{ children... }
</div>
@elementScriptsLoad()
</body>
@footer()
</html>
}

View File

@ -0,0 +1,331 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.707
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func head(title string) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<head><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/core.templ`, Line: 5, Col: 16}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN\" crossorigin=\"anonymous\"><link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css\"><script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\" integrity=\"sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL\" crossorigin=\"anonymous\">\n\t\t</script></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func headerNavbar(page PageKind) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<header><nav class=\"navbar navbar-expand-lg bg-body-tertiary w-auto\"><div class=\"container-fluid\"><a class=\"navbar-brand\" href=\"/htmlexamples/index.html\">Kurious</a> <button class=\"navbar-toggler\" type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#navbarSupportedContent\" aria-controls=\"navbarSupportedContent\" aria-expanded=\"false\" aria-label=\"Toggle navigation\"><span class=\"navbar-toggler-icon\"></span></button><div class=\"collapse navbar-collapse\" id=\"navbarSupportedContent\"><ul class=\"navbar-nav me-auto mb-2 mb-lg-0\"><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 = []any{"nav-link", templ.KV("active", page == PageIndex)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/core.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" aria-current=\"page\" href=\"/\">Home</a></li><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 = []any{"nav-link", templ.KV("active", page == PageCourses)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/core.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" aria-current=\"page\" href=\"/courses\">Courses</a></li><li class=\"nav-item\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 = []any{"nav-link", templ.KV("active", page == PageAbout)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/core.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" href=\"/about\">About us</a></li></ul></div></div></nav></header>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func footer() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<footer class=\"text-center text-lg-start bg-body-tertiary text-muted\"><section class=\"p-2\"><div class=\"container text-center text-md-start mt-5\"><div class=\"row mt-3\"><div class=\"col-md-3 col-lg-4 col-xl-3 mx-auto mb-4\"><h6 class=\"text-uppercase fw-bold mb-4\"><i class=\"fas fa-gem me-3\"></i>Courses</h6><p>Welcome to Courses, your gateway to learning! Explore a diverse range of courses and advance your skills with us. Join our community and transform your life through education.</p></div><div class=\"col-md-3 col-lg-2 col-xl-2 mx-auto mb-4\"><h6 class=\"text-uppercase fw-bold mb-4\">Useful links</h6><p><a href=\"#!\" class=\"text-reset\">Pricing</a></p><p><a href=\"#!\" class=\"text-reset\">Settings</a></p><p><a href=\"#!\" class=\"text-reset\">Orders</a></p><p><a href=\"#!\" class=\"text-reset\">Help</a></p></div><div class=\"col-md-4 col-lg-3 col-xl-3 mx-auto mb-md-0 mb-4\"><h6 class=\"text-uppercase fw-bold mb-4\">Contact</h6><p><i class=\"fas fa-home me-3\"></i> New York, NY 10012, US</p><p><i class=\"fas fa-envelope me-3\"></i> info@example.com</p><p><i class=\"fas fa-phone me-3\"></i> + 01 234 567 88</p><p><i class=\"fas fa-print me-3\"></i> + 01 234 567 89</p></div></div></div></section><div class=\"text-center p-4\" style=\"background-color: rgba(0, 0, 0, 0.05)\">© 2024 Copyright: <a class=\"text-reset fw-bold\" href=\"https://mdbootstrap.com/\">kursov.net</a></div></footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func elementScriptsLoad() templ.ComponentScript {
return templ.ComponentScript{
Name: `__templ_elementScriptsLoad_bfb1`,
Function: `function __templ_elementScriptsLoad_bfb1(){const loadInputValues = () => {
const lt = document.getElementById('learning-type-filter');
const ct = document.getElementById('course-thematic-filter');
const prefix = (lt !== null && lt.value !== '') ? ` + "`" + `/courses/${lt.value}` + "`" + ` : ` + "`" + `/courses` + "`" + `;
const base_url = (ct !== null && ct.value !== '') ? ` + "`" + `${prefix}/${ct.value}` + "`" + ` : prefix;
const school_selector = document.getElementById('schoolSelect');
const school_id = (school_selector !== null && school_selector.value !== '') ? school_selector.value : '';
const order_by = document.getElementById('sortBySelect');
const order_by_value = (order_by !== null && order_by.value !== '') ? order_by.value : '';
const ascending = document.getElementById('sortByOrder');
params = [];
if (school_id) {
params.push(` + "`" + `school_id=${school_id}` + "`" + `);
};
if (order_by_value) {
params.push(` + "`" + `order_by=${order_by_value}` + "`" + `);
};
if (ascending && ascending.checked) {
params.push(` + "`" + `asc=true` + "`" + `);
};
const final_url = base_url + "?" + params.join("&");
return {
base_url: base_url,
final_url: final_url,
school_id: school_id,
order_by: order_by_value,
ascending: ascending,
}
};
const formFilterOnSubmit = event => {
event.preventDefault();
// const lt = document.getElementById('learning-type-filter');
// const ct = document.getElementById('course-thematic-filter');
// const prefix = (lt !== null && lt.value !== '') ? ` + "`" + `/courses/${lt.value}` + "`" + ` : ` + "`" + `/courses` + "`" + `;
// const out = (ct !== null && ct.value !== '') ? ` + "`" + `${prefix}/${ct.value}` + "`" + ` : prefix;
values = loadInputValues();
const out = values.final_url;
document.location.assign(out);
return false;
};
const filterBySchool = () => {
/*
const school_selector = document.getElementById('schoolSelect');
const school_id = (school_selector !== null && school_selector.value !== '') ? school_selector.value : '';
const order_by = document.getElementById('sortBySelect');
const order_by_value = (order_by !== null && order_by.value !== '') ? order_by.value : '';
const ascending = document.getElementById('sortByOrder');
const baseUrl = ` + "`" + `${window.location.pathname}?` + "`" + `;
params = [];
if (school_id) {
params.push(` + "`" + `school_id=${school_id}` + "`" + `);
};
if (order_by_value) {
params.push(` + "`" + `order_by=${order_by_value}` + "`" + `);
};
if (ascending && ascending.checked) {
params.push(` + "`" + `asc=true` + "`" + `);
};
const finalUrl = baseUrl + params.join("&");
*/
values = loadInputValues();
const finalUrl = values.final_url;
if (history.pushState) {
// history.pushState(null, null, finalUrl);
// TODO: remove once htmx implemented
window.location.assign(finalUrl);
} else {
window.location.assign(finalUrl);
};
}
document.addEventListener('DOMContentLoaded', () => {
const ff = document.getElementById('filter-form');
if (ff) ff.addEventListener('submit', formFilterOnSubmit);
const fs = document.getElementById('schoolSelect');
if (fs) fs.onchange = filterBySchool;
const ob = document.getElementById('sortBySelect');
if (ob) ob.onchange = filterBySchool;
});
}`,
Call: templ.SafeScript(`__templ_elementScriptsLoad_bfb1`),
CallInline: templ.SafeScriptInline(`__templ_elementScriptsLoad_bfb1`),
}
}
func root(page PageKind, _ stats) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html lang=\"ru\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = head(string(page)).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = headerNavbar(page).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var11.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = elementScriptsLoad().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -0,0 +1,239 @@
package bootstrap
import "path"
import "strconv"
templ breadcrumbsItem(text, link string, isActive bool) {
<li class={ "breadcrumb-item", templ.KV("active", isActive) }>
if link != "" {
<a
href={ templ.URL(link) }
itemprop="url"
aria-label="breadcrumb"
>{ text }</a>
} else {
<span itemprop="url" itemprop="title">{ text }</span>
}
</li>
}
templ breadcrumNode(params BreadcrumbsParams) {
<nav
class={ "mt-4", breadcrumbSymbol() }
aria-label="breadcrumbs"
itemprop="breadcrumb"
itemtype="https://schema.org/BreadcrumbList"
itemscope
>
<ol class="breadcrumb">
@breadcrumbsItem("Курсы", "/courses", params.ActiveLearningType.Empty())
if !params.ActiveLearningType.Empty() {
@breadcrumbsItem(
params.ActiveLearningType.Name,
path.Join("/", "courses", params.ActiveLearningType.ID),
params.ActiveCourseThematic.Empty(),
)
}
if !params.ActiveCourseThematic.Empty() {
@breadcrumbsItem(
params.ActiveCourseThematic.Name,
path.Join("/", "courses", params.ActiveLearningType.ID, params.ActiveCourseThematic.ID),
true,
)
}
</ol>
</nav>
}
css breadcrumbSymbol() {
--bs-breadcrumb-divider: "/";
}
templ listCoursesSectionHeader(params BreadcrumbsParams) {
<section class="row bc-header">
@breadcrumNode(params)
</section>
}
templ listCoursesSectionFilters(params FilterFormParams) {
<section class="row filters">
<div
class={
templ.KV("visually-hidden", !params.Render),
"p-2",
}
>
<form id="filter-form" class="input-group">
<span class="input-group-text">Filter courses</span>
<select
id="learning-type-filter"
class={ "form-select" }
>
<option value="" selected?={ params.ActiveLearningType.ID=="" }>All</option>
for _, learningType := range params.AvailableLearningTypes {
<option
selected?={ params.ActiveLearningType.ID==learningType.ID }
value={ learningType.ID }
>{ learningType.Name }</option>
}
</select>
<select
id="course-thematic-filter"
class={ "form-select", templ.KV("d-none", len(params.AvailableCourseThematics) == 0) }
>
<option value="" selected?={ params.ActiveLearningType.ID=="" }>All</option>
for _, courseThematic := range params.AvailableCourseThematics {
<option
selected?={ params.ActiveCourseThematic.ID==courseThematic.ID }
value={ courseThematic.ID }
>{ courseThematic.Name }</option>
}
</select>
<button id="filter-course-thematic" class="btn btn-outline-secondary" type="submit">Go</button>
</form>
</div>
@filterCoursesSelectView(params.Schools)
</section>
}
templ selectOptionFromPairs(emptyName, activeID string, items []NameIDPair) {
<option value="" selected?={ activeID == "" }>
{ emptyName }
</option>
for _, item := range items {
<option
selected?={ activeID==item.ID }
value={ item.ID }
>
{ item.Name }
</option>
}
}
templ filterCoursesSelectView(params CoursesFilterViewParams) {
<div class="filter-content d-flex p-2">
<!-- School list -->
<div class="col-3">
<select name="school" id="schoolSelect" class="form-select" aria-label="school filter">
@selectOptionFromPairs("Pick a school:", params.SelectedSchoolID, params.Schools)
</select>
</div>
<!-- Sort options -->
<div class="col-3 mx-4">
<div class="input-group flex-nowrap">
<select
class="form-select"
id="sortBySelect"
aria-label="Sorting items by requested order"
>
@selectOptionFromPairs("Sort by:", params.OrderBy, params.OrderFields)
</select>
<input
type="radio"
class="btn-check"
name="sortByOrder"
id="sortByOrder"
autocomplete="off"
checked?={ !params.Ascending }
/>
<label class="btn" for="sortByOrder">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-sort-down"
viewBox="0 0 16 16"
>
<path
d="M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5M7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"
></path>
</svg>
</label>
</div>
</div>
<div class="col-4"></div>
<div class="col-auto ms-auto">
<div class="btn btn-primary">Promocodes</div>
</div>
</div>
}
templ listCoursesLearning(courses []CourseInfo) {
<div class="block">
<div class="row row-cols-1 row-cols-md-4 g-4">
for _, course := range courses {
@listCoursesCard(course)
}
</div>
</div>
}
templ listCoursesLearningLegacy(containers []CategoryContainer) {
for _, container := range containers {
<section class="row first-class-group g-4">
<h1 class="title">{ container.Name }</h1>
for _, subcategory := range container.Subcategories {
@listCoursesThematicRowLegacy(container.ID, subcategory)
}
</section>
}
}
templ listCoursesThematicRowLegacy(categoryID string, subcategory SubcategoryContainer) {
<div class="block second-class-group">
<h3 class="title">
<a href={ templ.SafeURL("/courses/" + categoryID + "/" + subcategory.ID) } class="text-decoration-none">
{ subcategory.Name }
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-up-right-square" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.854 8.803a.5.5 0 1 1-.708-.707L9.243 6H6.475a.5.5 0 1 1 0-1h3.975a.5.5 0 0 1 .5.5v3.975a.5.5 0 1 1-1 0V6.707z"></path>
</svg>
</a>
</h3>
<p class="visually-hidden">В категогрии { subcategory.Name } собраны { strconv.Itoa(subcategory.Count) } курсов. Раз в неделю мы обновляем информацию о всех курсах.</p>
<div class="row row-cols-1 row-cols-md-4 g-4">
for _, info := range subcategory.Courses {
@listCoursesCard(info)
}
</div>
</div>
}
css myImg() {
min-height: 19rem;
min-width: 19rem;
}
css cardTextSize() {
min-height: 12rem;
}
templ listCoursesCard(info CourseInfo) {
// <div class="col-12 col-md-6 col-lg-3">
<div class="col" id={ info.ID }>
<div class="card h-100">
<img src={ GetOrFallback(info.ImageLink, "https://placehold.co/128x128") } alt="Course picture" class={ "card-img-top" }/>
<div class={ "card-body", cardTextSize(), "row" }>
<h5 class="card-title">{ info.Name }</h5>
<div class="input-group d-flex align-self-end">
<a
href={ templ.URL(info.OriginLink) }
class="btn text btn-outline-primary flex-grow-1"
>Go!</a>
<span class="input-group-text justify-content-end flex-fill">
{ strconv.Itoa(info.FullPrice) } rub.
</span>
</div>
</div>
</div>
</div>
}
templ ListCourses(pageType PageKind, s stats, params ListCoursesParams) {
@root(pageType, s) {
@listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams)
@listCoursesSectionFilters(params.FilterForm)
@listCoursesLearning(params.Courses)
@pagination(params.Pagination)
}
}

View File

@ -0,0 +1,27 @@
package bootstrap
templ listCoursesByCourseThematic(params ListCoursesParams) {
<div class="container">
<h2>Здесь вы можете найти интересующие вас курсы
по теме { params.FilterForm.ActiveLearningType.Name }:
</h2>
<ul class="list-group">
for _, courseThematic := range params.FilterForm.AvailableCourseThematics {
<li class="list-group-item">
<a href={templ.SafeURL("/courses/" + params.FilterForm.ActiveLearningType.ID + "/" + courseThematic.ID)}>
{courseThematic.Name}
</a>
</li>
}
</ul>
</div>
}
templ ListCourseThematics(pageType PageKind, s stats, params ListCoursesParams) {
@root(pageType, s) {
@listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams)
@listCoursesSectionFilters(params.FilterForm)
@listCoursesByCourseThematic(params)
}
}

View File

@ -0,0 +1,135 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.707
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
func listCoursesByCourseThematic(params ListCoursesParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container\"><h2>Здесь вы можете найти интересующие вас курсы по теме ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(params.FilterForm.ActiveLearningType.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list_course_thematics.templ`, Line: 6, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(":</h2><ul class=\"list-group\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, courseThematic := range params.FilterForm.AvailableCourseThematics {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"list-group-item\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL = templ.SafeURL("/courses/" + params.FilterForm.ActiveLearningType.ID + "/" + courseThematic.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var3)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(courseThematic.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list_course_thematics.templ`, Line: 13, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func ListCourseThematics(pageType PageKind, s stats, params ListCoursesParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
if templ_7745c5c3_Var5 == nil {
templ_7745c5c3_Var5 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var6 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
templ_7745c5c3_Err = listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = listCoursesSectionFilters(params.FilterForm).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = listCoursesByCourseThematic(params).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
}
return templ_7745c5c3_Err
})
templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -0,0 +1,949 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.707
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "strings"
import "path"
import "strconv"
func breadcrumbsItem(text, link string, isActive bool) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var2 = []any{"breadcrumb-item", templ.KV("active", isActive)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if link != "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL = templ.URL(link)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var4)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" itemprop=\"url\" aria-label=\"breadcrumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 13, Col: 10}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<span itemprop=\"url\" itemprop=\"title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(text)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 15, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func breadcrumNode(params BreadcrumbsParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
var templ_7745c5c3_Var8 = []any{"mt-4", breadcrumbSymbol()}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" aria-label=\"breadcrumbs\" itemprop=\"breadcrumb\" itemtype=\"https://schema.org/BreadcrumbList\" itemscope><ol class=\"breadcrumb\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumbsItem("Курсы", "/courses", params.ActiveLearningType.Empty()).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !params.ActiveLearningType.Empty() {
templ_7745c5c3_Err = breadcrumbsItem(
params.ActiveLearningType.Name,
path.Join("/", "courses", params.ActiveLearningType.ID),
params.ActiveCourseThematic.Empty(),
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !params.ActiveCourseThematic.Empty() {
templ_7745c5c3_Err = breadcrumbsItem(
params.ActiveCourseThematic.Name,
path.Join("/", "courses", params.ActiveLearningType.ID, params.ActiveCourseThematic.ID),
true,
).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ol></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func breadcrumbSymbol() templ.CSSClass {
var templ_7745c5c3_CSSBuilder strings.Builder
templ_7745c5c3_CSSBuilder.WriteString(`--bs-breadcrumb-divider:"/";`)
templ_7745c5c3_CSSID := templ.CSSID(`breadcrumbSymbol`, templ_7745c5c3_CSSBuilder.String())
return templ.ComponentCSSClass{
ID: templ_7745c5c3_CSSID,
Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
}
}
func listCoursesSectionHeader(params BreadcrumbsParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var10 := templ.GetChildren(ctx)
if templ_7745c5c3_Var10 == nil {
templ_7745c5c3_Var10 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"row bc-header\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = breadcrumNode(params).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func listCoursesSectionFilters(params FilterFormParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var11 := templ.GetChildren(ctx)
if templ_7745c5c3_Var11 == nil {
templ_7745c5c3_Var11 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"row filters\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 = []any{
templ.KV("visually-hidden", !params.Render),
"p-2",
}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><form id=\"filter-form\" class=\"input-group\"><span class=\"input-group-text\">Filter courses</span> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 = []any{"form-select"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<select id=\"learning-type-filter\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var14).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><option value=\"\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if params.ActiveLearningType.ID == "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">All</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, learningType := range params.AvailableLearningTypes {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if params.ActiveLearningType.ID == learningType.ID {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(learningType.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 76, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(learningType.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 77, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 = []any{"form-select", templ.KV("d-none", len(params.AvailableCourseThematics) == 0)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<select id=\"course-thematic-filter\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var18).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><option value=\"\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if params.ActiveLearningType.ID == "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">All</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, courseThematic := range params.AvailableCourseThematics {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if params.ActiveCourseThematic.ID == courseThematic.ID {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(courseThematic.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 88, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(courseThematic.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 89, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select> <button id=\"filter-course-thematic\" class=\"btn btn-outline-secondary\" type=\"submit\">Go</button></form></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = filterCoursesSelectView(params.Schools).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func selectOptionFromPairs(emptyName, activeID string, items []NameIDPair) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var22 := templ.GetChildren(ctx)
if templ_7745c5c3_Var22 == nil {
templ_7745c5c3_Var22 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option value=\"\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if activeID == "" {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(emptyName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 101, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<option")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if activeID == item.ID {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 106, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 108, Col: 14}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func filterCoursesSelectView(params CoursesFilterViewParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var26 := templ.GetChildren(ctx)
if templ_7745c5c3_Var26 == nil {
templ_7745c5c3_Var26 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"filter-content d-flex p-2\"><!-- School list --><div class=\"col-3\"><select name=\"school\" id=\"schoolSelect\" class=\"form-select\" aria-label=\"school filter\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = selectOptionFromPairs("Pick a school:", params.SelectedSchoolID, params.Schools).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select></div><!-- Sort options --><div class=\"col-3 mx-4\"><div class=\"input-group flex-nowrap\"><select class=\"form-select\" id=\"sortBySelect\" aria-label=\"Sorting items by requested order\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = selectOptionFromPairs("Sort by:", params.OrderBy, params.OrderFields).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</select> <input type=\"radio\" class=\"btn-check\" name=\"sortByOrder\" id=\"sortByOrder\" autocomplete=\"off\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !params.Ascending {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("> <label class=\"btn\" for=\"sortByOrder\"><svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-sort-down\" viewBox=\"0 0 16 16\"><path d=\"M3.5 2.5a.5.5 0 0 0-1 0v8.793l-1.146-1.147a.5.5 0 0 0-.708.708l2 1.999.007.007a.497.497 0 0 0 .7-.006l2-2a.5.5 0 0 0-.707-.708L3.5 11.293zm3.5 1a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5M7.5 6a.5.5 0 0 0 0 1h5a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1zm0 3a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z\"></path></svg></label></div></div><div class=\"col-4\"></div><div class=\"col-auto ms-auto\"><div class=\"btn btn-primary\">Promocodes</div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func listCoursesLearning(courses []CourseInfo) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var27 := templ.GetChildren(ctx)
if templ_7745c5c3_Var27 == nil {
templ_7745c5c3_Var27 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"block\"><div class=\"row row-cols-1 row-cols-md-4 g-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, course := range courses {
templ_7745c5c3_Err = listCoursesCard(course).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func listCoursesLearningLegacy(containers []CategoryContainer) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var28 := templ.GetChildren(ctx)
if templ_7745c5c3_Var28 == nil {
templ_7745c5c3_Var28 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
for _, container := range containers {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<section class=\"row first-class-group g-4\"><h1 class=\"title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(container.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 175, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, subcategory := range container.Subcategories {
templ_7745c5c3_Err = listCoursesThematicRowLegacy(container.ID, subcategory).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func listCoursesThematicRowLegacy(categoryID string, subcategory SubcategoryContainer) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
if templ_7745c5c3_Var30 == nil {
templ_7745c5c3_Var30 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"block second-class-group\"><h3 class=\"title\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 templ.SafeURL = templ.SafeURL("/courses/" + categoryID + "/" + subcategory.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var31)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"text-decoration-none\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(subcategory.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 187, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" class=\"bi bi-arrow-up-right-square\" viewBox=\"0 0 16 16\"><path fill-rule=\"evenodd\" d=\"M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5.854 8.803a.5.5 0 1 1-.708-.707L9.243 6H6.475a.5.5 0 1 1 0-1h3.975a.5.5 0 0 1 .5.5v3.975a.5.5 0 1 1-1 0V6.707z\"></path></svg></a></h3><p class=\"visually-hidden\">В категогрии ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(subcategory.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 193, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" собраны ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(subcategory.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 193, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" курсов. Раз в неделю мы обновляем информацию о всех курсах.</p><div class=\"row row-cols-1 row-cols-md-4 g-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, info := range subcategory.Courses {
templ_7745c5c3_Err = listCoursesCard(info).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func myImg() templ.CSSClass {
var templ_7745c5c3_CSSBuilder strings.Builder
templ_7745c5c3_CSSBuilder.WriteString(`min-height:19rem;`)
templ_7745c5c3_CSSBuilder.WriteString(`min-width:19rem;`)
templ_7745c5c3_CSSID := templ.CSSID(`myImg`, templ_7745c5c3_CSSBuilder.String())
return templ.ComponentCSSClass{
ID: templ_7745c5c3_CSSID,
Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
}
}
func cardTextSize() templ.CSSClass {
var templ_7745c5c3_CSSBuilder strings.Builder
templ_7745c5c3_CSSBuilder.WriteString(`min-height:12rem;`)
templ_7745c5c3_CSSID := templ.CSSID(`cardTextSize`, templ_7745c5c3_CSSBuilder.String())
return templ.ComponentCSSClass{
ID: templ_7745c5c3_CSSID,
Class: templ.SafeCSS(`.` + templ_7745c5c3_CSSID + `{` + templ_7745c5c3_CSSBuilder.String() + `}`),
}
}
func listCoursesCard(info CourseInfo) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var35 := templ.GetChildren(ctx)
if templ_7745c5c3_Var35 == nil {
templ_7745c5c3_Var35 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col\" id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(info.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 213, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><div class=\"card h-100\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 = []any{"card-img-top"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(GetOrFallback(info.ImageLink, "https://placehold.co/128x128"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 215, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" alt=\"Course picture\" class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var37).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 = []any{"card-body", cardTextSize(), "row"}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var40).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><h5 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(info.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 217, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h5><div class=\"input-group d-flex align-self-end\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 templ.SafeURL = templ.URL(info.OriginLink)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var43)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"btn text btn-outline-primary flex-grow-1\">Go!</a> <span class=\"input-group-text justify-content-end flex-fill\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(info.FullPrice))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/list.templ`, Line: 224, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" rub.</span></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func ListCourses(pageType PageKind, s stats, params ListCoursesParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var45 := templ.GetChildren(ctx)
if templ_7745c5c3_Var45 == nil {
templ_7745c5c3_Var45 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var46 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
templ_7745c5c3_Err = listCoursesSectionHeader(params.FilterForm.BreadcrumbsParams).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = listCoursesSectionFilters(params.FilterForm).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = listCoursesLearning(params.Courses).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
}
return templ_7745c5c3_Err
})
templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var46), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -0,0 +1,96 @@
package bootstrap
import "strconv"
type IndexCourseCategoryItem struct {
ID string
Name string
Description string
ExampleThemes []string
Count int
}
// courseItemCard is a card that renders a single course thematic item
// that holds multiple learning types. It expected to have a basic description
// and an amount of items.
templ courseItemCard(item IndexCourseCategoryItem) {
<div class="card">
<div class="card-body">
<h5 class="card-title">{ item.Name }</h5>
<hr/>
<p>{ item.Description }</p>
if len(item.ExampleThemes) > 0 {
<p>В данной категории вы можете найти курсы по темам:</p>
<ul>
for _, exampleItem := range item.ExampleThemes {
<li>
<span class="d-inline-block text-truncate col-8">{ exampleItem }</span>
</li>
}
</ul>
}
<div class="d-flex justify-content-between align-items-center">
<a href={ templ.URL("/courses/" + item.ID) } class="btn btn-sm btn-outline-primary col-6">
Open
</a>
<small class="text-body-secondary">
{ strconv.Itoa(item.Count) } items.
</small>
</div>
</div>
</div>
}
templ courseCategory(items []IndexCourseCategoryItem) {
<div class="container w-75 mb-4">
<div class="row g-4">
for _, item := range items {
<div class="col-12 col-md-8 col-lg-4">
@courseItemCard(item)
</div>
}
</div>
</div>
}
type Pagination struct {
Page int
TotalPages int
BaseURL string
}
templ pagination(p Pagination) {
if p.Page > 0 && p.TotalPages > 0 {
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class={ "page-item" , templ.KV("disabled", p.Page==1), }>
<a href={ templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page-1)) } class="page-link">Previous</a>
</li>
for i := max(p.Page-2, 1); i < min(p.TotalPages, 10); i++ {
<li
class={ "page-item" , templ.KV("active", p.Page==i), }
>
<a href={ templ.URL(p.BaseURL + "?page=" + strconv.Itoa(i)) } class="page-link">{ strconv.Itoa(i) }</a>
</li>
}
<li class={ "page-item" , templ.KV("disabled", p.Page==p.TotalPages), }>
<a href={ templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page+1)) } class="page-link">Next</a>
</li>
</ul>
</nav>
}
}
type MainPageParams struct {
Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem
Pagination Pagination
}
templ MainPage(pageType PageKind, s stats, params MainPageParams) {
@root(pageType, s) {
@listCoursesSectionHeader(params.Breadcrumbs)
@courseCategory(params.Categories)
@pagination(params.Pagination)
}
}

View File

@ -0,0 +1,372 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.2.707
package bootstrap
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import "context"
import "io"
import "bytes"
import "strconv"
type IndexCourseCategoryItem struct {
ID string
Name string
Description string
ExampleThemes []string
Count int
}
// courseItemCard is a card that renders a single course thematic item
// that holds multiple learning types. It expected to have a basic description
// and an amount of items.
func courseItemCard(item IndexCourseCategoryItem) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"card\"><div class=\"card-body\"><h5 class=\"card-title\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 19, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h5><hr><p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 21, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(item.ExampleThemes) > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<p>В данной категории вы можете найти курсы по темам:</p><ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, exampleItem := range item.ExampleThemes {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><span class=\"d-inline-block text-truncate col-8\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(exampleItem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 27, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"d-flex justify-content-between align-items-center\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL = templ.URL("/courses/" + item.ID)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var5)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"btn btn-sm btn-outline-primary col-6\">Open</a> <small class=\"text-body-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(item.Count))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 37, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" items.</small></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
func courseCategory(items []IndexCourseCategoryItem) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
if templ_7745c5c3_Var7 == nil {
templ_7745c5c3_Var7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"container w-75 mb-4\"><div class=\"row g-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<div class=\"col-12 col-md-8 col-lg-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = courseItemCard(item).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
type Pagination struct {
Page int
TotalPages int
BaseURL string
}
func pagination(p Pagination) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
if templ_7745c5c3_Var8 == nil {
templ_7745c5c3_Var8 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
if p.Page > 0 && p.TotalPages > 0 {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<nav aria-label=\"Page navigation\"><ul class=\"pagination justify-content-center\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 = []any{"page-item", templ.KV("disabled", p.Page == 1)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page-1))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var11)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">Previous</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for i := max(p.Page-2, 1); i < min(p.TotalPages, 10); i++ {
var templ_7745c5c3_Var12 = []any{"page-item", templ.KV("active", p.Page == i)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var12).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(i))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var14)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(strconv.Itoa(i))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 73, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
var templ_7745c5c3_Var16 = []any{"page-item", templ.KV("disabled", p.Page == p.TotalPages)}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li class=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var16).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/kurious/ports/http/bootstrap/main.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 templ.SafeURL = templ.URL(p.BaseURL + "?page=" + strconv.Itoa(p.Page+1))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var18)))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\" class=\"page-link\">Next</a></li></ul></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}
type MainPageParams struct {
Breadcrumbs BreadcrumbsParams
Categories []IndexCourseCategoryItem
Pagination Pagination
}
func MainPage(pageType PageKind, s stats, params MainPageParams) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var19 := templ.GetChildren(ctx)
if templ_7745c5c3_Var19 == nil {
templ_7745c5c3_Var19 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var20 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
if !templ_7745c5c3_IsBuffer {
templ_7745c5c3_Buffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
}
templ_7745c5c3_Err = listCoursesSectionHeader(params.Breadcrumbs).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = courseCategory(params.Categories).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = pagination(params.Pagination).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
}
return templ_7745c5c3_Err
})
templ_7745c5c3_Err = root(pageType, s).Render(templ.WithChildren(ctx, templ_7745c5c3_Var20), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !templ_7745c5c3_IsBuffer {
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
}
return templ_7745c5c3_Err
})
}

View File

@ -0,0 +1,157 @@
package bootstrap
import (
"strconv"
"strings"
)
type PageKind string
const (
PageIndex PageKind = "main"
PageCourses PageKind = "courses"
PageAbout PageKind = "about"
)
func getCompactedValue(value int) string {
var (
myValue float64
dim string
)
switch {
case value/1e6 > 0:
cutted := value / 1e3
myValue, dim = float64(cutted)/1e3, "m"
case value/1e3 > 0:
myValue, dim = float64(value/1e3), "k"
default:
myValue, dim = float64(value), ""
}
return strings.TrimSuffix(strconv.FormatFloat(myValue, 'f', 3, 32), ".000") + dim
}
func MakeNewStats(courses, clients, categories int) stats {
return stats{
CoursesCount: getCompactedValue(courses),
ClientsCount: getCompactedValue(clients),
CategoriesCount: getCompactedValue(categories),
}
}
type stats struct {
CoursesCount string
ClientsCount string
CategoriesCount string
}
type NameIDPair struct {
ID string
Name string
}
type SortingItem struct {
NameIDPair
Ascending bool
}
type SortingView struct {
Items []SortingItem
}
type CoursesFilterViewParams struct {
SelectedSchoolID string
Schools []NameIDPair
OrderBy string
OrderFields []NameIDPair
Ascending bool
}
type Category struct {
ID string
Name string
}
func (c Category) Empty() bool {
return c == (Category{})
}
type BreadcrumbsParams struct {
ActiveLearningType Category
ActiveCourseThematic Category
}
type FilterFormParams struct {
BreadcrumbsParams
Schools CoursesFilterViewParams
AvailableLearningTypes []Category
AvailableCourseThematics []Category
Render bool
}
type CourseInfo struct {
ID string
Name string
FullPrice int
ImageLink string
OriginLink string
}
type CategoryBaseInfo struct {
ID string
Name string
Description string
Count int
}
type CategoryContainer struct {
CategoryBaseInfo
Subcategories []SubcategoryContainer
}
type SubcategoryContainer struct {
CategoryBaseInfo
Courses []CourseInfo
}
type ListCoursesParams struct {
FilterForm FilterFormParams
Categories []CategoryContainer
Pagination Pagination
Items int
Courses []CourseInfo
}
func GetOrFallback[T comparable](value T, fallback T) T {
var zeroValue T
if value == zeroValue {
return fallback
}
return value
}
type ordered interface {
~int8 | ~int16 | ~int32 | ~int64 | ~int |
~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uint |
~float32 | float64 | ~string
}
func min[T ordered](lhs, rhs T) T {
if lhs < rhs {
return lhs
}
return rhs
}
func max[T ordered](lhs, rhs T) T {
if lhs > rhs {
return lhs
}
return rhs
}

View File

@ -1,118 +1,406 @@
package http
import (
"html/template"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"slices"
"sync"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xslice"
"git.loyso.art/frx/kurious/internal/kurious/app"
"git.loyso.art/frx/kurious/internal/common/xslices"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
"git.loyso.art/frx/kurious/internal/kurious/ports/http/bootstrap"
"git.loyso.art/frx/kurious/internal/kurious/service"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type courseServer struct {
app app.Application
var (
paramsAttr = attribute.Key("params")
webtracer = otel.Tracer("http")
)
type courseTemplServer struct {
app service.Application
log *slog.Logger
}
type pagination struct {
page int
perPage int
}
func makeTemplListCoursesParams(counts map[string]domain.LearningTypeStat, in ...domain.Course) bootstrap.ListCoursesParams {
coursesBySubcategory := make(map[string][]bootstrap.CourseInfo, len(in))
subcategoriesByCategories := make(map[string]map[string]struct{}, len(in))
categoryByID := make(map[string]bootstrap.CategoryBaseInfo, len(in))
seenCourses := make(map[string]struct{}, len(in))
func (p pagination) LimitOffset() (limit, offset int) {
if p.page < 0 {
p.page = 0
}
if p.perPage <= 0 {
p.perPage = defaultPerPage
}
return p.perPage, p.page * p.perPage
}
func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
query := r.URL.Query()
if query.Has("page") {
out.page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad page value")
var out bootstrap.ListCoursesParams
xslices.ForEach(in, func(c domain.Course) {
courseInfo := bootstrap.CourseInfo{
ID: c.ID,
Name: c.Name,
FullPrice: int(c.FullPrice),
ImageLink: c.ImageLink,
OriginLink: c.OriginLink,
}
}
if query.Has("per_page") {
out.perPage, err = strconv.Atoi(query.Get("per_page"))
if err != nil {
return out, errors.NewValidationError("per_page", "bad per_page value")
coursesBySubcategory[c.ThematicID] = append(coursesBySubcategory[c.ThematicID], courseInfo)
if _, ok := subcategoriesByCategories[c.LearningTypeID]; !ok {
subcategoriesByCategories[c.LearningTypeID] = map[string]struct{}{}
}
} else {
out.perPage = 50
}
subcategoriesByCategories[c.LearningTypeID][c.ThematicID] = struct{}{}
return out, nil
}
if _, ok := categoryByID[c.LearningTypeID]; !ok {
categoryByID[c.LearningTypeID] = bootstrap.CategoryBaseInfo{
ID: c.LearningTypeID,
Name: c.LearningType,
}
}
if _, ok := categoryByID[c.ThematicID]; !ok {
categoryByID[c.ThematicID] = bootstrap.CategoryBaseInfo{
ID: c.ThematicID,
Name: c.Thematic,
Count: counts[c.LearningTypeID].CourseThematic[c.ThematicID],
}
}
func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) {
out.pagination, err = parsePaginationFromQuery(r)
if err != nil {
return out, err
}
if _, ok := seenCourses[c.ExternalID.Value()]; ok && c.ExternalID.Valid() {
return
}
query := r.URL.Query()
out.learningType = query.Get("category")
out.courseThematic = query.Get("type")
return out, nil
}
type listCoursesParams struct {
pagination
courseThematic string
learningType string
}
type templateCourse domain.Course
func mapDomainCourseToTemplate(in ...domain.Course) []templateCourse {
return xslice.Map(in, func(v domain.Course) templateCourse {
return templateCourse(v)
out.Courses = append(out.Courses, courseInfo)
seenCourses[c.ExternalID.Value()] = struct{}{}
})
for categoryID, subcategoriesID := range subcategoriesByCategories {
outCategory := bootstrap.CategoryContainer{
CategoryBaseInfo: categoryByID[categoryID],
}
for subcategoryID := range subcategoriesID {
outSubcategory := bootstrap.SubcategoryContainer{
CategoryBaseInfo: categoryByID[subcategoryID],
Courses: coursesBySubcategory[subcategoryID],
}
outCategory.Subcategories = append(outCategory.Subcategories, outSubcategory)
}
out.Categories = append(out.Categories, outCategory)
}
return out
}
func (c courseServer) List(w http.ResponseWriter, r *http.Request) {
func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
params, err := parseListCoursesParams(r)
if err != nil {
handleError(ctx, err, w, c.log, "unable to parse list courses params")
var span trace.Span
ctx, span = webtracer.Start(ctx, "http_server.list")
defer func() {
span.End()
}()
stats := bootstrap.MakeNewStats(10_240, 2_560_000, 1800)
pathParams, err := parseListCoursesParams(r)
if handleError(ctx, err, w, c.log, "unable to parse list courses params") {
return
}
limit, offset := params.LimitOffset()
jsonParams, _ := json.Marshal(pathParams)
span.SetAttributes(paramsAttr.String(string(jsonParams)))
courses, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: params.courseThematic,
LearningType: params.learningType,
Limit: limit,
var offset int
if pathParams.Page > 0 {
offset = (pathParams.Page - 1) * pathParams.PerPage
}
listCoursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
CourseThematic: pathParams.CourseThematic,
LearningType: pathParams.LearningType,
OrganizationID: pathParams.School,
OrderBy: orderByListing.getField(pathParams.OrderBy),
Ascending: pathParams.Ascending,
Limit: pathParams.PerPage,
NextPageToken: pathParams.NextPageToken,
Offset: offset,
})
if err != nil {
handleError(ctx, err, w, c.log, "unable to list courses")
}
templateCourses := mapDomainCourseToTemplate(courses...)
err = template.Must(template.ParseFiles("templates/list.tmpl")).Execute(w, templateCourses)
if err != nil {
handleError(ctx, err, w, c.log, "unable to execute template")
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
w.WriteHeader(http.StatusOK)
statsresult, err := c.app.Queries.ListCourseStatistics.Handle(ctx, query.ListCoursesStats{
LearningTypeID: pathParams.LearningType,
CourseThematicsID: pathParams.CourseThematic,
OrganizationID: pathParams.School,
})
if handleError(ctx, err, w, c.log, "unable to load stats") {
return
}
params := makeTemplListCoursesParams(statsresult.StatsByLearningType, listCoursesResult.Courses...)
learningTypeResult, err := c.app.Queries.ListLearningTypes.Handle(ctx, query.ListLearningTypes{})
if handleError(ctx, err, w, c.log, "unable to list learning types") {
return
}
params.FilterForm.AvailableLearningTypes = xslices.Map(learningTypeResult.LearningTypes, func(in query.LearningType) bootstrap.Category {
outcategory := bootstrap.Category{
ID: in.ID,
Name: in.Name,
}
if in.ID == pathParams.LearningType {
params.FilterForm.ActiveLearningType = outcategory
}
return outcategory
})
if pathParams.LearningType != "" {
courseThematicsResult, err := c.app.Queries.ListCourseThematics.Handle(ctx, query.ListCourseThematics{
LearningTypeID: pathParams.LearningType,
})
if handleError(ctx, err, w, c.log, "unable to list course thematics") {
return
}
params.FilterForm.AvailableCourseThematics = xslices.Map(courseThematicsResult.CourseThematics, func(in query.CourseThematic) bootstrap.Category {
outcategory := bootstrap.Category{
ID: in.ID,
Name: in.Name,
}
if pathParams.CourseThematic == in.ID {
params.FilterForm.ActiveCourseThematic = outcategory
}
return outcategory
})
}
organizaions, err := c.app.Queries.ListOrganizationsStats.Handle(ctx, query.ListOrganizationsStats{
LearningTypeID: pathParams.LearningType,
CourseThematicID: pathParams.CourseThematic,
})
if handleError(ctx, err, w, c.log, "unable to list organizations") {
return
}
organizationStatSortFunc := func(lhs, rhs domain.OrganizationStat) int {
if lhs.CoursesCount > rhs.CoursesCount {
return -1
} else if lhs.CoursesCount < rhs.CoursesCount {
return 1
}
if lhs.ID > rhs.ID {
return 1
}
return -1
}
slices.SortFunc(organizaions, organizationStatSortFunc)
schools := xslices.Map(organizaions, func(in domain.OrganizationStat) bootstrap.NameIDPair {
return bootstrap.NameIDPair{
ID: in.ID,
Name: fmt.Sprintf("%s (count: %d)", in.Name, in.CoursesCount),
}
})
params = bootstrap.ListCoursesParams{
FilterForm: bootstrap.FilterFormParams{
Render: true,
BreadcrumbsParams: bootstrap.BreadcrumbsParams{
ActiveLearningType: params.FilterForm.ActiveLearningType,
ActiveCourseThematic: params.FilterForm.ActiveCourseThematic,
},
AvailableLearningTypes: params.FilterForm.AvailableLearningTypes,
AvailableCourseThematics: params.FilterForm.AvailableCourseThematics,
Schools: bootstrap.CoursesFilterViewParams{
SelectedSchoolID: pathParams.School,
Schools: schools,
Ascending: pathParams.Ascending,
OrderBy: pathParams.OrderBy,
OrderFields: orderByListing.asNameIDPair(),
},
},
Courses: params.Courses,
Categories: params.Categories,
Pagination: bootstrap.Pagination{
Page: pathParams.Page,
TotalPages: listCoursesResult.Count / pathParams.PerPage,
BaseURL: r.URL.Path,
},
}
c.log.DebugContext(
ctx, "params rendered",
slog.Int("course_thematic", len(params.FilterForm.AvailableCourseThematics)),
slog.Int("learning_type", len(params.FilterForm.AvailableLearningTypes)),
slog.Int("items", len(listCoursesResult.Courses)),
slog.Int("page", params.Pagination.Page),
slog.Int("total_pages", params.Pagination.TotalPages),
)
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.CategoryContainer) int {
if lhs.Count > rhs.Count {
return 1
} else if lhs.Count < rhs.Count {
return -1
} else {
return 0
}
})
span.AddEvent("starting to render")
if pathParams.CourseThematic == "" {
params.FilterForm.Render = true
err = bootstrap.ListCourseThematics(bootstrap.PageCourses, stats, params).Render(ctx, w)
} else {
err = bootstrap.ListCourses(bootstrap.PageCourses, stats, params).Render(ctx, w)
}
span.AddEvent("render finished")
if handleError(ctx, err, w, c.log, "unable to render list courses") {
return
}
span.SetStatus(codes.Ok, "request completed")
}
func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var span trace.Span
ctx, span = webtracer.Start(ctx, "http_server.index")
defer func() {
span.End()
}()
stats := bootstrap.MakeNewStats(1, 2, 3)
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{})
if handleError(ctx, err, w, c.log, "unable to list courses") {
return
}
params := bootstrap.MainPageParams{
Categories: []bootstrap.IndexCourseCategoryItem{},
}
coursesByLearningType := make(map[IDNamePair][]domain.Course)
xslices.ForEach(coursesResult.Courses, func(in domain.Course) {
pair := IDNamePair{
ID: in.LearningTypeID,
Name: in.LearningType,
}
coursesByLearningType[pair] = append(coursesByLearningType[pair], in)
})
for learningTypeInfo, courses := range coursesByLearningType {
category := bootstrap.IndexCourseCategoryItem{
ID: learningTypeInfo.ID,
Name: learningTypeInfo.Name,
Count: len(courses),
}
xslices.Shuffle(courses)
if len(courses) > 3 {
courses = courses[:3]
}
names := xslices.Map(courses, func(in domain.Course) string {
return in.Name
})
category.ExampleThemes = names
params.Categories = append(params.Categories, category)
}
slices.SortFunc(params.Categories, func(lhs, rhs bootstrap.IndexCourseCategoryItem) int {
if lhs.Count < rhs.Count {
return 1
} else if lhs.Count > rhs.Count {
return -1
}
return 0
})
span.AddEvent("starting to render")
err = bootstrap.MainPage(bootstrap.PageIndex, stats, params).Render(ctx, w)
span.AddEvent("render finished")
if handleError(ctx, err, w, c.log, "rendeting template") {
return
}
span.SetStatus(codes.Ok, "request completed")
}
var orderByListing = newOrderableContainer(
newOrderableUnit("pr", "Price", "full_price"),
newOrderableUnit("na", "Name", "name"),
newOrderableUnit("di", "Discount", "discount"),
newOrderableUnit("du", "Duration", "duration"),
newOrderableUnit("st", "Starts At", "starts_at"),
)
type orderableUnit struct {
ID string
Name string
Field string
}
func newOrderableUnit(id, name, field string) orderableUnit {
return orderableUnit{
ID: id,
Name: name,
Field: field,
}
}
type orderableContainer struct {
nameByID map[string]string
fieldByID map[string]string
cachedNameIDPair []bootstrap.NameIDPair
makeCache sync.Once
}
func (c *orderableContainer) asNameIDPair() []bootstrap.NameIDPair {
c.makeCache.Do(func() {
c.cachedNameIDPair = make([]bootstrap.NameIDPair, 0, len(c.nameByID))
for id, name := range c.nameByID {
c.cachedNameIDPair = append(c.cachedNameIDPair, bootstrap.NameIDPair{
ID: id,
Name: name,
})
}
})
return c.cachedNameIDPair
}
func (c *orderableContainer) getField(id string) string {
return c.fieldByID[id]
}
func newOrderableContainer(units ...orderableUnit) *orderableContainer {
nameByID := make(map[string]string, len(units))
fieldByID := make(map[string]string, len(units))
xslices.ForEach(units, func(u orderableUnit) {
nameByID[u.ID] = u.Name
fieldByID[u.ID] = u.Field
})
return &orderableContainer{
nameByID: nameByID,
fieldByID: fieldByID,
}
}

View File

@ -5,35 +5,43 @@ import (
stderrors "errors"
"log/slog"
"net/http"
"strconv"
"git.loyso.art/frx/kurious/internal/common/errors"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/app"
)
"git.loyso.art/frx/kurious/internal/kurious/service"
"git.loyso.art/frx/kurious/pkg/xdefault"
const (
defaultPerPage = 50
"github.com/gorilla/mux"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
type Server struct {
app app.Application
app service.Application
log *slog.Logger
}
func NewServer(app app.Application) Server {
return Server{}
}
func (s Server) Courses() courseServer {
return courseServer{
app: s.app,
func NewServer(app service.Application, log *slog.Logger) Server {
return Server{
app: app,
log: log,
}
}
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) {
func (s Server) Courses() courseTemplServer {
return courseTemplServer(s)
}
func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slog.Logger, msg string) bool {
if err == nil {
return
return false
}
span := trace.SpanFromContext(ctx)
span.RecordError(err)
span.SetStatus(codes.Error, "error during handling request")
var errorString string
var code int
valErr := new(errors.ValidationError)
@ -52,4 +60,82 @@ func handleError(ctx context.Context, err error, w http.ResponseWriter, log *slo
xcontext.LogWithWarnError(ctx, log, err, msg, slog.Int("status_code", code), slog.String("response", errorString))
http.Error(w, errorString, code)
return true
}
type pagination struct {
NextPageToken string
PerPage int
Page int
}
func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
query := r.URL.Query()
if query.Has("next") && query.Has("page") {
return out, errors.NewValidationError("next", `could not be set together with "page"`)
}
out.NextPageToken = query.Get("next")
if query.Has("per_page") {
out.PerPage, err = strconv.Atoi(query.Get("per_page"))
if err != nil {
return out, errors.NewValidationError("per_page", "bad per_page value")
}
} else {
out.PerPage = 20
}
if query.Has("page") {
out.Page, err = strconv.Atoi(query.Get("page"))
if err != nil {
return out, errors.NewValidationError("page", "bad per_page value")
}
} else if !query.Has("next") {
out.Page = 1
}
return out, nil
}
const (
LearningTypePathParam = "learning_type"
ThematicTypePathParam = "thematic_type"
)
func parseListCoursesParams(r *http.Request) (out listCoursesParams, err error) {
out.pagination, err = parsePaginationFromQuery(r)
if err != nil {
return out, err
}
vars := mux.Vars(r)
out.LearningType = vars[LearningTypePathParam]
out.CourseThematic = vars[ThematicTypePathParam]
out.School = r.URL.Query().Get("school_id")
out.OrderBy = xdefault.WithFallback(r.URL.Query().Get("order_by"), "price")
if r.URL.Query().Has("asc") {
out.Ascending, _ = strconv.ParseBool(r.URL.Query().Get("asc"))
}
return out, nil
}
type listCoursesParams struct {
pagination
CourseThematic string
LearningType string
School string
OrderBy string
Ascending bool
}
type IDNamePair struct {
ID string
Name string
IsActive bool
}

View File

@ -1,86 +0,0 @@
{{define "courses"}}
<!DOCTYPE html>
<html>
<head>
<title>Courses</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}
header {
background-color: #333;
color: white;
padding: 10px;
}
header h1 {
margin: 0;
}
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav li {
display: inline;
margin-right: 10px;
}
nav a {
color: white;
text-decoration: none;
}
h1, h2, h3 {
margin-top: 0;
}
p {
margin-bottom: 10px;
}
.course-plate {
background-color: #f2f2f2;
border: 1px solid #ddd;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
text-align: center;
}
.course-plate a {
color: #333;
text-decoration: none;
}
.course-plate a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<header>
<h1>My Product</h1>
<nav>
<ul>
<li><a href="/">Main page</a></li>
<li><a href="/about">About us</a></li>
<li><a href="/help">Help</a></li>
</ul>
</nav>
</header>
<h1>Courses</h1>
{{range $category, $courses := .Courses}}
<h2>{{$category}}</h2>
<p>{{$category.Description}}</p>
{{range $course := $courses}}
<div class="course-plate">
<h3><a href="/courses/{{$course.ID}}">{{$course.Name}}</a></h3>
<p>{{$course.Description}}</p>
<p>Full price: {{$course.FullPrice}}</p>
<p>Discount: {{$course.Discount}}</p>
<p>Thematic: {{$course.Thematic}}</p>
<p>Learning type: {{$course.LearningType}}</p>
<p>Duration: {{$course.Duration}}</p>
<p>Starts at: {{$course.StartsAt}}</p>
</div>
{{end}}
{{end}}
</body>
</html>
{{end}}

View File

@ -2,20 +2,33 @@ package service
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"git.loyso.art/frx/kurious/internal/common/config"
"git.loyso.art/frx/kurious/internal/common/xcontext"
"git.loyso.art/frx/kurious/internal/kurious/adapters"
"git.loyso.art/frx/kurious/internal/kurious/app"
"git.loyso.art/frx/kurious/internal/kurious/app/command"
"git.loyso.art/frx/kurious/internal/kurious/app/query"
"git.loyso.art/frx/kurious/internal/kurious/domain"
)
type RepositoryEngine uint8
const (
RepositoryEngineUnknown RepositoryEngine = iota
RepositoryEngineYDB
RepositoryEngineSqlite
)
type ApplicationConfig struct {
LogConfig config.Log
YDB config.YDB
Sqlite config.Sqlite
Engine RepositoryEngine
}
type Application struct {
@ -25,29 +38,57 @@ type Application struct {
closers []io.Closer
}
func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, error) {
func NewApplication(ctx context.Context, cfg ApplicationConfig, mapper domain.CourseMapper) (out Application, err error) {
log := config.NewSLogger(cfg.LogConfig)
ydbConnection, err := adapters.NewYDBConnection(ctx, cfg.YDB, log.With(slog.String("db", "ydb")))
if err != nil {
return Application{}, fmt.Errorf("making ydb connection: %w", err)
var repoCloser io.Closer
var courseadapter domain.CourseRepository
var organizationrepo domain.OrganizationRepository
switch cfg.Engine {
case RepositoryEngineSqlite:
sqliteConnection, err := adapters.NewSqliteConnection(ctx, cfg.Sqlite, log.With(slog.String("db", "sqlite")))
if err != nil {
return Application{}, fmt.Errorf("making sqlite connection: %w", err)
}
courseadapter = sqliteConnection.CourseRepository()
organizationrepo = sqliteConnection.Organization()
repoCloser = sqliteConnection
case RepositoryEngineYDB:
return Application{}, errors.New("ydb is no longer supported")
default:
return Application{}, errors.New("unable to decide which db engine to use")
}
courseadapter := ydbConnection.CourseRepository()
err = mapper.CollectCounts(ctx, courseadapter)
if err != nil {
xcontext.LogWithWarnError(ctx, log, err, "unable to properly collect counts")
}
application := app.Application{
Commands: app.Commands{
InsertCourses: command.NewCreateCoursesHandler(courseadapter, log),
InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
InsertCourses: command.NewCreateCoursesHandler(courseadapter, log),
InsertCourse: command.NewCreateCourseHandler(courseadapter, log),
DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log),
UpdateCourseDescription: command.NewUpdateCourseDescriptionHandler(courseadapter, log),
InsertOrganization: command.NewCreateOrganizationHandler(organizationrepo, log),
},
Queries: app.Queries{
GetCourse: query.NewGetCourseHandler(courseadapter, log),
ListCourses: query.NewListCourseHandler(courseadapter, log),
ListCourses: query.NewListCourseHandler(courseadapter, mapper, log),
ListLearningTypes: query.NewListLearningTypesHandler(courseadapter, mapper, log),
ListCourseThematics: query.NewListCourseThematicsHandler(courseadapter, mapper, log),
ListCourseStatistics: query.NewListCoursesStatsHandler(mapper, courseadapter, log),
GetCourse: query.NewGetCourseHandler(courseadapter, mapper, log),
ListOrganzations: query.NewListOrganizationsHandler(organizationrepo, log),
ListOrganizationsStats: query.NewListOrganizationsStatsHandler(organizationrepo, log),
GetOrganization: query.NewGetOrganizationHandler(organizationrepo, log),
},
}
out := Application{Application: application}
out.closers = append(out.closers, ydbConnection)
out = Application{Application: application}
out.closers = append(out.closers, repoCloser)
out.log = log
return out, nil

View File

@ -0,0 +1,24 @@
CREATE TABLE courses (
id TEXT PRIMARY KEY,
external_id TEXT,
source_type TEXT NOT NULL,
source_name TEXT,
course_thematic TEXT NOT NULL,
learning_type TEXT NOT NULL,
organization_id TEXT NOT NULL,
origin_link TEXT NOT NULL,
image_link TEXT,
name TEXT NOT NULL,
description TEXT NOT NULL,
full_price REAL NOT NULL,
discount REAL NOT NULL,
duration INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
starts_at DATETIME,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
);
CREATE INDEX idx_course_thematic ON courses (course_thematic);
CREATE INDEX idx_learning_type ON courses (learning_type);
CREATE INDEX idx_organization_id ON courses (organization_id);

View File

@ -0,0 +1,19 @@
CREATE TABLE learning_categories (
id TEXT PRIMARY KEY,
logo TEXT NULL,
courses_count INT NOT NULL DEFAULT 0
);
create table organizations (
id TEXT PRIMARY KEY,
external_id TEXT NULL,
alias TEXT NOT NULL,
name TEXT NOT NULL,
site TEXT NOT NULL,
logo TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE INDEX idx_organization_external_id ON organizations (external_id);

View File

@ -0,0 +1,18 @@
package sqlite
import (
"embed"
"io/fs"
)
//go:embed *.sql
var migrations embed.FS
func getMigrationEntries() ([]fs.DirEntry, error) {
entries, err := fs.ReadDir(migrations, ".")
if err != nil {
return nil, err
}
return entries, nil
}

View File

@ -0,0 +1,240 @@
package sqlite
import (
"cmp"
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"path"
"slices"
"strconv"
"strings"
"time"
"git.loyso.art/frx/kurious/internal/common/xcontext"
)
type migrationUnit struct {
num int
name string
path string
}
func (u migrationUnit) apply(ctx context.Context, tx *sql.Tx) error {
content, err := fs.ReadFile(migrations, u.path)
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
query := string(content)
_, err = tx.ExecContext(ctx, query)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
}
func sortMigrationUnit(lhs, rhs migrationUnit) int {
if lhs.num < rhs.num {
return -1
} else if lhs.num > rhs.num {
return 1
} else {
return 0
}
}
func RunMigrations(ctx context.Context, db *sql.DB, log *slog.Logger) error {
items, err := getMigrationEntries()
if err != nil {
return fmt.Errorf("reading directory: %w", err)
}
units := make([]migrationUnit, 0, len(items))
for _, item := range items {
if item.IsDir() {
continue
}
itemName := item.Name()
splitted := strings.SplitN(itemName, "_", 2)
if len(splitted) != 2 {
return fmt.Errorf("bad number of parts, expected 2, got %d", len(splitted))
}
splittedNum, err := strconv.Atoi(splitted[0])
if err != nil {
return fmt.Errorf("parsing migration number: %w", err)
}
if splittedNum < 1 {
return fmt.Errorf("migration number expected to be greater than 0, but got %d", splittedNum)
}
unit := migrationUnit{
num: splittedNum,
name: strings.TrimSuffix(splitted[1], ".sql"),
path: path.Join(itemName),
}
xcontext.LogDebug(ctx, log, "found migration unit", slog.Any("unit", unit))
units = append(units, unit)
}
slices.SortFunc(units, sortMigrationUnit)
mr := &metaRepository{
db: db,
log: log,
}
err = mr.prepare(ctx)
if err != nil {
return fmt.Errorf("preparing meta repository: %w", err)
}
count, err := mr.run(ctx, units...)
if err != nil {
return fmt.Errorf("running transaction: %w", err)
}
if count > 0 {
xcontext.LogInfo(ctx, log, "some new migrations has been applied", slog.Int("count", count))
} else {
xcontext.LogDebug(ctx, log, "no new migrations has been applied")
}
return nil
}
type metaRepository struct {
db *sql.DB
log *slog.Logger
lastAppliedNumber int
}
func (r *metaRepository) run(ctx context.Context, units ...migrationUnit) (count int, err error) {
idx, found := slices.BinarySearchFunc(units, r.lastAppliedNumber, func(mu migrationUnit, i int) int {
return cmp.Compare(mu.num, i)
})
if !found && r.lastAppliedNumber > 0 {
return 0, fmt.Errorf("migration %d stored in meta was not found in provided migrations", r.lastAppliedNumber)
} else if r.lastAppliedNumber > 0 {
idx++
}
xcontext.LogDebug(
ctx, r.log,
"starting to apply migrations",
slog.Int("last_applied_migration", r.lastAppliedNumber),
slog.Int("next_migration_idx", idx),
)
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelDefault,
ReadOnly: false,
})
if err != nil {
return 0, fmt.Errorf("starting transaction: %w", err)
}
defer func() {
var errtx error
if err != nil {
xcontext.LogError(ctx, r.log, "rolling back migration changes due to error")
errtx = tx.Rollback()
} else {
xcontext.LogDebug(ctx, r.log, "commiting migration changes")
errtx = tx.Commit()
}
err = errors.Join(err, errtx)
}()
for i := idx; i < len(units); i++ {
unit := units[i]
err = unit.apply(ctx, tx)
if err != nil {
return 0, fmt.Errorf("unable to apply migration %q: %w", unit.name, err)
}
err = r.adjustMigrationApplied(ctx, tx, unit)
if err != nil {
return 0, fmt.Errorf("storing migration process info: %w", err)
}
xcontext.LogInfo(
ctx, r.log, "migration unit applied",
slog.Int("number", unit.num),
slog.String("name", unit.name),
)
count++
}
return count, nil
}
func (r *metaRepository) prepare(ctx context.Context) error {
err := r.makeTable(ctx)
if err != nil {
return fmt.Errorf("making table: %w", err)
}
err = r.loadLastAppliedMigration(ctx)
if err != nil {
return fmt.Errorf("loading last applied migration: %w", err)
}
return nil
}
func (r *metaRepository) makeTable(ctx context.Context) error {
const query = `CREATE TABLE IF NOT EXISTS migration_meta (` +
` id INT PRIMARY KEY NOT NULL` +
`, name TEXT NOT NULL` +
`, applied_at INT NOT NULL` +
`);`
_, err := r.db.ExecContext(ctx, query)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
return nil
}
func (r *metaRepository) loadLastAppliedMigration(ctx context.Context) error {
const query = `SELECT COALESCE(MAX(id), 0) FROM migration_meta;`
err := r.db.QueryRowContext(ctx, query).Scan(&r.lastAppliedNumber)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("executing query: %w", err)
}
}
return nil
}
func (r *metaRepository) adjustMigrationApplied(ctx context.Context, tx *sql.Tx, unit migrationUnit) error {
const query = `INSERT INTO migration_meta (id, name, applied_at) VALUES (?, ?, ?)`
args := []any{
unit.num,
unit.name,
time.Now().Truncate(time.Second).Unix(),
}
_, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("executing query: %w", err)
}
r.lastAppliedNumber = unit.num
return nil
}

Some files were not shown because too many files have changed in this diff Show More