diff --git a/.gitignore b/.gitignore index 49ec817fb5545fecadd2ddbdd2c4add7c0c79d3e..88b5cec8dafb2d4c5e45e0d5dbcfe52f15092b13 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ go.work var/ tmp/ +# HARMONY specific config/**/*.local.toml +files/**/* +!files/.gitkeep .idea diff --git a/config/eiffel.toml b/config/eiffel.toml new file mode 100644 index 0000000000000000000000000000000000000000..5f606d0e32a1341c50e2e8ac2819adc0f6c12809 --- /dev/null +++ b/config/eiffel.toml @@ -0,0 +1,2 @@ +[output] +base_dir = "files/eiffel" \ No newline at end of file diff --git a/files/.gitkeep b/files/.gitkeep new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/app/eiffel/eiffel.go b/src/app/eiffel/eiffel.go new file mode 100644 index 0000000000000000000000000000000000000000..b6e3e22010c918c535434baebc8c90870e79f4f9 --- /dev/null +++ b/src/app/eiffel/eiffel.go @@ -0,0 +1,7 @@ +// Package eiffel contains necessary functionality for the Elicitation Interface for eFFective Language (EIFFEL). +package eiffel + +// Cfg is EIFFEL's configuration struct. This can be used to unmarshal a TOML configuration file into. +type Cfg struct { + Output OutputCfg `toml:"output"` +} diff --git a/src/app/eiffel/output.go b/src/app/eiffel/output.go new file mode 100644 index 0000000000000000000000000000000000000000..a373a8ffa0819c28d939e5e7737daeae1098cce6 --- /dev/null +++ b/src/app/eiffel/output.go @@ -0,0 +1,202 @@ +package eiffel + +import ( + "encoding/csv" + "fmt" + "github.com/org-harmony/harmony/src/app/template/parser" + "github.com/org-harmony/harmony/src/app/user" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +// OutputWriter defines the common interface for all EIFFEL's output writers. +// Most prominently, this is the CSVWriter. +type OutputWriter interface { + // WriteHeaderRow writes the header row to the output file. The header row contains the names of the columns. + // This is most important for OutputWriter implementations that write to files. + // The header row should be written only once per file. + WriteHeaderRow() error + // WriteRow writes a row to the output file. The row contains the values of the columns. + // The values are taken from the parser.ParsingResult and the user.User. + WriteRow(pr parser.ParsingResult, usr *user.User) error +} + +// OutputCfg contains the configuration for the output of EIFFEL. +type OutputCfg struct { + // BaseDir is the base directory for the output of EIFFEL. + // Each requirement will be written to an output file lying in a (sub-)directory of the base directory. + // EIFFEL will create the (sub-)directories if they do not exist. + // BaseDir should be "files/eiffel". + BaseDir string `toml:"base_dir" env:"EIFFEL_OUTPUT_BASE_DIR" hvalidate:"required"` +} + +// CSVWriter is an OutputWriter that writes to a CSV-file. +// The CSV-file contains the following columns: Requirement, Date, Time, Template, Variant, Template Version, Author. +// The CSVWriter requires a file handle to the output file. Ensure sufficient permissions for the file. +type CSVWriter struct { + file *os.File +} + +// DirSearch searches the baseDir for (sub-)directories containing the query string. +// The search is case-insensitive. Returns a slice of matching (sub-)directories. +// If no (sub-)directories match the query, an empty slice is returned. +// The returned slice contains the relative path to the (sub-)directories from the baseDir. +func DirSearch(baseDir string, query string) ([]string, error) { + var dirs []string + queryLower := strings.ToLower(query) + + err := filepath.WalkDir(baseDir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if !d.IsDir() { + return nil + } + + if path == baseDir { + return nil + } + + visiblePath := strings.TrimPrefix(path, baseDir+"/") + if strings.Contains(strings.ToLower(d.Name()), queryLower) || strings.Contains(strings.ToLower(visiblePath), queryLower) { + dirs = append(dirs, visiblePath) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return dirs, nil +} + +// FileSearch searches the base directory + a specified sub-path for .csv-files containing the query string in their name. +// Only files with the .csv-extension are considered. The search is case-insensitive. Returns a slice of matching files. +func FileSearch(baseDir string, subPath string, query string) ([]string, error) { + var files []string + queryLower := strings.ToLower(query) + + if subPath != "" { + subPath = filepath.Clean(subPath) + } + + err := filepath.WalkDir(filepath.Join(baseDir, subPath), func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + filenameExt := d.Name() + ext := filepath.Ext(filenameExt) + if strings.ToLower(ext) != ".csv" { + return nil + } + + name := strings.TrimSuffix(filenameExt, ext) + if strings.Contains(strings.ToLower(name), queryLower) { + files = append(files, name) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return files, nil +} + +// BuildDirPath takes in a base dir + sub-path and returns the sanitized, full dir-path. +// The sub-path can be empty. The sub-path is sanitized before being used in the dir-path. +// The baseDir is not sanitized! It is assumed to be safe. +// Every character that is not a letter, number, underscore or hyphen is replaced by an underscore. +func BuildDirPath(baseDir, subPath string) string { + return filepath.Join(baseDir, filepath.Clean(SanitizeFilepath(subPath))) +} + +// BuildFilename takes in a filename (w/o extension) and returns the sanitized filename with the .csv-extension. +func BuildFilename(filename string) string { + return fmt.Sprintf("%s.csv", SanitizeFilepath(filename)) +} + +// SanitizeFilepath takes in a filepath and returns the sanitized filepath. +// Every character that is not a letter, number, underscore or hyphen is replaced by an underscore. +func SanitizeFilepath(path string) string { + reg := regexp.MustCompile(`[^/a-zA-Z0-9_-]+`) + + return reg.ReplaceAllString(path, "_") +} + +// CreateIfNotExists creates the specified file in a directory and all the necessary directories if they do not exist. +// The file is created with the specified permissions. If the file already exists, nothing happens. +// CreateIfNotExists returns the file, a boolean indicating whether the file was created and an error. +func CreateIfNotExists(dirPath, filename string, perm os.FileMode) (*os.File, bool, error) { + if _, err := os.Stat(dirPath); os.IsNotExist(err) { + if err := os.MkdirAll(dirPath, perm); err != nil { + return nil, false, err + } + } + + filePath := filepath.Join(dirPath, filename) + if _, err := os.Stat(filePath); os.IsNotExist(err) { + if file, err := os.Create(filePath); err == nil { + return file, true, nil + } + } + + file, err := os.OpenFile(filePath, os.O_RDWR|os.O_APPEND, perm) + + return file, false, err +} + +// WriteHeaderRow writes the header row to the output file. The header row contains the names of the columns. +// The columns are: Requirement, Date, Time, Template, Variant, Template Version, Author. +func (c *CSVWriter) WriteHeaderRow() error { + header := []string{"Requirement", "Date", "Time", "Template", "Variant", "Template Version", "Author"} + writer := csv.NewWriter(c.file) + + err := writer.Write(header) + if err != nil { + return err + } + + writer.Flush() + + return nil +} + +// WriteRow writes a row to the output file. The row contains the values of the columns. +// The values are the requirement, the current date and time, the template name, the variant name, the template version and the author. +func (c *CSVWriter) WriteRow(pr parser.ParsingResult, usr *user.User) error { + now := time.Now() + row := []string{ + pr.Requirement, + now.Format("2006-01-02"), + now.Format("15:04:05"), + pr.TemplateName, + pr.VariantName, + pr.TemplateVersion, + fmt.Sprintf("%s %s", usr.Firstname, usr.Lastname), + } + + writer := csv.NewWriter(c.file) + + err := writer.Write(row) + if err != nil { + return err + } + + writer.Flush() + + return nil +} diff --git a/src/app/eiffel/parser.go b/src/app/eiffel/parser.go index 6b62a642d307212fa3834ec72187309cb4a516c7..9b4d22c1385d0b68050a08ed4bc4d721fef8f892 100644 --- a/src/app/eiffel/parser.go +++ b/src/app/eiffel/parser.go @@ -181,8 +181,7 @@ func RuleParsers() *RuleParserProvider { // Parse implements the template.ParsableTemplate interface for the BasicTemplate. It is used to parse requirements in the form of segments. // Each segment is a part of the requirement that is to be parsed. For the EIFFEL basic template (EBT), each segment is an input from auto-generated // input field based on the rules defined in the template. Therefore, each segment will be validated by the corresponding rule. -// -// This function might panic if the template is not valid. Therefore, it is recommended to validate the template before parsing requirements. +// It is recommended to validate the template before parsing requirements. // // The parsing process is as follows: // 1. Prepare segments by trimming whitespaces from the input string and indexing them. @@ -200,10 +199,12 @@ func RuleParsers() *RuleParserProvider { // Also, it is possible that a parsed rule contains warnings those are not downgraded and the parsing result will not be flawless. func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvider, variantName string, segments ...parser.ParsingSegment) (parser.ParsingResult, error) { result := parser.ParsingResult{ - TemplateID: bt.ID, - TemplateType: BasicTemplateType, - TemplateName: bt.Name, - VariantName: variantName, + TemplateID: bt.ID, + TemplateType: BasicTemplateType, + TemplateVersion: bt.Version, + TemplateName: bt.Name, + VariantName: variantName, + Requirement: "", } indexedSegments := prepareSegments(segments) @@ -211,6 +212,7 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi if !ok { return result, ErrInvalidVariant } + result.VariantName = variant.Name for _, ruleName := range variant.Rules { rule, ok := bt.Rules[ruleName] @@ -219,9 +221,9 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi } segment, ok := indexedSegments[ruleName] - if !ok && !rule.Optional { + if (!ok || segment.Value == "") && !rule.Optional { result.Errors = append(result.Errors, parser.ParsingLog{ - Segment: nil, + Segment: &parser.ParsingSegment{Name: ruleName}, Level: parser.ParsingLogLevelError, Message: "eiffel.parser.error.missing-segment", TranslationArgs: []string{ @@ -234,7 +236,7 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi continue } - if !ok { + if !ok || segment.Value == "" { continue // rule is optional and segment is missing -> ignore } @@ -248,6 +250,19 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi return result, err } + be := " " + af := "" + before, bOk := rule.Extra["before"] + after, aOk := rule.Extra["after"] + if bStr, ok := before.(string); ok && bOk { + be = bStr + } + if aStr, ok := after.(string); ok && aOk { + af = aStr + } + + result.Requirement += be + strings.TrimSpace(segment.Value) + af + for _, log := range parsingLogs { switch log.Level { case parser.ParsingLogLevelError: @@ -266,6 +281,8 @@ func (bt *BasicTemplate) Parse(ctx context.Context, ruleParsers *RuleParserProvi } } + result.Requirement = strings.TrimSpace(result.Requirement) + return result, nil } diff --git a/src/app/eiffel/service.go b/src/app/eiffel/service.go index b70059d6a2ae78942c61b6467007910d6b0f88e5..164e263d5bd74814ac53f3233e979754d3cbb466 100644 --- a/src/app/eiffel/service.go +++ b/src/app/eiffel/service.go @@ -5,7 +5,10 @@ import ( "encoding/json" "github.com/google/uuid" "github.com/org-harmony/harmony/src/app/template" + "github.com/org-harmony/harmony/src/app/template/parser" "github.com/org-harmony/harmony/src/core/validation" + "net/http" + "strings" ) // TemplateDisplayTypes returns a map of rule names to display types. The rule names are the keys of the BasicTemplate.Rules map. @@ -100,3 +103,52 @@ func TemplateFormFromRequest( TemplateID: templateUUID, }, nil } + +// SegmentMapFromRequest parses the segments from the request and returns a map of segment names to values. +// The length parameter is used to initialize the map with a given length. If the length is 0, the map will be +// initialized with a length of 0, no error will occur. The length is only used for pre-allocation. +// +// Segments are expected to be in the form of "segment-<name>". +// +// Use SegmentMapToSegments to convert the map into a slice of ParsingSegments. +func SegmentMapFromRequest(request *http.Request, length int) (map[string]string, error) { + err := request.ParseForm() + if err != nil { + return nil, err + } + + var segments map[string]string + if length > 0 { + segments = make(map[string]string, length) + } else { + segments = make(map[string]string) + } + + for name, values := range request.Form { + if !strings.HasPrefix(name, "segment-") { + continue + } + + if len(values) < 1 { + continue + } + + segments[strings.TrimPrefix(name, "segment-")] = values[0] + } + + return segments, nil +} + +// SegmentMapToSegments converts a map of segment names to values into a slice of ParsingSegments. +// The order of the segments in the slice is not guaranteed. The map can be generated using SegmentMapFromRequest. +func SegmentMapToSegments(segments map[string]string) []parser.ParsingSegment { + parsingSegments := make([]parser.ParsingSegment, 0, len(segments)) + for name, value := range segments { + parsingSegments = append(parsingSegments, parser.ParsingSegment{ + Name: name, + Value: value, + }) + } + + return parsingSegments +} diff --git a/src/app/eiffel/web.go b/src/app/eiffel/web.go index e1ad9ee651633a1b925d6437b0676e0ff1aa0789..381ba3a71e5fb6b81fe4df0aebb6abad2a555bce 100644 --- a/src/app/eiffel/web.go +++ b/src/app/eiffel/web.go @@ -6,7 +6,9 @@ import ( "fmt" "github.com/google/uuid" "github.com/org-harmony/harmony/src/app/template" + "github.com/org-harmony/harmony/src/app/template/parser" "github.com/org-harmony/harmony/src/app/user" + "github.com/org-harmony/harmony/src/core/config" "github.com/org-harmony/harmony/src/core/event" "github.com/org-harmony/harmony/src/core/hctx" "github.com/org-harmony/harmony/src/core/persistence" @@ -14,6 +16,7 @@ import ( "github.com/org-harmony/harmony/src/core/validation" "github.com/org-harmony/harmony/src/core/web" "net/http" + "path/filepath" "strings" ) @@ -33,6 +36,10 @@ var ( ErrTemplateNotFound = errors.New("eiffel.elicitation.template.not-found") // ErrTemplateVariantNotFound will be displayed to the user if the template variant could not be found. ErrTemplateVariantNotFound = errors.New("eiffel.elicitation.template.variant.not-found") + // ErrInvalidFilepath will be displayed to the user if the filepath is invalid. + ErrInvalidFilepath = errors.New("eiffel.elicitation.output.file.path-invalid") + // ErrCouldNotCreateFile will be displayed to the user if the file could not be created. + ErrCouldNotCreateFile = errors.New("eiffel.elicitation.output.file.create-failed") ) // TemplateDisplayType specifies how a rule should be displayed in the UI. @@ -54,6 +61,13 @@ type TemplateFormData struct { TemplateID uuid.UUID // CopyAfterParse is a flag indicating if the user wants to copy the parsed requirement to the clipboard. CopyAfterParse bool + // ParsingResult is the result of the parsing process. This can be empty if no parsing was done yet. + ParsingResult *parser.ParsingResult + // SegmentMap is a map of rule names to their corresponding segment value. + // This is used to fill the segments with their values after parsing. + SegmentMap map[string]string + // OutputFormData is the form data for the output directory and file. + OutputFormData web.BaseTemplateData } // SearchTemplateData contains templates to render as search results and a flag indicating if the query was too short. @@ -62,7 +76,17 @@ type SearchTemplateData struct { QueryTooShort bool } +// OutputFormData is the form data for the output directory and file search form. +// It is used to fill the form with the previously selected values. +type OutputFormData struct { + OutputDir string + OutputFile string +} + func RegisterController(appCtx *hctx.AppCtx, webCtx *web.Ctx) { + cfg := Cfg{} + util.Ok(config.C(&cfg, config.From("eiffel"), config.Validate(appCtx.Validator))) + // TODO move this to module init when module manager is implemented (see subscribeEvents) subscribeEvents(appCtx) @@ -77,8 +101,10 @@ func RegisterController(appCtx *hctx.AppCtx, webCtx *web.Ctx) { router.Post("/eiffel/elicitation/templates/search", searchTemplate(appCtx, webCtx).ServeHTTP) router.Get("/eiffel/elicitation/{templateID}", elicitationTemplate(appCtx, webCtx, true).ServeHTTP) router.Get("/eiffel/elicitation/{templateID}/{variant}", elicitationTemplate(appCtx, webCtx, false).ServeHTTP) - router.Post("/eiffel/elicitation/{templateID}/{variant}", parseRequirement(appCtx, webCtx).ServeHTTP) - router.Get("/eiffel/elicitation/output/file/form", outputFileForm(appCtx, webCtx).ServeHTTP) + router.Post("/eiffel/elicitation/{templateID}/{variant}", parseRequirement(appCtx, webCtx, cfg).ServeHTTP) + router.Post("/eiffel/elicitation/output/search", outputSearchForm(appCtx, webCtx, cfg).ServeHTTP) + router.Post("/eiffel/elicitation/output/dir/search", outputSearchDir(appCtx, webCtx, cfg).ServeHTTP) + router.Post("/eiffel/elicitation/output/file/search", outputFileSearch(appCtx, webCtx, cfg).ServeHTTP) } func subscribeEvents(appCtx *hctx.AppCtx) { @@ -232,14 +258,144 @@ func elicitationTemplate(appCtx *hctx.AppCtx, webCtx *web.Ctx, defaultFirstVaria }) } -func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler { +func parseRequirement(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler { + templateRepository := util.UnwrapType[template.Repository](appCtx.Repository(template.RepositoryName)) + + return web.NewController(appCtx, webCtx, func(io web.IO) error { + request := io.Request() + ctx := request.Context() + parsers := RuleParsers() + + templateID := web.URLParam(request, "templateID") + variant := web.URLParam(request, "variant") + outputDir := BuildDirPath(cfg.Output.BaseDir, request.FormValue("elicitationOutputDir")) + outputFile := BuildFilename(request.FormValue("elicitationOutputFile")) + + formData, err := TemplateFormFromRequest( + ctx, + templateID, + variant, + templateRepository, + parsers, + appCtx.Validator, + false, + ) + if err != nil { + return io.InlineError(err) + } + + segmentMap, err := SegmentMapFromRequest(request, len(formData.Variant.Rules)) + if err != nil { + return io.InlineError(web.ErrInternal) + } + formData.SegmentMap = segmentMap + + parsingResult, err := formData.Template.Parse(ctx, parsers, formData.VariantKey, SegmentMapToSegments(segmentMap)...) + formData.ParsingResult = &parsingResult + + var s []string + if parsingResult.Flawless() { + s = []string{"eiffel.elicitation.parse.flawless-success"} + } else if parsingResult.Ok() { + s = []string{"eiffel.elicitation.parse.success"} + } + + if parsingResult.Ok() { + usr := user.MustCtxUser(ctx) + csv, created, err := CreateIfNotExists(outputDir, outputFile, 0750) + if err != nil { + return io.InlineError(ErrCouldNotCreateFile, err) + } + + writer := CSVWriter{file: csv} + if created { + err := writer.WriteHeaderRow() + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + } + + err = writer.WriteRow(parsingResult, usr) + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + } + + return io.Render(web.NewFormData(formData, s, err), "eiffel.elicitation.form", "eiffel/_form-elicitation.go.html") + }) +} + +func outputSearchForm(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + request := io.Request() + err := request.ParseForm() + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + + rawDir := request.FormValue("output-dir") + dir := BuildDirPath(cfg.Output.BaseDir, rawDir) + rawFile := request.FormValue("output-file") + file := BuildFilename(rawFile) + + path := filepath.Join(dir, file) + if path == "" { + return io.InlineError(ErrInvalidFilepath) + } + + csv, created, err := CreateIfNotExists(dir, file, 0750) + if err != nil { + return io.InlineError(ErrCouldNotCreateFile, err) + } + + if created { + writer := CSVWriter{file: csv} + err = writer.WriteHeaderRow() + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + } + + return io.Render(web.NewFormData(OutputFormData{ + OutputDir: rawDir, + OutputFile: rawFile, + }, []string{"eiffel.elicitation.output.file.success"}), "eiffel.elicitation.output-file.form", "eiffel/_form-output-file.go.html") + }) +} + +func outputSearchDir(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler { return web.NewController(appCtx, webCtx, func(io web.IO) error { - return io.Render(nil, "eiffel.elicitation.form", "eiffel/_form-elicitation.go.html") + request := io.Request() + err := request.ParseForm() + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + + query := request.FormValue("output-dir") + dirs, err := DirSearch(cfg.Output.BaseDir, query) + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + + return io.Render(dirs, "eiffel.elicitation.output.dir.search-result", "eiffel/_output-dir-search-result.go.html") }) } -func outputFileForm(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler { +func outputFileSearch(appCtx *hctx.AppCtx, webCtx *web.Ctx, cfg Cfg) http.Handler { return web.NewController(appCtx, webCtx, func(io web.IO) error { - return io.Render(nil, "eiffel.elicitation.output-file.form", "eiffel/_form-output-file.go.html") + request := io.Request() + err := request.ParseForm() + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + + query := request.FormValue("output-file") + dir := request.FormValue("output-dir") + files, err := FileSearch(cfg.Output.BaseDir, dir, query) + if err != nil { + return io.InlineError(web.ErrInternal, err) + } + + return io.Render(files, "eiffel.elicitation.output.file.search-result", "eiffel/_output-file-search-result.go.html") }) } diff --git a/src/app/template/parser/parser.go b/src/app/template/parser/parser.go index 3ebecafb907fa022cd6198fe9505307198c7b96e..afae82cb4d1aff8c88be07ea1192c6165b6451d6 100644 --- a/src/app/template/parser/parser.go +++ b/src/app/template/parser/parser.go @@ -28,13 +28,15 @@ type ParsingSegment struct { // ParsingResult is the result of parsing a requirement using a template. type ParsingResult struct { - TemplateID string - TemplateType string - TemplateName string - VariantName string - Errors []ParsingLog - Warnings []ParsingLog - Notices []ParsingLog + TemplateID string + TemplateType string + TemplateVersion string + TemplateName string + VariantName string + Requirement string + Errors []ParsingLog + Warnings []ParsingLog + Notices []ParsingLog } // ParsingLog is a log entry of a parsing result. It contains the segment that was parsed, the level of the log and a message. @@ -77,3 +79,19 @@ func (r ParsingResult) Ok() bool { func (r ParsingResult) Flawless() bool { return len(r.Errors) == 0 && len(r.Warnings) == 0 } + +// ViolationsForRule returns all violations (errors) for a given rule. +// This can be used to check if a rule was violated and what to display to the user. +// Warnings and Notices are not considered violations and will be displayed to the user as general information, not per rule. +func (r ParsingResult) ViolationsForRule(rule string) []ParsingLog { + var violations []ParsingLog + for _, log := range r.Errors { + if log.Segment.Name != rule { + continue + } + + violations = append(violations, log) + } + + return violations +} diff --git a/src/cmd/web/main.go b/src/cmd/web/main.go index 862d69c70d10e8907667ca50280b7b1d7d269f32..f0bbfd8010ca4d74c851b928d44cbcd3d64532e7 100644 --- a/src/cmd/web/main.go +++ b/src/cmd/web/main.go @@ -27,6 +27,7 @@ import ( // TODO add utilities for easier testing of the web layer // TODO improve UI/UX/Design (styling, css, scss) // TODO add more loading indications, especially for loading body changes +// TODO improve user logged out handling during requests/responses and general site interaction/navigation func main() { logger := trace.NewLogger() diff --git a/src/core/trans/trans.go b/src/core/trans/trans.go index 772822b455ddcf62e1ffb173a1524d3b7500b35c..615e5a58fbaf076dfbb5e379fb8a883080fcae88 100644 --- a/src/core/trans/trans.go +++ b/src/core/trans/trans.go @@ -69,8 +69,8 @@ type HTranslatorProvider struct { // HTranslatorOption is a functional option for the HTranslator. type HTranslatorOption func(*HTranslator) -// Error is an interface for errors that can be translated. -type Error interface { +// Translatable is an interface for errors that can be translated. +type Translatable interface { Translate(Translator) string } @@ -138,6 +138,10 @@ func NewTranslator(opts ...HTranslatorOption) Translator { // T translates a string. func (t *HTranslator) T(s string) string { + if t == nil { + return s + } + transS, ok := t.translations[s] if !ok { return s @@ -153,6 +157,10 @@ func (t *HTranslator) T(s string) string { // // This parsing of args is done by the ArgsAsMap function. func (t *HTranslator) Tf(s string, args ...string) string { + if t == nil { + return s + } + var err error s = t.T(s) hash := md5.New() @@ -187,6 +195,10 @@ func (t *HTranslator) Tf(s string, args ...string) string { // Locale returns the locale the translator translates to. func (t *HTranslator) Locale() *Locale { + if t == nil { + return nil + } + return t.locale } diff --git a/src/core/web/ui.go b/src/core/web/ui.go index 4097e85c25635dbcc59b9174755ab83993840b29..51f8de0933eb2b53f1aee0f8213dd82c4cd4a0a8 100644 --- a/src/core/web/ui.go +++ b/src/core/web/ui.go @@ -444,6 +444,7 @@ func (d *FormData[T]) ValidationErrorsForField(field string) []validation.Error } // AllValidationErrors returns all validation errors for all fields. +// If you want to display all errors to the user use AllViolations instead. func (d *FormData[T]) AllValidationErrors() []validation.Error { var errs []validation.Error for _, fieldErrs := range d.Violations { @@ -458,27 +459,20 @@ func (d *FormData[T]) AllValidationErrors() []validation.Error { return errs } -// AllTranslatableErrors returns all errors for all fields that can be read as trans.Error. -// This is useful for translating errors before displaying them to the user. -func (d *FormData[T]) AllTranslatableErrors() []trans.Error { - var errs []trans.Error - for _, fieldErrs := range d.Violations { - for _, err := range fieldErrs { - var t trans.Error - if errors.As(err, &t) { - errs = append(errs, t) - } - } - } - - return errs -} - // AllViolations returns all errors for all fields. They can then be used to display all errors to the user. +// +// Important: AllViolations does *not* return any validation errors. Use AllValidationErrors for that. +// ValidationErrors are filtered out because they are usually displayed to the user in a different way than other errors. func (d *FormData[T]) AllViolations() []error { var errs []error for _, fieldErrs := range d.Violations { - errs = append(errs, fieldErrs...) + for _, err := range fieldErrs { + if errors.Is(err, &validation.Error{}) { + continue + } + + errs = append(errs, err) + } } return errs @@ -564,13 +558,16 @@ func makeTemplateTranslatable(ctx context.Context, t *template.Template) error { "tf": func(s string, args ...string) string { return translator.Tf(s, args...) }, - "tErrs": func(errs []trans.Error) []string { - var s []string - for _, err := range errs { - s = append(s, err.Translate(translator)) + "tryTranslate": func(t any) string { + if s, ok := t.(string); ok { + return translator.T(s) } - return s + if t, ok := t.(trans.Translatable); ok { + return t.Translate(translator) + } + + return fmt.Sprintf("%s", t) }, }) @@ -580,6 +577,9 @@ func makeTemplateTranslatable(ctx context.Context, t *template.Template) error { // templateFuncs returns a template.FuncMap containing basic template functions. func templateFuncs(ui *UICfg) template.FuncMap { return template.FuncMap{ + "add": func(a, b int) int { + return a + b + }, "asset": func(filename string) string { return filepath.Join(ui.AssetsUri, filename) }, @@ -592,13 +592,12 @@ func templateFuncs(ui *UICfg) template.FuncMap { "tf": func(s string, args ...string) string { return s }, - "tErrs": func(errs []error) []string { - var s []string - for _, err := range errs { - s = append(s, err.Error()) + "tryTranslate": func(t any) string { + if s, ok := t.(string); ok { + return s } - return s + return fmt.Sprintf("%s", t) }, } } diff --git a/templates/base/layout.go.html b/templates/base/layout.go.html index 673027d4f17985d2d612d2fc7ce5f071b939d722..9c3b1fad56de91cf9db7d6dce850cbf398c3b3fe 100644 --- a/templates/base/layout.go.html +++ b/templates/base/layout.go.html @@ -23,7 +23,7 @@ {{ block "content-container" . }} <section class="section content-section mt-3"> - <div class="content-container container col-8"> + <div class="content-container container"> <div id="content"> {{ template "content" . }} </div> diff --git a/templates/eiffel/_elicitation-template.go.html b/templates/eiffel/_elicitation-template.go.html index ef8928fd0217862f4d861ebfef236015c14a9084..0ba7f4acc62badd4a8e67df54dc9c861467bc294 100644 --- a/templates/eiffel/_elicitation-template.go.html +++ b/templates/eiffel/_elicitation-template.go.html @@ -9,13 +9,11 @@ <div class="row"> <div class="col"> {{ range $key, $variant := .Data.Form.Template.Variants }} - <input id="eiffelVariant-{{ $key }}" - hx-get="/eiffel/elicitation/{{ $templateID }}/{{ $key }}" - hx-target="#eiffelElicitationTemplate" - type="radio" name="options-base" - {{ if eq $variantKey $key }}checked{{ end }} - autocomplete="off" class="btn-check"/> - <label class="btn" for="eiffelVariant-{{ $key }}">{{ $variant.Name }}</label> + <button hx-get="/eiffel/elicitation/{{ $templateID }}/{{ $key }}" + hx-target="#eiffelElicitationTemplate" + class="btn {{ if eq $variantKey $key }}btn-outline-primary{{ else }}btn-outline-secondary{{ end }}"> + {{ $variant.Name }} + </button> {{ end }} </div> </div> @@ -103,10 +101,10 @@ <div id="collapseSettings" class="accordion-collapse collapse" aria-labelledby="headingSettings" data-bs-parent="#eiffelTemplateInfoAccordion"> <div class="accordion-body"> <div class="form-check"> - <input form="eiffelElicitationForm" class="form-check-input" type="checkbox" - name="copyAfterParse" id="copyAfterParse" + <input form="eiffelElicitationForm" class="form-check-input" role="button" + type="checkbox" name="copyAfterParse" id="copyAfterParse" {{ if .Data.Form.CopyAfterParse }}checked{{ end }}/> - <label class="form-check-label" for="copyAfterParse"> + <label class="form-check-label" for="copyAfterParse" role="button"> {{ t "eiffel.elicitation.template.copy-after-parse" }} </label> </div> @@ -115,19 +113,6 @@ </div> </div> - {{/* - Hier muss von Anfang eine Variante als selektiert reingegeben werden. Bei initialem Laden einfach die 1. Variante nehmen. - Ebenfalls beim Suchen und Auswählen der Schablone. (Gleichzusetzen mit Neuladen der Seite.) - Beim Nachladen/Verändern der Variante wird hier die gewählte Variante übergeben und vorausgewählt. - - Klicken der Variante wechselt damit die Variante und erzeugt einen HTMX Request, der die Variante wechselt. - Damit wird auch die URL für das Formular verändert, diese enthält Schablone und Variante. - - Beim Abschicken und Validieren des Forms (Parsing) muss die Variante übergeben werden und im Gegenzug wird lediglich das Formular - zurückgegeben. Die aktuell ausgewählte Schablone und Variante sollten mit übergeben werden (über die URL). - Die ausgewählte Datei sollte über einen Input außerhalb des Formulars übergeben werden. (Hier kann das Input-Attribut "form" verwendet werden.) - */}} - <div class="eiffel-elicitation-template-variant-form mt-3 w-100"> {{ template "eiffel.elicitation.form" . }} </div> diff --git a/templates/eiffel/_form-elicitation.go.html b/templates/eiffel/_form-elicitation.go.html index 4a4160fbfd5d1e944147d6f45ca2e1e61d1e2033..cb5213d6f0a52e89f41656d29ce97b143dcb5931 100644 --- a/templates/eiffel/_form-elicitation.go.html +++ b/templates/eiffel/_form-elicitation.go.html @@ -1,15 +1,219 @@ {{ define "eiffel.elicitation.form" }} - <h4>{{ t "eiffel.elicitation.template.form.title" }}</h4> - <form - hx-post="/eiffel/elicitation/{id}" - hx-target="#eiffelElicitationForm" - hx-disabled-elt=".eiffel-elicitation-form-fieldset" - hx-swap="outerHTML" - hx-trigger="submit" - id="eiffelElicitationForm" - > + {{ $rules := .Data.Form.Template.Rules }} + {{ $displayTypes := .Data.Form.DisplayTypes }} + {{ $parsingResult := .Data.Form.ParsingResult }} + {{ $segments := .Data.Form.SegmentMap }} + + {{/* + TODO save copy to clipboard setting to user session + TODO JS for better UX => automatically active input etc. + TODO add copy to clipboard + TODO shortcuts hinzufügen + */}} + + <h4>{{ t "eiffel.elicitation.form.title" }}</h4> + <form hx-post="/eiffel/elicitation/{{ .Data.Form.TemplateID }}/{{ .Data.Form.VariantKey }}" + hx-target=".eiffel-elicitation-template-variant-form" + hx-disabled-elt=".eiffel-elicitation-form-fieldset" + id="eiffelElicitationForm"> <fieldset class="eiffel-elicitation-form-fieldset"> - <input name="test" placeholder="test" /> + <div class="row"> + {{/* TODO beautify this code and improve readability - good templating is hard :/ */}} + + {{ range $i, $ruleName := .Data.Form.Variant.Rules }} + {{ $rule := index $rules . }} + {{ $displayType := index $displayTypes $ruleName }} + {{ $col := "col-6" }} + + {{ if eq $rule.Size "small" }} + {{ $col = "col-3" }} + {{ else if eq $rule.Size "medium" }} + {{ $col = "col-6" }} + {{ else if eq $rule.Size "large" }} + {{ $col = "col-9" }} + {{ else if eq $rule.Size "full" }} + {{ $col = "col-12" }} + {{ end }} + + {{ $violations := "" }} + {{ if $parsingResult }} + {{ $violations = $parsingResult.ViolationsForRule $ruleName }} + {{ end }} + + {{ $displayName := $rule.Name }} + {{ if not $rule.Optional }} + {{ $displayName = printf "%s %s" $displayName "*" }} + {{ end }} + + {{ $inputName := printf "segment-%s" $ruleName }} + + <div class="{{ $col }}"> + {{ if or (eq $displayType "input-text") + (eq $displayType "text") + (eq $displayType "input-single-select") }} + <div class="mb-3"> + <div class="input-group {{ if $violations }}has-validation{{ end }}"> + <span data-bs-target="#eiffelRule-{{ $ruleName }}-info" class="input-group-text" role="button" data-bs-toggle="modal">i</span> + + {{/* this has to be before the input, otherwise the border radius on the group will not match */}} + {{ if eq $displayType "input-single-select"}} + <datalist id="eiffelFormInput-{{ $ruleName }}-datalist"> + {{ range $i, $option := $rule.Value }} + <option value="{{ $option }}"></option> + {{ end }} + </datalist> + {{ end }} + + {{ if eq $displayType "text" }} + <input type="hidden" name="{{ $inputName }}" value="{{ $rule.Value }}" /> + {{ end }} + + <input type="text" + id="eiffelFormInput-{{ $ruleName }}" + class="form-control {{ if $violations }}is-invalid{{ end }}" + name="{{ $inputName }}" + placeholder="{{ $displayName}}" + aria-label="{{ $displayName }}" + aria-description="{{ $rule.Hint }}" + {{ if eq $displayType "text" }} + disabled + value="{{ $rule.Value }}" + {{ else if $parsingResult }} + value="{{ index $segments $ruleName }}" + {{ end }} + {{ if eq $displayType "input-single-select" }} + list="eiffelFormInput-{{ $ruleName }}-datalist" + {{ end }} + {{ if not $rule.Optional }} + required + {{ end }} + /> + + {{ if $violations }} + <div id="eiffelFormInput-{{ $ruleName }}-error" class="invalid-feedback"> + {{ range $i, $violation := $violations }} + {{ tryTranslate $violation }} + {{ end }} + </div> + {{ end }} + </div> + </div> + {{ else if eq $displayType "input-textarea" }} + <div class="mb-3"> + <div class="input-group {{ if $violations }}has-validation{{ end }}"> + <span data-bs-target="#eiffelRule-{{ $ruleName }}-info" class="input-group-text" role="button" data-bs-toggle="modal">i</span> + + <textarea id="eiffelFormInput-{{ $ruleName }}" + class="form-control {{ if $violations }}is-invalid{{ end }}" + name="{{ $inputName }}" + placeholder="{{ $displayName }}" + aria-label="{{ $displayName }}" + aria-description="{{ $rule.Hint }}" + {{ if not $rule.Optional }} + required + {{ end }} + rows="1">{{ if not $parsingResult }}{{ $rule.Value }}{{ else }}{{ index $segments $ruleName }}{{ end }}</textarea> + + {{ if $violations }} + <div id="eiffelFormInput-{{ $ruleName }}-error" class="invalid-feedback"> + {{ range $i, $violation := $violations }} + {{ tryTranslate $violation }} + {{ end }} + </div> + {{ end }} + </div> + </div> + {{ end }} + + <div class="modal fade" id="eiffelRule-{{ $ruleName }}-info" + tabindex="-1" aria-labelledby="eiffelRule-{{ $ruleName }}-info-label" + aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h1 class="modal-title fs-5" id="eiffelRule-{{ $ruleName }}-info-label"> + {{ tf "eiffel.elicitation.form.rule-description" "rule" $rule.Name }} + {{ if $rule.Optional }}{{ t "eiffel.elicitation.form.rule-description.optional-flag" }}{{ end }} + </h1> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <div class="modal-body"> + <dl> + {{ if eq $displayType "text" }} + <dt>{{ t "eiffel.elicitation.form.value" }}</dt> + <dd>"{{ $rule.Value }}"</dd> + {{ end }} + {{ if eq $displayType "input-single-select" }} + <dt>{{ t "eiffel.elicitation.form.value-single-select" }}</dt> + <dd> + {{ $valueLength := len $rule.Value }} + {{ range $i, $val := $rule.Value }} + "{{ $val }}"{{ if lt (add $i 1) $valueLength }}, {{ end }} + {{ end }} + </dd> + {{ end }} + {{ if $rule.Hint }} + <dt>{{ t "eiffel.elicitation.form.hint" }}</dt> + <dd>{{ $rule.Hint }}</dd> + {{ end }} + {{ if $rule.Explanation }} + <dt>{{ t "eiffel.elicitation.form.explanation" }}</dt> + <dd>{{ $rule.Explanation }}</dd> + {{ end }} + {{ if and (not $rule.Hint) (not $rule.Explanation) }} + <dd>{{ t "eiffel.elicitation.form.no-further-info" }}</dd> + {{ end }} + </dl> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t "harmony.generic.close" }}</button> + </div> + </div> + </div> + </div> + </div> + {{ end }} + <div class="col-12"> + <button type="submit" class="btn btn-primary w-100">{{ t "eiffel.elicitation.form.submit" }}</button> + </div> + </div> + <div class="row mt-2"> + {{ range .Data.Successes }} + <div class="col-12"> + <div class="alert alert-success" role="alert">{{ t . }}</div> + </div> + {{ end }} + {{ range .Data.AllViolations }} + <div class="col-12"> + <div class="alert alert-danger" role="alert">{{ tryTranslate . }}</div> + </div> + {{ end }} + {{ range .Data.AllValidationErrors }} + <div class="col-12"> + <div class="alert alert-danger" role="alert">{{ t .FieldErrorKey }}</div> + </div> + {{ end }} + + {{ if .Data.Form.ParsingResult }} + {{ if not .Data.Form.ParsingResult.Ok }} + <div class="col-12"> + <div class="alert alert-danger" role="alert">{{ t "eiffel.elicitation.form.parsing-error" }}</div> + </div> + {{ end }} + + {{ range .Data.Form.ParsingResult.Warnings }} + <div class="col-12"> + <div class="alert alert-warning" role="alert">{{ tryTranslate . }}</div> + </div> + {{ end }} + + {{ range .Data.Form.ParsingResult.Notices }} + <div class="col-12"> + <div class="alert alert-info" role="alert">{{ tryTranslate . }}</div> + </div> + {{ end }} + {{ end }} + </div> </fieldset> </form> {{ end }} \ No newline at end of file diff --git a/templates/eiffel/_form-output-file.go.html b/templates/eiffel/_form-output-file.go.html index 2e88f8ca9ec1cf80f8275dbbd0b0235245e395e4..4352ac8442e920268b117008a9f61b1a2f581c66 100644 --- a/templates/eiffel/_form-output-file.go.html +++ b/templates/eiffel/_form-output-file.go.html @@ -1,3 +1,69 @@ {{ define "eiffel.elicitation.output-file.form" }} - output file: requirements here + <div class="alert alert-info" role="alert"> + {{ t "eiffel.elicitation.output.file.info" }} + </div> + + {{ if .Data }} + <input form="eiffelElicitationForm" type="hidden" name="elicitationOutputDir" value="{{ .Data.Form.OutputDir }}"/> + <input form="eiffelElicitationForm" type="hidden" name="elicitationOutputFile" value="{{ .Data.Form.OutputFile }}"/> + {{ end }} + + <div class="eiffel-elicitation-output-file"> + <form hx-post="/eiffel/elicitation/output/search" hx-trigger="submit" + hx-target=".eiffel-requirements" hx-disabled-elt=".eiffel-elicitation-output-file-fieldset" + id="eiffelOutputForm"> + <fieldset class="eiffel-elicitation-output-file-fieldset"> + {{ if .Data }} + {{ range .Data.Successes }} + <div class="alert alert-success" role="alert">{{ tryTranslate . }}</div> + {{ end }} + + {{ range .Data.AllViolations }} + <div class="alert alert-danger">{{ tryTranslate . }}</div> + {{ end }} + {{ end }} + + <div class="form-floating"> + <input id="eiffelOutputDir" + hx-post="/eiffel/elicitation/output/dir/search" + hx-trigger="input changed delay:300ms, search" + hx-target="#eiffelOutputDirList" + hx-disabled-elt="#eiffelOutputDir" + {{ if .Data }}value="{{ .Data.Form.OutputDir }}"{{ end }} + autocomplete="off" + list="eiffelOutputDirList" placeholder="{{ t "eiffel.elicitation.output.directory" }}" + type="text" name="output-dir" class="form-control" aria-describedby="eiffelOutputDirHelp"> + <label for="eiffelOutputDir">{{ t "eiffel.elicitation.output.directory" }}</label> + <div id="eiffelOutputDirHelp" class="form-text"> + {{ t "eiffel.elicitation.output.directory.help" }} + </div> + + <datalist id="eiffelOutputDirList"> + </datalist> + </div> + + <div class="form-floating"> + <input id="eiffelOutputFile" required + hx-post="/eiffel/elicitation/output/file/search" + hx-include="#eiffelOutputDir" + hx-trigger="input changed delay:300ms, search" + hx-target="#eiffelOutputFileList" + hx-disabled-elt="#eiffelOutputFile" + {{ if .Data }}value="{{ .Data.Form.OutputFile }}"{{ end }} + autocomplete="off" + list="eiffelOutputFileList" placeholder="{{ t "eiffel.elicitation.output.file" }}" + type="text" name="output-file" class="form-control mt-2" aria-describedby="eiffelOutputFileHelp"> + <label for="eiffelOutputFile">{{ t "eiffel.elicitation.output.file" }}</label> + <div id="eiffelOutputFileHelp" class="form-text"> + {{ t "eiffel.elicitation.output.file.help" }} + </div> + + <datalist id="eiffelOutputFileList"> + </datalist> + </div> + + <input type="submit" class="btn btn-primary mt-3 w-100" value="{{ t "eiffel.elicitation.output.file.save" }}"/> + </fieldset> + </form> + </div> {{ end }} \ No newline at end of file diff --git a/templates/eiffel/_output-dir-search-result.go.html b/templates/eiffel/_output-dir-search-result.go.html new file mode 100644 index 0000000000000000000000000000000000000000..dd9e3b7b3675161cb975f259abe4b208b8388d8d --- /dev/null +++ b/templates/eiffel/_output-dir-search-result.go.html @@ -0,0 +1,5 @@ +{{ define "eiffel.elicitation.output.dir.search-result" }} + {{ range .Data }} + <option value="{{ . }}">{{ . }}</option> + {{ end }} +{{ end }} \ No newline at end of file diff --git a/templates/eiffel/_output-file-search-result.go.html b/templates/eiffel/_output-file-search-result.go.html new file mode 100644 index 0000000000000000000000000000000000000000..db96a13eb6090ef2ffbef2149cd37022061e5f51 --- /dev/null +++ b/templates/eiffel/_output-file-search-result.go.html @@ -0,0 +1,5 @@ +{{ define "eiffel.elicitation.output.file.search-result" }} + {{ range .Data }} + <option value="{{ . }}">{{ . }}</option> + {{ end }} +{{ end }} \ No newline at end of file diff --git a/templates/eiffel/elicitation-page.go.html b/templates/eiffel/elicitation-page.go.html index 4375a0ce7d9ca690c780eaf7d0dbf86456e032a5..809c519b16a73793957bf301d794df57d586c492 100644 --- a/templates/eiffel/elicitation-page.go.html +++ b/templates/eiffel/elicitation-page.go.html @@ -2,14 +2,6 @@ {{ template "index" . }} {{ end }} -{{ block "content-container" . }} - <section class="section content-section mt-3"> - <div class="content-container container content"> - {{ template "content" . }} - </div> - </section> -{{ end }} - {{ define "content" }} {{ template "eiffel.elicitation" . }} {{ end }} @@ -62,7 +54,7 @@ </div> <div class="col eiffel-requirements"> - <p>Requirements here</p> + {{ template "eiffel.elicitation.output-file.form" .Data.Form.OutputFormData }} </div> </div> {{ end }} \ No newline at end of file diff --git a/templates/home.go.html b/templates/home.go.html index d43dd4e35c18355fe640497d1c413f3581ba07e3..99d3f025a0370bcf71cf940e3ecbb41f50f54a9d 100644 --- a/templates/home.go.html +++ b/templates/home.go.html @@ -3,11 +3,21 @@ {{ end }} {{ define "content" }} - <h1>{{ t "harmony.head.welcome" }}</h1> + <div class="content-inner col-7 m-auto"> + <h1>{{ t "harmony.head.welcome" }}</h1> - {{ t "harmony.text.welcome" | safeHTML }} + {{ t "harmony.text.welcome" | safeHTML }} - <div class="d-grid"> - <a href="/auth/login" hx-boost="true" hx-target="body" hx-swap="innerHTML" class="btn btn-primary">{{ t "user.auth.login.action" }}</a> + <div class="d-grid"> + {{ if not (index .Extra "User") }} + <a href="/auth/login" hx-boost="true" hx-target="body" hx-swap="innerHTML" class="btn btn-primary"> + {{ t "user.auth.login.action" }} + </a> + {{ else }} + <a href="/eiffel" hx-boost="true" hx-target="body" hx-swap="innerHTML" class="btn btn-primary"> + {{ t "eiffel.elicitation.call-to-action" }} + </a> + {{ end }} + </div> </div> {{ end }} \ No newline at end of file diff --git a/templates/template/_form.go.html b/templates/template/_form.go.html index 054028dd8f651f82db8a53d4278bc6db4e6fdece..de366a14c9fb6ead0deaa44a302540ac89321fd5 100644 --- a/templates/template/_form.go.html +++ b/templates/template/_form.go.html @@ -30,8 +30,8 @@ {{ range .Data.AllValidationErrors }} <div class="alert alert-danger">{{ t .FieldErrorKey }}</div> {{ end }} - {{ range tErrs .Data.AllTranslatableErrors }} - <div class="alert alert-danger">{{ . }}</div> + {{ range .Data.AllViolations }} + <div class="alert alert-danger">{{ tryTranslate . }}</div> {{ end }} {{ range .Data.Successes }} <div class="alert alert-success">{{ t . }}</div> diff --git a/templates/user/auth/login.go.html b/templates/user/auth/login.go.html index c048bac087b39705f870247a66dce1485a5841d5..4e55c9c4412c9f4e3413a347a6222adcac6cd880 100644 --- a/templates/user/auth/login.go.html +++ b/templates/user/auth/login.go.html @@ -3,7 +3,7 @@ {{ end }} {{ define "content" }} - <div class="card auth-login-providers"> + <div class="card auth-login-providers col-6 m-auto"> <div class="card-header">{{ t "user.auth.login.title" }}</div> <div class="card-body"> {{ block "auth.login.providers" . }} diff --git a/translations/de.json b/translations/de.json index ae5ecc6b1068d6d73c757de787a4ee4135bc1b63..c7a68d307430783d053e28de61bf063a696168e5 100644 --- a/translations/de.json +++ b/translations/de.json @@ -94,9 +94,39 @@ "invalid-rule-value": "Der Wert \"value\" für die Regel \"{{ .rule }}\" vom Typ \"{{ .type }}\" ist ungültig. Bitte überprüfen Sie die Schablonen-Dokumentation.", "not-a-slice": "Der Wert \"value\" für die Regel \"{{ .rule }}\" vom Typ \"{{ .type }}\" ist keine Liste. Bitte überprüfen Sie die Schablonen-Dokumentation.", "not-a-string": "Der Wert \"value\" für die Regel \"{{ .rule }}\" vom Typ \"{{ .type }}\" sollte aus einer Zeichenkette oder einer Liste an Zeichenketten bestehen, jedoch wurde ein anderer Typ gefunden. Bitte überprüfen Sie die Schablonen-Dokumentation." - } + }, + "equals.error": "Erwarteter Wert: {{ .expected }}.", + "equals-any.error": "Erwarteter Wert: {{ .expected }}." }, "elicitation": { + "call-to-action": "Anforderungen mit EIFFEL erfassen", + "parse": { + "flawless-success": "Die Anforderung ist fehlerfrei.", + "success": "Die Anforderung ist gültig, jedoch wurden potentielle Probleme gefunden." + }, + "form": { + "title": "Anforderung erfassen", + "submit": "Anforderung prüfen", + "parsing-error": "Die Anforderung entspricht nicht der Schablone.", + "rule-description": "{{ .rule }}", + "rule-description.optional-flag": "(Optional)", + "hint": "Hinweis", + "explanation": "Erklärung", + "no-further-info": "Es wurden keine weiteren Informationen für diese Regel hinterlegt.", + "value": "Erwartet", + "value-single-select": "Ein Wert aus" + }, + "output": { + "file": "Ausgabedatei", + "file.success": "Ausgabedatei für Erfassung festgelegt.", + "file.save": "Ausgabedatei für Erfassung speichern", + "file.help": "Die Ausgabedatei wird automatisch erstellt, wenn sie nicht existiert. Anforderungen werden angehängt. Die \".csv\"-Dateierweiterung wird automatisch hinzugefügt.", + "directory": "Ausgabeverzeichnis", + "directory.help": "Unterverzeichnisse sollten durch \"\/\" getrennt werden. Nicht existierende Verzeichnisse werden automatisch erstellt.", + "file.info": "Legen Sie eine Ausgabedatei fest, in die Anforderungen nach der Prüfung automatisch geschrieben werden. Somit gehen keine Anforderungen verloren.", + "file.path-invalid": "Der Pfad ist ungültig. Bitte überprüfen Sie den Pfad und versuchen Sie es erneut.", + "file.create-failed": "Die Datei konnte nicht erstellt werden. Bitte überprüfen Sie den Pfad und die Dateiberechtigungen und versuchen Sie es erneut." + }, "template": { "search": { "title": "Schablone suchen", @@ -115,9 +145,6 @@ "not-found": "Die Variante wurde nicht gefunden.", "description": "Variantenbeschreibung" }, - "form": { - "title": "Anforderung erfassen" - }, "construction": "Schablonenaufbau", "example": "Beispielsatz", "description.title": "Beschreibung",