diff --git a/.gitignore b/.gitignore index ba077a4..c1c7174 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin +./tags diff --git a/.mockery.yaml b/.mockery.yaml new file mode 100644 index 0000000..a422248 --- /dev/null +++ b/.mockery.yaml @@ -0,0 +1,3 @@ +with-expecter: true +keeptree: True + diff --git a/cmd/dev/sravnicli/main.go b/cmd/dev/sravnicli/main.go index b1c6a02..8d83329 100644 --- a/cmd/dev/sravnicli/main.go +++ b/cmd/dev/sravnicli/main.go @@ -44,10 +44,15 @@ func setupCLI(ctx context.Context) cli.App { log := makeLogger(options) client, err := makeSravniClient(ctx, log, options) if err != nil { - log.ErrorContext(ctx, "making client", slog.Any("err", err)) + log.ErrorContext(ctx, "unable to make client", slog.Any("err", err)) + return -1 + } + + state, err := client.GetMainPageState() + if err != nil { + log.ErrorContext(ctx, "unable to make page state", slog.Any("err", err)) return -1 } - state := client.GetMainPageState() var out any switch options["part"] { diff --git a/go.mod b/go.mod index f770d37..7eefa31 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.21 require ( github.com/go-resty/resty/v2 v2.10.0 + github.com/stretchr/testify v1.8.4 + github.com/teris-io/cli v1.0.1 golang.org/x/net v0.18.0 ) require ( - github.com/teris-io/cli v1.0.1 // indirect - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bf59d55..f2c2abc 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,24 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +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/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -47,3 +58,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/courses/client.go b/internal/app/courses/client.go deleted file mode 100644 index 01e398d..0000000 --- a/internal/app/courses/client.go +++ /dev/null @@ -1 +0,0 @@ -package courses diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/client.go b/internal/common/client/sravni/client.go similarity index 94% rename from internal/infrastructure/interfaceadapters/courses/sravni/client.go rename to internal/common/client/sravni/client.go index bcd204b..27254e6 100644 --- a/internal/infrastructure/interfaceadapters/courses/sravni/client.go +++ b/internal/common/client/sravni/client.go @@ -9,8 +9,8 @@ import ( "strconv" "strings" - "git.loyso.art/frx/kurious/internal/domain" - "git.loyso.art/frx/kurious/pkg/utilities/slices" + "git.loyso.art/frx/kurious/internal/common/errors" + "git.loyso.art/frx/kurious/pkg/slices" "github.com/go-resty/resty/v2" "golang.org/x/net/html" @@ -21,8 +21,10 @@ const ( baseURL = "https://www.sravni.ru/kursy" ) +//go:generate mockery --name Client type Client interface { - GetMainPageState() *PageState + GetMainPageState() (*PageState, error) + ListEducationalProducts( ctx context.Context, params ListEducationProductsParams, @@ -66,8 +68,8 @@ type client struct { validCourseThematics querySet } -func (c *client) GetMainPageState() *PageState { - return c.cachedMainPageInfo.Clone() +func (c *client) GetMainPageState() (*PageState, error) { + return c.cachedMainPageInfo.Clone(), nil } type ListEducationProductsParams struct { @@ -119,10 +121,10 @@ func (c *client) ListEducationalProducts( } if !c.validLearningTypes.hasValue(params.LearningType) { - return result, domain.NewValidationError("learning_type", "bad value") + return result, errors.NewValidationError("learning_type", "bad value") } if !c.validCourseThematics.hasValue(params.CoursesThematics) { - return result, domain.NewValidationError("courses_thematics", "bad value") + return result, errors.NewValidationError("courses_thematics", "bad value") } reqParams := ListEducationProductsRequest{ @@ -161,7 +163,7 @@ func (c *client) ListEducationalProducts( } if resp.IsError() { - return result, fmt.Errorf("bad status code %d: %w", resp.StatusCode(), domain.ErrUnexpectedStatus) + return result, fmt.Errorf("bad status code %d: %w", resp.StatusCode(), errors.ErrUnexpectedStatus) } return result, nil @@ -202,7 +204,7 @@ func (c *client) getMainPageState(ctx context.Context) (*PageState, error) { if resp.IsError() { c.log.ErrorContext(ctx, "unable to proceed request", slog.String("body", string(resp.Body()))) - return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), domain.ErrUnexpectedStatus) + return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), errors.ErrUnexpectedStatus) } traceInfo := resp.Request.TraceInfo() diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/entities.go b/internal/common/client/sravni/entities.go similarity index 98% rename from internal/infrastructure/interfaceadapters/courses/sravni/entities.go rename to internal/common/client/sravni/entities.go index f4c274a..9898818 100644 --- a/internal/infrastructure/interfaceadapters/courses/sravni/entities.go +++ b/internal/common/client/sravni/entities.go @@ -3,11 +3,11 @@ package sravni import ( "time" - "git.loyso.art/frx/kurious/internal/domain" + "git.loyso.art/frx/kurious/internal/common/errors" ) const ( - ErrClientNotInited domain.SimpleError = "client was not inited" + ErrClientNotInited errors.SimpleError = "client was not inited" ) type PageStateRuntimeConfig struct { diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/helpers.go b/internal/common/client/sravni/helpers.go similarity index 100% rename from internal/infrastructure/interfaceadapters/courses/sravni/helpers.go rename to internal/common/client/sravni/helpers.go diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/logger.go b/internal/common/client/sravni/logger.go similarity index 100% rename from internal/infrastructure/interfaceadapters/courses/sravni/logger.go rename to internal/common/client/sravni/logger.go diff --git a/internal/common/client/sravni/noop.go b/internal/common/client/sravni/noop.go new file mode 100644 index 0000000..d694cb4 --- /dev/null +++ b/internal/common/client/sravni/noop.go @@ -0,0 +1,17 @@ +package sravni + +import ( + "context" + + "git.loyso.art/frx/kurious/internal/common/errors" +) + +type NoopClient struct{} + +func (NoopClient) GetMainPageState() (*PageState, error) { + return nil, errors.ErrNotImplemented +} + +func (NoopClient) ListEducationalProducts(context.Context, ListEducationProductsParams) (ListEducationProductsResponse, error) { + return ListEducationProductsResponse{}, errors.ErrNotImplemented +} diff --git a/internal/common/config/log.go b/internal/common/config/log.go new file mode 100644 index 0000000..e62ac9c --- /dev/null +++ b/internal/common/config/log.go @@ -0,0 +1,55 @@ +package config + +import ( + "log/slog" + "os" +) + +type LogFormat uint8 + +const ( + LogFormatText LogFormat = iota + LogFormatJSON +) + +type LogLevel uint8 + +const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError +) + +type LogConfig struct { + Level LogLevel + Format LogFormat +} + +func NewSLogger(config LogConfig) *slog.Logger { + var level slog.Level + switch config.Level { + case LogLevelDebug: + level = slog.LevelDebug + case LogLevelInfo: + level = slog.LevelInfo + case LogLevelWarn: + level = slog.LevelWarn + case LogLevelError: + level = slog.LevelError + } + + opts := &slog.HandlerOptions{ + Level: level, + } + + var h slog.Handler + switch config.Format { + case LogFormatJSON: + h = slog.NewJSONHandler(os.Stdout, opts) + case LogFormatText: + h = slog.NewTextHandler(os.Stdout, opts) + } + + return slog.New(h) +} diff --git a/internal/common/decorator/command.go b/internal/common/decorator/command.go new file mode 100644 index 0000000..8e57240 --- /dev/null +++ b/internal/common/decorator/command.go @@ -0,0 +1,17 @@ +package decorator + +import ( + "context" + "log/slog" +) + +type CommandHandler[T any] interface { + Handle(ctx context.Context, params T) error +} + +func ApplyCommandDecorators[T any](base CommandHandler[T], log *slog.Logger) CommandHandler[T] { + return commandLoggingDecorator[T]{ + base: base, + log: log, + } +} diff --git a/internal/common/decorator/logging.go b/internal/common/decorator/logging.go new file mode 100644 index 0000000..f3fd05d --- /dev/null +++ b/internal/common/decorator/logging.go @@ -0,0 +1,64 @@ +package decorator + +import ( + "context" + "fmt" + "log/slog" + "time" + + "git.loyso.art/frx/kurious/internal/common/xcontext" +) + +type commandLoggingDecorator[T any] struct { + base CommandHandler[T] + log *slog.Logger +} + +func (c commandLoggingDecorator[T]) Handle(ctx context.Context, cmd T) (err error) { + handlerName := getTypeName[T]() + + ctx = xcontext.WithLogFields(ctx, slog.String("handler", handlerName)) + xcontext.LogDebug(ctx, c.log, "executing command") + start := time.Now() + + defer func() { + elapsed := slog.Duration("elapsed", time.Since(start)) + if err == nil { + xcontext.LogInfo(ctx, c.log, "command executed successfuly", elapsed) + } else { + xcontext.LogError(ctx, c.log, "command execution failed", elapsed, slog.Any("err", err)) + } + }() + + return c.base.Handle(ctx, cmd) +} + +type queryLoggingDecorator[Q, U any] struct { + base QueryHandler[Q, U] + log *slog.Logger +} + +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)) + xcontext.LogDebug(ctx, q.log, "executing command") + start := time.Now() + + defer func() { + elapsed := slog.Duration("elapsed", time.Since(start)) + if err == nil { + xcontext.LogInfo(ctx, q.log, "command executed successfuly", elapsed) + } else { + xcontext.LogError(ctx, q.log, "command execution failed", elapsed, slog.Any("err", err)) + } + }() + + return q.base.Handle(ctx, query) + +} + +func getTypeName[T any]() string { + var t T + out := fmt.Sprintf("%T", t) + return out +} diff --git a/internal/common/decorator/query.go b/internal/common/decorator/query.go new file mode 100644 index 0000000..285404c --- /dev/null +++ b/internal/common/decorator/query.go @@ -0,0 +1,17 @@ +package decorator + +import ( + "context" + "log/slog" +) + +type QueryHandler[Q, U any] interface { + Handle(ctx context.Context, query Q) (entity U, err error) +} + +func AddQueryDecorators[Q, U any](base QueryHandler[Q, U], log *slog.Logger) QueryHandler[Q, U] { + return queryLoggingDecorator[Q, U]{ + base: base, + log: log, + } +} diff --git a/internal/domain/error.go b/internal/common/errors/error.go similarity index 97% rename from internal/domain/error.go rename to internal/common/errors/error.go index 4f0b477..e0c8190 100644 --- a/internal/domain/error.go +++ b/internal/common/errors/error.go @@ -1,4 +1,4 @@ -package domain +package errors import ( "fmt" diff --git a/internal/common/nullable/value.go b/internal/common/nullable/value.go new file mode 100644 index 0000000..b643318 --- /dev/null +++ b/internal/common/nullable/value.go @@ -0,0 +1,42 @@ +package nullable + +type Value[T any] struct { + value T + valid bool +} + +func NewValue[T any](value T) Value[T] { + return Value[T]{ + value: value, + valid: true, + } +} + +func NewValuePtr[T any](value *T) Value[T] { + if value == nil { + return Value[T]{} + } + + return NewValue(*value) +} + +func (n Value[T]) Value() T { + return n.value +} + +func (n Value[T]) Valid() bool { + return n.valid +} + +func (n Value[T]) ValutPtr() *T { + if n.valid { + return &n.value + } + + return nil +} + +func (n *Value[T]) Set(value T) { + n.valid = true + n.value = value +} diff --git a/internal/common/xcontext/log.go b/internal/common/xcontext/log.go new file mode 100644 index 0000000..79a587c --- /dev/null +++ b/internal/common/xcontext/log.go @@ -0,0 +1,40 @@ +package xcontext + +import ( + "context" + "log/slog" +) + +type ctxLogKey struct{} + +type ctxLogAttrStore struct { + attrs []slog.Attr +} + +func WithLogFields(ctx context.Context, fields ...slog.Attr) context.Context { + store, _ := ctx.Value(ctxLogKey{}).(ctxLogAttrStore) + store.attrs = append(store.attrs, fields...) + + return context.WithValue(ctx, ctxLogKey{}, store) +} + +func LogDebug(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { + log.LogAttrs(ctx, slog.LevelDebug, msg, append(attrs, getLogFields(ctx)...)...) +} + +func LogInfo(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { + log.LogAttrs(ctx, slog.LevelInfo, msg, append(attrs, getLogFields(ctx)...)...) +} + +func LogWarn(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { + log.LogAttrs(ctx, slog.LevelWarn, msg, append(attrs, getLogFields(ctx)...)...) +} + +func LogError(ctx context.Context, log *slog.Logger, msg string, attrs ...slog.Attr) { + log.LogAttrs(ctx, slog.LevelError, msg, append(attrs, getLogFields(ctx)...)...) +} + +func getLogFields(ctx context.Context) []slog.Attr { + store, _ := ctx.Value(ctxLogKey{}).(ctxLogAttrStore) + return store.attrs +} diff --git a/internal/infrastructure/interfaceadapters/services.go b/internal/infrastructure/interfaceadapters/services.go deleted file mode 100644 index 90910a7..0000000 --- a/internal/infrastructure/interfaceadapters/services.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package adapters aggregates all external services and it's implementations. -package adapters - -type Services struct{} - -func NewServices() Services { - return Services{} -} diff --git a/internal/kurious/adapters/ydb_course_repository.go b/internal/kurious/adapters/ydb_course_repository.go new file mode 100644 index 0000000..56ab892 --- /dev/null +++ b/internal/kurious/adapters/ydb_course_repository.go @@ -0,0 +1,32 @@ +package adapters + +import ( + "context" + + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +func NewYDBCourseRepository() (*ydbCourseRepository, error) { + return &ydbCourseRepository{}, nil +} + +type ydbCourseRepository struct{} + +func (ydbCourseRepository) List(ctx context.Context, params domain.ListCoursesParams) ([]domain.Course, error) { + return nil, nil +} +func (ydbCourseRepository) Get(ctx context.Context, id string) (domain.Course, error) { + return domain.Course{}, nil +} +func (ydbCourseRepository) GetByExternalID(ctx context.Context, id string) (domain.Course, error) { + return domain.Course{}, nil +} +func (ydbCourseRepository) Create(context.Context, domain.CreateCourseParams) (domain.Course, error) { + return domain.Course{}, nil +} +func (ydbCourseRepository) Delete(ctx context.Context, id string) error { + return nil +} +func (ydbCourseRepository) Close() error { + return nil +} diff --git a/internal/kurious/app/app.go b/internal/kurious/app/app.go new file mode 100644 index 0000000..2160f34 --- /dev/null +++ b/internal/kurious/app/app.go @@ -0,0 +1,21 @@ +package app + +import ( + "git.loyso.art/frx/kurious/internal/kurious/app/command" + "git.loyso.art/frx/kurious/internal/kurious/app/query" +) + +type Commands struct { + InsertCourse command.CreateCourseHandler + DeleteCourse command.DeleteCourseHandler +} + +type Queries struct { + GetCourse query.GetCourseHandler + ListCourses query.ListCourseHandler +} + +type Application struct { + Commands Commands + Queries Queries +} diff --git a/internal/kurious/app/command/createcourse.go b/internal/kurious/app/command/createcourse.go new file mode 100644 index 0000000..f22bd97 --- /dev/null +++ b/internal/kurious/app/command/createcourse.go @@ -0,0 +1,54 @@ +package command + +import ( + "context" + "fmt" + "log/slog" + "time" + + "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 CreateCourse struct { + ID string + Name string + Description string + ExternalID nullable.Value[string] + SourceType domain.SourceType + SourceName nullable.Value[string] + OrganizationID string + OriginLink string + ImageLink string + FullPrice float64 + Discount float64 + Duration time.Duration + StartsAt time.Time +} + +type CreateCourseHandler decorator.CommandHandler[CreateCourse] + +type createCourseHandler struct { + repo domain.CourseRepository +} + +func NewCreateCourseHandler( + repo domain.CourseRepository, + log *slog.Logger, +) CreateCourseHandler { + h := createCourseHandler{ + repo: repo, + } + + return decorator.ApplyCommandDecorators(h, log) +} + +func (h createCourseHandler) Handle(ctx context.Context, cmd CreateCourse) error { + _, err := h.repo.Create(ctx, domain.CreateCourseParams(cmd)) + if err != nil { + return fmt.Errorf("creating course: %w", err) + } + + return nil +} diff --git a/internal/kurious/app/command/deletecourse.go b/internal/kurious/app/command/deletecourse.go new file mode 100644 index 0000000..36f9e2f --- /dev/null +++ b/internal/kurious/app/command/deletecourse.go @@ -0,0 +1,40 @@ +package command + +import ( + "context" + "fmt" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type DeleteCourse struct { + ID string +} + +type DeleteCourseHandler decorator.CommandHandler[DeleteCourse] + +type deleteCourseHandler struct { + repo domain.CourseRepository +} + +func NewDeleteCourseHandler( + repo domain.CourseRepository, + log *slog.Logger, +) DeleteCourseHandler { + h := deleteCourseHandler{ + repo: repo, + } + + return decorator.ApplyCommandDecorators(h, log) +} + +func (h deleteCourseHandler) Handle(ctx context.Context, cmd DeleteCourse) error { + err := h.repo.Delete(ctx, cmd.ID) + if err != nil { + return fmt.Errorf("deleting: %w", err) + } + + return nil +} diff --git a/internal/kurious/app/query/getcourse.go b/internal/kurious/app/query/getcourse.go new file mode 100644 index 0000000..0a5d320 --- /dev/null +++ b/internal/kurious/app/query/getcourse.go @@ -0,0 +1,39 @@ +package query + +import ( + "context" + "fmt" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/decorator" + "git.loyso.art/frx/kurious/internal/kurious/domain" +) + +type GetCourse struct { + ID string +} + +type GetCourseHandler decorator.QueryHandler[GetCourse, domain.Course] + +type getCourseHandler struct { + repo domain.CourseRepository +} + +func NewGetCourseHandler( + repo domain.CourseRepository, + log *slog.Logger, +) GetCourseHandler { + h := getCourseHandler{ + repo: repo, + } + return decorator.AddQueryDecorators(h, log) +} + +func (h getCourseHandler) Handle(ctx context.Context, query GetCourse) (domain.Course, error) { + course, err := h.repo.Get(ctx, query.ID) + if err != nil { + return domain.Course{}, fmt.Errorf("getting course: %w", err) + } + + return course, nil +} diff --git a/internal/kurious/app/query/listcourses.go b/internal/kurious/app/query/listcourses.go new file mode 100644 index 0000000..6c141ea --- /dev/null +++ b/internal/kurious/app/query/listcourses.go @@ -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/kurious/domain" +) + +type ListCourse struct { + CategoryID string + OrganizationID string + Keyword string +} + +type ListCourseHandler decorator.QueryHandler[ListCourse, []domain.Course] + +type listCourseHandler struct { + repo domain.CourseRepository +} + +func NewListCourseHandler( + repo domain.CourseRepository, + log *slog.Logger, +) ListCourseHandler { + h := listCourseHandler{ + repo: repo, + } + 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{ + CategoryID: query.CategoryID, + OrganizationID: query.OrganizationID, + Keyword: query.Keyword, + }) + if err != nil { + return nil, fmt.Errorf("listing courses: %w", err) + } + + return courses, nil +} diff --git a/internal/kurious/domain/course.go b/internal/kurious/domain/course.go new file mode 100644 index 0000000..3f8b724 --- /dev/null +++ b/internal/kurious/domain/course.go @@ -0,0 +1,42 @@ +package domain + +import ( + "time" + + "git.loyso.art/frx/kurious/internal/common/nullable" +) + +// Course is a main entity of this project. +type Course struct { + // ID is our unique identifier + ID string + // ExternalID if exists + ExternalID nullable.Value[string] + SourceType SourceType + SourceName nullable.Value[string] + // OrganizationID that provides course. + OrganizationID string + // Link to the course + OriginLink string + ImageLink string + + Name string + // Description of the course. Might be html encoded value. + // Maybe it's worth to add flag about it. + Description string + // FullPrice is a course full price without discount. + FullPrice float64 + // Discount for the course. + Discount float64 + Keywords []string + + // Duration for the course. It will be splitted in values like: + // full month / full day / full hour. + Duration time.Duration + // StartsAt points to time when the course will start. + StartsAt time.Time + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt nullable.Value[time.Time] +} diff --git a/internal/kurious/domain/kurious.go b/internal/kurious/domain/kurious.go new file mode 100644 index 0000000..11a7b9d --- /dev/null +++ b/internal/kurious/domain/kurious.go @@ -0,0 +1,19 @@ +package domain + +// SourceType defines the method this course was added. +type SourceType uint8 + +const ( + // SourceTypeUnset should be treated as invalid value. + SourceTypeUnset SourceType = iota + // SourceTypeManual defines this course was not parsed and + // has been added manually. + SourceTypeManual + // SourceTypeParsed defines this course was parsed + SourceTypeParsed +) + +type Category struct { + ID string + Name string +} diff --git a/internal/kurious/domain/organization.go b/internal/kurious/domain/organization.go new file mode 100644 index 0000000..29d4a31 --- /dev/null +++ b/internal/kurious/domain/organization.go @@ -0,0 +1,22 @@ +package domain + +import ( + "time" + + "git.loyso.art/frx/kurious/internal/common/nullable" +) + +// Organization is an entity that can have coursed. +type Organization struct { + ID string + ExternalID nullable.Value[string] + + Alias string + Name string + Site string + LogoLink string + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt nullable.Value[time.Time] +} diff --git a/internal/kurious/domain/repository.go b/internal/kurious/domain/repository.go new file mode 100644 index 0000000..d60041f --- /dev/null +++ b/internal/kurious/domain/repository.go @@ -0,0 +1,65 @@ +package domain + +import ( + "context" + "time" + + "git.loyso.art/frx/kurious/internal/common/nullable" +) + +type ListCoursesParams struct { + OrganizationID string + CategoryID string + Keyword string +} + +type CreateCourseParams struct { + ID string + Name string + Description string + ExternalID nullable.Value[string] + SourceType SourceType + SourceName nullable.Value[string] + OrganizationID string + OriginLink string + ImageLink string + FullPrice float64 + Discount float64 + Duration time.Duration + StartsAt time.Time +} + +//go:generate mockery --name CourseRepository +type CourseRepository interface { + // List courses by specifid parameters. + List(ctx context.Context, params ListCoursesParams) ([]Course, error) + // Get course by id. + // Should return ErrNotFound in case course not found. + Get(ctx context.Context, id string) (Course, error) + // GetByExternalID finds course by external id. + // Should return ErrNotFound in case course not found. + GetByExternalID(ctx context.Context, id string) (Course, error) + + // Create course, but might fail in case of + // unique constraint violation. + Create(context.Context, CreateCourseParams) (Course, error) + // Delete course by id. + Delete(ctx context.Context, id string) error +} + +type CreateOrganizationParams struct { + ID string + ExternalID nullable.Value[string] + + Alias string + Name string + Site string + LogoLink string +} + +//go:generate mockery --name OrganizationRepository +type OrganizationRepository interface { + Get(ctx context.Context) (Organization, error) + Create(context.Context, CreateOrganizationParams) (Organization, error) + Delete(ctx context.Context, id string) error +} diff --git a/internal/kurious/service/service.go b/internal/kurious/service/service.go new file mode 100644 index 0000000..8a07864 --- /dev/null +++ b/internal/kurious/service/service.go @@ -0,0 +1,59 @@ +package service + +import ( + "context" + "fmt" + "io" + "log/slog" + + "git.loyso.art/frx/kurious/internal/common/config" + "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" +) + +type ApplicationConfig struct { + LogConfig config.LogConfig +} + +type Application struct { + app.Application + + log *slog.Logger + closers []io.Closer +} + +func NewApplication(ctx context.Context, cfg ApplicationConfig) (Application, error) { + log := config.NewSLogger(cfg.LogConfig) + courseadapter, err := adapters.NewYDBCourseRepository() + if err != nil { + return Application{}, fmt.Errorf("making ydb course repository: %w", err) + } + + application := app.Application{ + Commands: app.Commands{ + InsertCourse: command.NewCreateCourseHandler(courseadapter, log), + DeleteCourse: command.NewDeleteCourseHandler(courseadapter, log), + }, + Queries: app.Queries{ + GetCourse: query.NewGetCourseHandler(courseadapter, log), + ListCourses: query.NewListCourseHandler(courseadapter, log), + }, + } + + out := Application{Application: application} + out.closers = append(out.closers, courseadapter) + out.log = log + + return out, nil +} + +func (app Application) Close() { + for _, closer := range app.closers { + err := closer.Close() + if err != nil { + app.log.Error("unable to close closer", slog.Any("err", err)) + } + } +} diff --git a/pkg/utilities/slices/map.go b/pkg/slices/map.go similarity index 100% rename from pkg/utilities/slices/map.go rename to pkg/slices/map.go diff --git a/tags b/tags new file mode 100644 index 0000000..6dd90f5 --- /dev/null +++ b/tags @@ -0,0 +1,331 @@ +!_TAG_EXTRA_DESCRIPTION anonymous /Include tags for non-named objects like lambda/ +!_TAG_EXTRA_DESCRIPTION fileScope /Include tags of file scope/ +!_TAG_EXTRA_DESCRIPTION pseudo /Include pseudo tags/ +!_TAG_EXTRA_DESCRIPTION subparser /Include tags generated by subparsers/ +!_TAG_FIELD_DESCRIPTION epoch /the last modified time of the input file (only for F\/file kind tag)/ +!_TAG_FIELD_DESCRIPTION file /File-restricted scoping/ +!_TAG_FIELD_DESCRIPTION input /input file/ +!_TAG_FIELD_DESCRIPTION name /tag name/ +!_TAG_FIELD_DESCRIPTION pattern /pattern/ +!_TAG_FIELD_DESCRIPTION typeref /Type and name of a variable or typedef/ +!_TAG_FIELD_DESCRIPTION!Go package /the real package specified by the package name/ +!_TAG_FIELD_DESCRIPTION!Go packageName /the name for referring the package/ +!_TAG_FILE_FORMAT 2 /extended format; --format=1 will not append ;" to lines/ +!_TAG_FILE_SORTED 1 /0=unsorted, 1=sorted, 2=foldcase/ +!_TAG_KIND_DESCRIPTION!DTD E,entity /entities/ +!_TAG_KIND_DESCRIPTION!DTD a,attribute /attributes/ +!_TAG_KIND_DESCRIPTION!DTD e,element /elements/ +!_TAG_KIND_DESCRIPTION!DTD n,notation /notations/ +!_TAG_KIND_DESCRIPTION!DTD p,parameterEntity /parameter entities/ +!_TAG_KIND_DESCRIPTION!Go M,anonMember /struct anonymous members/ +!_TAG_KIND_DESCRIPTION!Go P,packageName /name for specifying imported package/ +!_TAG_KIND_DESCRIPTION!Go Y,unknown /unknown/ +!_TAG_KIND_DESCRIPTION!Go a,talias /type aliases/ +!_TAG_KIND_DESCRIPTION!Go c,const /constants/ +!_TAG_KIND_DESCRIPTION!Go f,func /functions/ +!_TAG_KIND_DESCRIPTION!Go i,interface /interfaces/ +!_TAG_KIND_DESCRIPTION!Go m,member /struct members/ +!_TAG_KIND_DESCRIPTION!Go n,methodSpec /interface method specification/ +!_TAG_KIND_DESCRIPTION!Go p,package /packages/ +!_TAG_KIND_DESCRIPTION!Go s,struct /structs/ +!_TAG_KIND_DESCRIPTION!Go t,type /types/ +!_TAG_KIND_DESCRIPTION!Go v,var /variables/ +!_TAG_OUTPUT_EXCMD mixed /number, pattern, mixed, or combineV2/ +!_TAG_OUTPUT_FILESEP slash /slash or backslash/ +!_TAG_OUTPUT_MODE u-ctags /u-ctags or e-ctags/ +!_TAG_OUTPUT_VERSION 0.0 /current.age/ +!_TAG_PARSER_VERSION!DTD 0.0 /current.age/ +!_TAG_PARSER_VERSION!Go 0.0 /current.age/ +!_TAG_PATTERN_LENGTH_LIMIT 96 /0 for no limit/ +!_TAG_PROC_CWD /home/pi/go/src/git.loyso.art/frx/kurious/ // +!_TAG_PROGRAM_AUTHOR Universal Ctags Team // +!_TAG_PROGRAM_NAME Universal Ctags /Derived from Exuberant Ctags/ +!_TAG_PROGRAM_URL https://ctags.io/ /official site/ +!_TAG_PROGRAM_VERSION 6.0.0 /c480d71e/ +!_TAG_ROLE_DESCRIPTION!DTD!element attOwner /attributes owner/ +!_TAG_ROLE_DESCRIPTION!DTD!parameterEntity condition /conditions/ +!_TAG_ROLE_DESCRIPTION!DTD!parameterEntity elementName /element names/ +!_TAG_ROLE_DESCRIPTION!DTD!parameterEntity partOfAttDef /part of attribute definition/ +!_TAG_ROLE_DESCRIPTION!Go!package imported /imported package/ +!_TAG_ROLE_DESCRIPTION!Go!unknown receiverType /receiver type/ +APIGatewayURL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ APIGatewayURL string `json:"apiGatewayUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +Address internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Address string `json:"address"`$/;" m struct:sravni.Contacts typeref:typename:string +Advertising internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Advertising CourseAdvertising `json:"advertising"`$/;" m struct:sravni.Course typeref:typename:CourseAdvertising +AdvertisingOnly internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ AdvertisingOnly bool `json:"advertisingOnly"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:bool +Alias internal/domain/kurious/kurious.go /^ Alias string$/;" m struct:kurious.Organization typeref:typename:string +Alias internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Alias string `json:"alias"`$/;" m struct:sravni.Organization typeref:typename:string +Alias internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Alias string `json:"alias"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:string +Approved internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Approved int `json:"approved"`$/;" m struct:sravni.RatingsInfo typeref:typename:int +BrandingURL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ BrandingURL string `json:"brandingUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +BuildID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ BuildID string `json:"buildId"`$/;" m struct:sravni.PageState typeref:typename:string +BuildTime kurious.go /^func BuildTime() time.Time {$/;" f package:kurious typeref:typename:time.Time +ButtonMobileText internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ButtonMobileText string `json:"buttonMobileText"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +ButtonText internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ButtonText string `json:"buttonText"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +Categories internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Categories struct {$/;" m struct:sravni.InitialReduxState typeref:typename:struct { Data map[string]int `json:"data"`; } +Client internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type Client interface {$/;" i package:sravni +Clone internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^func (p *PageState) Clone() *PageState {$/;" f struct:sravni.PageState typeref:typename:*PageState +Commit kurious.go /^func Commit() string {$/;" f package:kurious typeref:typename:string +ComplexCalculatedRatingValue internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ComplexCalculatedRatingValue float64 `json:"complexCalculatedRatingValue"`$/;" m struct:sravni.RatingsInfo typeref:typename:float64 +Contacts internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Contacts Contacts `json:"contacts"`$/;" m struct:sravni.Organization typeref:typename:Contacts +Contacts internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type Contacts struct {$/;" s package:sravni +Cost internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Cost float64 `json:"cost"`$/;" m struct:sravni.CourseAdvertising typeref:typename:float64 +Course internal/domain/kurious/kurious.go /^type Course struct {$/;" s package:kurious +Course internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type Course struct {$/;" s package:sravni +CourseAdvertising internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type CourseAdvertising struct {$/;" s package:sravni +CourseDiscount internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type CourseDiscount struct {$/;" s package:sravni +CourseImage internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ CourseImage string `json:"courseImage"`$/;" m struct:sravni.Course typeref:typename:string +CoursesThematics internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ CoursesThematics []string `json:"coursesThematics"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:[]string +CoursesThematics internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ CoursesThematics string$/;" m struct:sravni.ListEducationProductsParams typeref:typename:string +Created internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Created time.Time `json:"created"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:time.Time +CreatedAt internal/domain/kurious/kurious.go /^ CreatedAt time.Time$/;" m struct:kurious.Course typeref:typename:time.Time +CreatedAt internal/domain/kurious/kurious.go /^ CreatedAt time.Time$/;" m struct:kurious.Organization typeref:typename:time.Time +Data internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Data struct {$/;" m struct:sravni.ReduxDictionaries typeref:typename:struct { CourseThematics ReduxDictionaryContainer `json:"coursesThematics"`; LearningType ReduxDictionaryContainer `json:"learningType"`; LearningTypeSelection ReduxDictionaryContainer `json:"learningTypeSelection"`; } +Data internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Data struct {$/;" m struct:sravni.ReduxMetadata typeref:typename:struct { Prefooter []ReduxStatePrefooterItem `json:"prefooter"`; } +DateStart internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ DateStart any `json:"dateStart"`$/;" m struct:sravni.Course typeref:typename:any +Debugf internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^func (l restyCtxLogger) Debugf(format string, v ...any) {$/;" f struct:sravni.restyCtxLogger +DeletedAt internal/domain/kurious/kurious.go /^ DeletedAt nullable.Value[time.Time]$/;" m struct:kurious.Course typeref:typename:nullable.Value +DeletedAt internal/domain/kurious/kurious.go /^ DeletedAt nullable.Value[time.Time]$/;" m struct:kurious.Organization typeref:typename:nullable.Value +Description internal/domain/kurious/kurious.go /^ Description string$/;" m struct:kurious.Course typeref:typename:string +Dialog internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Dialog string `json:"dialog"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +Dictionaries internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Dictionaries ReduxDictionaries `json:"dictionaries"`$/;" m struct:sravni.InitialReduxState typeref:typename:ReduxDictionaries +DictionaryFormatFilterNew internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ DictionaryFormatFilterNew []string `json:"dictionaryFormatFilterNew"`$/;" m struct:sravni.Course typeref:typename:[]string +DictionaryLevelFilterNew internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ DictionaryLevelFilterNew []string `json:"dictionaryLevelFilterNew"`$/;" m struct:sravni.Course typeref:typename:[]string +Discount internal/domain/kurious/kurious.go /^ Discount float64$/;" m struct:kurious.Course typeref:typename:float64 +Discount internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Discount CourseDiscount `json:"discount"`$/;" m struct:sravni.Course typeref:typename:CourseDiscount +Duration internal/domain/kurious/kurious.go /^ Duration time.Duration$/;" m struct:kurious.Course typeref:typename:time.Duration +EducationURL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ EducationURL string `json:"educationUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +EndDate internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ EndDate time.Time `json:"endDate"`$/;" m struct:sravni.CourseDiscount typeref:typename:time.Time +EndTime internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ EndTime any `json:"endTime"`$/;" m struct:sravni.CourseDiscount typeref:typename:any +Environment internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Environment string `json:"environment"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +ErrClientNotInited internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ErrClientNotInited domain.SimpleError = "client was not inited"$/;" c package:sravni typeref:typename:domain.SimpleError +ErrNotImplemented internal/domain/error.go /^ ErrNotImplemented SimpleError = "not implemented"$/;" c package:domain typeref:typename:SimpleError +ErrUnexpectedStatus internal/domain/error.go /^ ErrUnexpectedStatus SimpleError = "unexpected status"$/;" c package:domain typeref:typename:SimpleError +Error internal/domain/error.go /^func (err *ValidationError) Error() string {$/;" f struct:domain.ValidationError typeref:typename:string +Error internal/domain/error.go /^func (err SimpleError) Error() string {$/;" f type:domain.SimpleError typeref:typename:string +Errorf internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^func (l restyCtxLogger) Errorf(format string, v ...any) {$/;" f struct:sravni.restyCtxLogger +ExternalID internal/domain/kurious/kurious.go /^ ExternalID nullable.Value[string]$/;" m struct:kurious.Course typeref:typename:nullable.Value +ExternalID internal/domain/kurious/kurious.go /^ ExternalID nullable.Value[string]$/;" m struct:kurious.Organization typeref:typename:nullable.Value +Field internal/domain/error.go /^ Field string$/;" m struct:domain.ValidationError typeref:typename:string +Fields internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Fields []string `json:"fields"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:[]string +Fields internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Fields []field `json:"fields"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:[]field +Fingerprint internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Fingerprint string `json:"fingerPrint,omitempty"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +Full internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Full string `json:"full"`$/;" m struct:sravni.OrganizationName typeref:typename:string +FullPrice internal/domain/kurious/kurious.go /^ FullPrice float64$/;" m struct:kurious.Course typeref:typename:float64 +Gateway internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Gateway string `json:"gatewayUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +Genitive internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Genitive string `json:"genitive"`$/;" m struct:sravni.OrganizationName typeref:typename:string +GetMainPageState internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ GetMainPageState() *PageState$/;" n interface:sravni.Client typeref:typename:*PageState +GetMainPageState internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) GetMainPageState() *PageState {$/;" f struct:sravni.client typeref:typename:*PageState +HasOffersID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ HasOffersID string `json:"hasOffersId"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +ID internal/domain/kurious/kurious.go /^ ID string$/;" m struct:kurious.Organization typeref:typename:string +ID internal/domain/kurious/kurious.go /^ ID string$/;" m struct:kurious.Course typeref:typename:string +ID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ID string `json:"id"`$/;" m struct:sravni.Course typeref:typename:string +ID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ID string `json:"id"`$/;" m struct:sravni.Organization typeref:typename:string +ID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ID string `json:"_id"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:string +ImageLink internal/domain/kurious/kurious.go /^ ImageLink string$/;" m struct:kurious.Course typeref:typename:string +InitialReduxState internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ InitialReduxState InitialReduxState `json:"initialReduxState"`$/;" m struct:sravni.PageStateProperties typeref:typename:InitialReduxState +InitialReduxState internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type InitialReduxState struct {$/;" s package:sravni +IsLabsPartner internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ IsLabsPartner bool `json:"isLabsPartner"`$/;" m struct:sravni.Organization typeref:typename:bool +IsMix internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ IsMix bool `json:"isMix"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:bool +IsPartner internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ IsPartner bool `json:"isPartner"`$/;" m struct:sravni.CourseAdvertising typeref:typename:bool +IsTermApproximately internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ IsTermApproximately bool `json:"isTermApproximately"`$/;" m struct:sravni.Course typeref:typename:bool +Items internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Items []Course `json:"items"`$/;" m struct:sravni.ListEducationProductsResponse typeref:typename:[]Course +Keywords internal/domain/kurious/kurious.go /^ Keywords []string$/;" m struct:kurious.Course typeref:typename:[]string +LabelText internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ LabelText string `json:"labelText"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +LearningType internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ LearningType []string `json:"learningtype"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:[]string +LearningType internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ LearningType string$/;" m struct:sravni.ListEducationProductsParams typeref:typename:string +Learningtype internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Learningtype []string `json:"learningtype"`$/;" m struct:sravni.Course typeref:typename:[]string +License internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ License string `json:"license"`$/;" m struct:sravni.Organization typeref:typename:string +Limit internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Limit int `json:"limit"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:int +Limit internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Limit int$/;" m struct:sravni.ListEducationProductsParams typeref:typename:int +Link internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Link string `json:"link"`$/;" m struct:sravni.Course typeref:typename:string +Link internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type Link struct {$/;" s package:sravni +Links internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Links []Link `json:"links"`$/;" m struct:sravni.ReduxStatePrefooterItem typeref:typename:[]Link +ListEducationProductsParams internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type ListEducationProductsParams struct {$/;" s package:sravni +ListEducationProductsRequest internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type ListEducationProductsRequest struct {$/;" s package:sravni +ListEducationProductsResponse internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type ListEducationProductsResponse struct {$/;" s package:sravni +ListEducationalProducts internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ ListEducationalProducts($/;" n interface:sravni.Client typeref:typename:(result ListEducationProductsResponse, err error) +ListEducationalProducts internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) ListEducationalProducts($/;" f struct:sravni.client typeref:typename:(result ListEducationProductsResponse, err error) +Location internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Location string `json:"location"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +LogoLink internal/domain/kurious/kurious.go /^ LogoLink string$/;" m struct:kurious.Organization typeref:typename:string +Logotypes internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Logotypes struct {$/;" m struct:sravni.Organization typeref:typename:struct { Square string `json:"square"`; Web string `json:"web"`; Android string `json:"android"`; } +Map pkg/utilities/slices/map.go /^func Map[S any, E any](s []S, f func(S) E) []E {$/;" f package:slices typeref:typename:(s []S, f func(S) E) [ +Metadata internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Metadata ReduxMetadata `json:"metadata"`$/;" m struct:sravni.InitialReduxState typeref:typename:ReduxMetadata +MixRepeated internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ MixRepeated bool `json:"mixRepeated"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:bool +Monetization internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Monetization struct {$/;" m struct:sravni.CourseAdvertising typeref:typename:struct { Pixels struct { Click string `json:"click"`; Display string `json:"display"`; } `json:"pixels"`; Kind string `json:"kind"`; } +Name internal/domain/kurious/kurious.go /^ Name string$/;" m struct:kurious.Organization typeref:typename:string +Name internal/domain/kurious/kurious.go /^ Name string$/;" m struct:kurious.Course typeref:typename:string +Name internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Name string `json:"name"`$/;" m struct:sravni.Course typeref:typename:string +Name internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Name OrganizationName `json:"name"`$/;" m struct:sravni.Organization typeref:typename:OrganizationName +Name internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Name string `json:"name"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:string +Name internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Name string `json:"name"`$/;" m struct:sravni.field typeref:typename:string +NewClient internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func NewClient(ctx context.Context, log *slog.Logger, debug bool) (c *client, err error) {$/;" f package:sravni typeref:typename:(c *client, err error) +NewServices internal/infrastructure/interfaceadapters/services.go /^func NewServices() Services {$/;" f package:adapters typeref:typename:Services +NewValidationError internal/domain/error.go /^func NewValidationError(field, reason string) *ValidationError {$/;" f package:domain typeref:typename:*ValidationError +NewValue internal/domain/nullable/value.go /^func NewValue[T any](value T) Value[T] {$/;" f package:nullable typeref:typename:(value T) Value +NewValuePtr internal/domain/nullable/value.go /^func NewValuePtr[T any](value *T) Value[T] {$/;" f package:nullable typeref:typename:(value *T) Value +NotB2B internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ NotB2B string `json:"not-b2b"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +NotSubIsWebinar internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ NotSubIsWebinar string `json:"not-sub-isWebinar"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +OfferHighlightColor internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ OfferHighlightColor string `json:"offerHighlightColor"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +OfferTypes internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ OfferTypes []string `json:"offerTypes"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:[]string +Offset internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Offset int `json:"offset"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:int +Offset internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Offset int$/;" m struct:sravni.ListEducationProductsParams typeref:typename:int +Organization internal/domain/kurious/kurious.go /^type Organization struct {$/;" s package:kurious +Organization internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Organization string `json:"organization"`$/;" m struct:sravni.Course typeref:typename:string +Organization internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type Organization struct {$/;" s package:sravni +OrganizationID internal/domain/kurious/kurious.go /^ OrganizationID string$/;" m struct:kurious.Course typeref:typename:string +OrganizationName internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type OrganizationName struct {$/;" s package:sravni +Organizations internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ Organizations map[string]Organization `json:"organizations"`$/;" m struct:sravni.ListEducationProductsResponse typeref:typename:map[string]Organization +OrgnazationURL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ OrgnazationURL string `json:"organizationsUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +OriginLink internal/domain/kurious/kurious.go /^ OriginLink string$/;" m struct:kurious.Course typeref:typename:string +Page internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Page string `json:"page"`$/;" m struct:sravni.PageState typeref:typename:string +PageState internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type PageState struct {$/;" s package:sravni +PageStateProperties internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type PageStateProperties struct {$/;" s package:sravni +PageStateRuntimeConfig internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type PageStateRuntimeConfig struct {$/;" s package:sravni +ParticipantsCount internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ParticipantsCount int `json:"participantsCount"`$/;" m struct:sravni.RatingsInfo typeref:typename:int +Percent internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Percent int `json:"percent"`$/;" m struct:sravni.CourseDiscount typeref:typename:int +Phone internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Phone []string `json:"phone"`$/;" m struct:sravni.Contacts typeref:typename:[]string +PhoneVerifierURL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ PhoneVerifierURL string `json:"phoneVerifierUrl"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +Prepositional internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Prepositional string `json:"prepositional"`$/;" m struct:sravni.OrganizationName typeref:typename:string +Price internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Price int `json:"price"`$/;" m struct:sravni.Course typeref:typename:int +PriceAll internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ PriceAll int `json:"priceAll"`$/;" m struct:sravni.Course typeref:typename:int +PriceInstallment internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ PriceInstallment int `json:"priceInstallment"`$/;" m struct:sravni.Course typeref:typename:int +ProductName internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ ProductName string `json:"productName,omitempty"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +PromoCode internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ PromoCode string `json:"promoCode"`$/;" m struct:sravni.CourseDiscount typeref:typename:string +PromoCodeType internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ PromoCodeType string `json:"promoCodeType"`$/;" m struct:sravni.CourseDiscount typeref:typename:string +Props internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Props PageStateProperties `json:"props"`$/;" m struct:sravni.PageState typeref:typename:PageStateProperties +Query internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Query map[string]string `json:"query"`$/;" m struct:sravni.PageState typeref:typename:map[string]string +RatingsInfo internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ RatingsInfo RatingsInfo `json:"ratingsInfo"`$/;" m struct:sravni.Organization typeref:typename:RatingsInfo +RatingsInfo internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type RatingsInfo struct {$/;" s package:sravni +Reason internal/domain/error.go /^ Reason string$/;" m struct:domain.ValidationError typeref:typename:string +ReduxDictionaries internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type ReduxDictionaries struct {$/;" s package:sravni +ReduxDictionaryContainer internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type ReduxDictionaryContainer struct {$/;" s package:sravni +ReduxMetadata internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type ReduxMetadata struct {$/;" s package:sravni +ReduxStatePrefooterItem internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type ReduxStatePrefooterItem struct {$/;" s package:sravni +Release internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Release string `json:"release"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +RuntimeConfig internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ RuntimeConfig PageStateRuntimeConfig `json:"runtimeConfig"`$/;" m struct:sravni.PageState typeref:typename:PageStateRuntimeConfig +ServiceName internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ ServiceName string `json:"serviceName"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +Services internal/infrastructure/interfaceadapters/services.go /^type Services struct{}$/;" s package:adapters +Set internal/domain/nullable/value.go /^func (n *Value[T]) Set(value T) {$/;" f unknown:nullable.T +Short internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Short string `json:"short"`$/;" m struct:sravni.OrganizationName typeref:typename:string +SideBarBannerText internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ SideBarBannerText string `json:"sideBarBannerText"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +SimpleError internal/domain/error.go /^type SimpleError string$/;" t package:domain typeref:typename:string +Site internal/domain/kurious/kurious.go /^ Site string$/;" m struct:kurious.Organization typeref:typename:string +SortDirection internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ SortDirection string `json:"sortDirection"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +SortProperty internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ SortProperty string `json:"sortProperty"`$/;" m struct:sravni.ListEducationProductsRequest typeref:typename:string +SourceName internal/domain/kurious/kurious.go /^ SourceName nullable.Value[string]$/;" m struct:kurious.Course typeref:typename:nullable.Value +SourceType internal/domain/kurious/kurious.go /^ SourceType SourceType$/;" m struct:kurious.Course typeref:typename:SourceType +SourceType internal/domain/kurious/kurious.go /^type SourceType uint8$/;" t package:kurious typeref:typename:uint8 +SourceTypeManual internal/domain/kurious/kurious.go /^ SourceTypeManual$/;" c package:kurious +SourceTypeParsed internal/domain/kurious/kurious.go /^ SourceTypeParsed$/;" c package:kurious +SourceTypeUnset internal/domain/kurious/kurious.go /^ SourceTypeUnset SourceType = iota$/;" c package:kurious typeref:type:SourceType +StartsAt internal/domain/kurious/kurious.go /^ StartsAt time.Time$/;" m struct:kurious.Course typeref:typename:time.Time +TimeAllDay internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ TimeAllDay any `json:"timeAllDay"`$/;" m struct:sravni.Course typeref:typename:any +TimeAllHour internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ TimeAllHour any `json:"timeAllHour"`$/;" m struct:sravni.Course typeref:typename:any +TimeAllMonth internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ TimeAllMonth int `json:"timeAllMonth"`$/;" m struct:sravni.Course typeref:typename:int +TimeStart internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ TimeStart any `json:"timeStart"`$/;" m struct:sravni.Course typeref:typename:any +Title internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Title string `json:"title"`$/;" m struct:sravni.Link typeref:typename:string +Title internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Title string `json:"title"`$/;" m struct:sravni.ReduxStatePrefooterItem typeref:typename:string +Token internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Token []struct {$/;" m struct:sravni.CourseAdvertising typeref:typename:[]struct { ID string `json:"_id"`; Token []string `json:"token"`; Updated time.Time `json:"updated"`; V int `json:"__v"`; } +TotalCount internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ TotalCount int `json:"totalCount"`$/;" m struct:sravni.ListEducationProductsResponse typeref:typename:int +TotalCountAdv internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ TotalCountAdv int `json:"totalCountAdv"`$/;" m struct:sravni.ListEducationProductsResponse typeref:typename:int +TrackingType internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ TrackingType string `json:"trackingType"`$/;" m struct:sravni.CourseAdvertising typeref:typename:string +URL internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ URL string `json:"url"`$/;" m struct:sravni.Link typeref:typename:string +Updated internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Updated time.Time `json:"updated"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:time.Time +UpdatedAt internal/domain/kurious/kurious.go /^ UpdatedAt time.Time$/;" m struct:kurious.Course typeref:typename:time.Time +UpdatedAt internal/domain/kurious/kurious.go /^ UpdatedAt time.Time$/;" m struct:kurious.Organization typeref:typename:time.Time +UserID internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ UserID string `json:"userId"`$/;" m struct:sravni.ReduxDictionaryContainer typeref:typename:string +Valid internal/domain/nullable/value.go /^func (n Value[T]) Valid() bool {$/;" f unknown:nullable.T typeref:typename:bool +ValidationError internal/domain/error.go /^type ValidationError struct {$/;" s package:domain +Value internal/domain/nullable/value.go /^func (n Value[T]) Value() T {$/;" f unknown:nullable.T typeref:typename:T +Value internal/domain/nullable/value.go /^type Value[T any] struct {$/;" t package:nullable typeref:typename:[T any] struct { value T; valid bool;} +Value internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ Value string `json:"value"`$/;" m struct:sravni.field typeref:typename:string +Values internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (qs querySet) Values() []string {$/;" f struct:sravni.querySet typeref:typename:[]string +ValutPtr internal/domain/nullable/value.go /^func (n Value[T]) ValutPtr() *T {$/;" f unknown:nullable.T typeref:typename:*T +Version kurious.go /^func Version() string {$/;" f package:kurious typeref:typename:string +Warnf internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^func (l restyCtxLogger) Warnf(format string, v ...any) {$/;" f struct:sravni.restyCtxLogger +WebPath internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ WebPath string `json:"webPath"`$/;" m struct:sravni.PageStateRuntimeConfig typeref:typename:string +WithoutDiscountPrice internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^ WithoutDiscountPrice int `json:"withoutDiscountPrice"`$/;" m struct:sravni.Course typeref:typename:int +action cmd/dev/sravnicli/products.go /^type action interface {$/;" i package:main +adapters internal/infrastructure/interfaceadapters/services.go /^package adapters$/;" p +app cmd/dev/sravnicli/main.go /^func app(ctx context.Context, log *slog.Logger) (exitCode int, err error) {$/;" f package:main typeref:typename:(exitCode int, err error) +asCLIAction cmd/dev/sravnicli/products.go /^func asCLIAction(a action) cli.Action {$/;" f package:main typeref:typename:cli.Action +baseAction cmd/dev/sravnicli/products.go /^ *baseAction$/;" M struct:main.listProductsAction typeref:typename:*baseAction +baseAction cmd/dev/sravnicli/products.go /^type baseAction struct {$/;" s package:main +baseURL internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ baseURL = "https:\/\/www.sravni.ru\/kursy"$/;" c package:sravni +buildTime kurious.go /^ buildTime = ""$/;" v package:kurious +buildTimeParseOnce kurious.go /^var buildTimeParseOnce sync.Once$/;" v package:kurious typeref:typename:sync.Once +buildTimeParsed kurious.go /^ buildTimeParsed = time.Time{}$/;" v package:kurious +cachedMainPageInfo internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ cachedMainPageInfo *PageState$/;" m struct:sravni.client typeref:typename:*PageState +checkClientInited internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) checkClientInited() error {$/;" f struct:sravni.client typeref:typename:error +client cmd/dev/sravnicli/products.go /^ client sravni.Client$/;" m struct:main.baseAction typeref:typename:sravni.Client +client internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type client struct {$/;" s package:sravni +commit kurious.go /^ commit = "unknown"$/;" v package:kurious +context cmd/dev/sravnicli/products.go /^ context() context.Context$/;" n interface:main.action typeref:typename:context.Context +context cmd/dev/sravnicli/products.go /^func (ba baseAction) context() context.Context {$/;" f struct:main.baseAction typeref:typename:context.Context +courseThematic cmd/dev/sravnicli/products.go /^ courseThematic string$/;" m struct:main.listProductsActionParams typeref:typename:string +courseThematicOptName cmd/dev/sravnicli/products.go /^ courseThematicOptName = "course_thematic"$/;" c package:main +courses internal/app/courses/client.go /^package courses$/;" p +ctx cmd/dev/sravnicli/products.go /^ ctx context.Context$/;" m struct:main.baseAction typeref:typename:context.Context +ctx internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^ ctx context.Context$/;" m struct:sravni.restyCtxLogger typeref:typename:context.Context +debugOptName cmd/dev/sravnicli/core.go /^ debugOptName = "verbose"$/;" c package:main +defaultOutput cmd/dev/sravnicli/main.go /^var defaultOutput = os.Stdout$/;" v package:main +defaultProductFields internal/infrastructure/interfaceadapters/courses/sravni/client.go /^var defaultProductFields = must(educationProductFields.exactSubset($/;" v package:sravni +domain internal/domain/error.go /^package domain$/;" p +educationProductFields internal/infrastructure/interfaceadapters/courses/sravni/client.go /^var educationProductFields = newQuerySet($/;" v package:sravni +exactSubset internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (qs querySet) exactSubset(values ...string) ([]string, error) {$/;" f struct:sravni.querySet typeref:typename:([]string, error) +field internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^type field struct {$/;" s package:sravni +findNode internal/infrastructure/interfaceadapters/courses/sravni/helpers.go /^func findNode(parent *html.Node, eq func(*html.Node) (found, deeper bool)) *html.Node {$/;" f package:sravni typeref:typename:*html.Node +getMainPageState internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) getMainPageState(ctx context.Context) (*PageState, error) {$/;" f struct:sravni.client typeref:typename:(*PageState, error) +handle cmd/dev/sravnicli/products.go /^ handle() error$/;" n interface:main.action typeref:typename:error +handle cmd/dev/sravnicli/products.go /^func (a *listProductsAction) handle() error {$/;" f struct:main.listProductsAction typeref:typename:error +handle cmd/dev/sravnicli/products.go /^func (ba *baseAction) handle() error {$/;" f struct:main.baseAction typeref:typename:error +hasValue internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (qs querySet) hasValue(value string) bool {$/;" f struct:sravni.querySet typeref:typename:bool +http internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ http *resty.Client$/;" m struct:sravni.client typeref:typename:*resty.Client +jsonOptName cmd/dev/sravnicli/core.go /^ jsonOptName = "json"$/;" c package:main +kurious internal/domain/kurious/kurious.go /^package kurious$/;" p +kurious internal/domain/kurious/repository.go /^package kurious$/;" p +kurious kurious.go /^package kurious$/;" p +learningType cmd/dev/sravnicli/products.go /^ learningType string$/;" m struct:main.listProductsActionParams typeref:typename:string +learningTypeOptName cmd/dev/sravnicli/products.go /^ learningTypeOptName = "learning_type"$/;" c package:main +limit cmd/dev/sravnicli/products.go /^ limit int$/;" m struct:main.listProductsActionParams typeref:typename:int +limitOption cmd/dev/sravnicli/core.go /^var limitOption = cli.NewOption("limit", "Limits amount of items to return").WithType(cli.TypeIn/;" v package:main +listProductsAction cmd/dev/sravnicli/products.go /^type listProductsAction struct {$/;" s package:main +listProductsActionParams cmd/dev/sravnicli/products.go /^type listProductsActionParams struct {$/;" s package:main +log cmd/dev/sravnicli/products.go /^ log *slog.Logger$/;" m struct:main.baseAction typeref:typename:*slog.Logger +log internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ log *slog.Logger$/;" m struct:sravni.client typeref:typename:*slog.Logger +log internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^ log *slog.Logger$/;" m struct:sravni.restyCtxLogger typeref:typename:*slog.Logger +main cmd/cli/main.go /^func main() {$/;" f package:main +main cmd/cli/main.go /^package main$/;" p +main cmd/dev/sravnicli/core.go /^package main$/;" p +main cmd/dev/sravnicli/main.go /^func main() {$/;" f package:main +main cmd/dev/sravnicli/main.go /^package main$/;" p +main cmd/dev/sravnicli/products.go /^package main$/;" p +makeEducationURL internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) makeEducationURL(path string) string {$/;" f struct:sravni.client typeref:typename:string +makeLogger cmd/dev/sravnicli/core.go /^func makeLogger(options map[string]string) *slog.Logger {$/;" f package:main typeref:typename:*slog.Logger +makeSravniClient cmd/dev/sravnicli/core.go /^func makeSravniClient(ctx context.Context, log *slog.Logger, options map[string]string) (sravni./;" f package:main typeref:typename:(sravni.Client, error) +mappedValues internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ mappedValues map[string]struct{}$/;" m struct:sravni.querySet typeref:typename:map[string]struct{} +must internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func must[T any](t T, err error) T {$/;" f package:sravni typeref:typename:(t T, err error) T +newBaseAction cmd/dev/sravnicli/products.go /^func newBaseAction(ctx context.Context) *baseAction {$/;" f package:main typeref:typename:*baseAction +newListProductAction cmd/dev/sravnicli/products.go /^func newListProductAction(ctx context.Context) cli.Action {$/;" f package:main typeref:typename:cli.Action +newQuerySet internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func newQuerySet(values ...string) querySet {$/;" f package:sravni typeref:typename:querySet +nullable internal/domain/nullable/value.go /^package nullable$/;" p +offset cmd/dev/sravnicli/products.go /^ offset int$/;" m struct:main.listProductsActionParams typeref:typename:int +offsetOption cmd/dev/sravnicli/core.go /^var offsetOption = cli.NewOption("offset", "Offsets items to return").WithType(cli.TypeInt)$/;" v package:main +params cmd/dev/sravnicli/products.go /^ params listProductsActionParams$/;" m struct:main.listProductsAction typeref:typename:listProductsActionParams +parse cmd/dev/sravnicli/products.go /^ parse(args []string, options map[string]string) error$/;" n interface:main.action typeref:typename:error +parse cmd/dev/sravnicli/products.go /^func (a *listProductsAction) parse(args []string, options map[string]string) error {$/;" f struct:main.listProductsAction typeref:typename:error +parse cmd/dev/sravnicli/products.go /^func (ba *baseAction) parse(_ []string, options map[string]string) (err error) {$/;" f struct:main.baseAction typeref:typename:(err error) +parsePageState internal/infrastructure/interfaceadapters/courses/sravni/client.go /^func (c *client) parsePageState(ctx context.Context, body io.Reader) (*PageState, error) {$/;" f struct:sravni.client typeref:typename:(*PageState, error) +querySet internal/infrastructure/interfaceadapters/courses/sravni/client.go /^type querySet struct {$/;" s package:sravni +restyCtxLogger internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^type restyCtxLogger struct {$/;" s package:sravni +setupAPICommand cmd/dev/sravnicli/products.go /^func setupAPICommand(ctx context.Context) cli.Command {$/;" f package:main typeref:typename:cli.Command +setupCLI cmd/dev/sravnicli/main.go /^func setupCLI(ctx context.Context) cli.App {$/;" f package:main typeref:typename:cli.App +slices pkg/utilities/slices/map.go /^package slices$/;" p +sravni internal/infrastructure/interfaceadapters/courses/sravni/client.go /^package sravni$/;" p +sravni internal/infrastructure/interfaceadapters/courses/sravni/entities.go /^package sravni$/;" p +sravni internal/infrastructure/interfaceadapters/courses/sravni/helpers.go /^package sravni$/;" p +sravni internal/infrastructure/interfaceadapters/courses/sravni/logger.go /^package sravni$/;" p +validCourseThematics internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ validCourseThematics querySet$/;" m struct:sravni.client typeref:typename:querySet +validLearningTypes internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ validLearningTypes querySet$/;" m struct:sravni.client typeref:typename:querySet +values internal/infrastructure/interfaceadapters/courses/sravni/client.go /^ values []string$/;" m struct:sravni.querySet typeref:typename:[]string +version kurious.go /^ version = "unknown"$/;" v package:kurious