From 2d28f92a41b7a911c7a4ee28e0c70dd7341cf05f Mon Sep 17 00:00:00 2001 From: awnzl <31739165+awnzl@users.noreply.github.com> Date: Wed, 19 Feb 2025 00:33:28 +0200 Subject: [PATCH 1/5] add csv file importing handler --- api_handler.go | 4 + csv_importer.go | 249 +++++++++++++++++++++++++++++++++++++++++++ csv_importer_test.go | 34 ++++++ get_schema.go | 8 ++ process_form.go | 21 ++-- schema.go | 7 +- 6 files changed, 314 insertions(+), 9 deletions(-) create mode 100644 csv_importer.go create mode 100644 csv_importer_test.go diff --git a/api_handler.go b/api_handler.go index e504bf81..e870ac1f 100644 --- a/api_handler.go +++ b/api_handler.go @@ -48,4 +48,8 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { GetFieldsAPI(w, r, session) return } + if strings.HasPrefix(Path, "/csv_import") { + CSVImporterHandler(w, r, session) + return + } } diff --git a/csv_importer.go b/csv_importer.go new file mode 100644 index 00000000..7fd8b481 --- /dev/null +++ b/csv_importer.go @@ -0,0 +1,249 @@ +package uadmin + +import ( + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" +) + +// CSVImporterHandler handles CSV files +func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + dataJSON := r.FormValue("data") + if dataJSON == "" || dataJSON == "[]" { + Trail(ERROR, "no csv data is provided") + http.Error(w, "no csv data is provided", http.StatusBadRequest) + return + } + + // a csv file schema is the fields in the order they are defined in the model + // a csv file should respect this order, the data should have two additional fields: id and language + // the id should be the same for all the languages (translates) of the same model instance + var csvFileRows []string + if err := json.Unmarshal([]byte(dataJSON), &csvFileRows); err != nil { + Trail(ERROR, err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + modelName := r.FormValue("m") + s, _ := getSchema(modelName) + + modelDataMapping, err := getModelDataMapping(csvFileRows) + if err != nil { + Trail(ERROR, err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + for _, objectDescription := range modelDataMapping { + var model reflect.Value + var err error + fields := getFieldsList(s) + + ok, err := objectExists(modelName, objectDescription, fields) + if err != nil { + Trail(ERROR, err.Error()) + http.Error(w, "failed to process model data", http.StatusInternalServerError) + } + if ok { + continue + } + + if model, err = getPopulatedModel(modelName, objectDescription, fields); err != nil { + Trail(ERROR, "failed to process model %v", err.Error()) + http.Error(w, "failed to process model", http.StatusBadRequest) + return + } + SaveRecord(model) + } + + response := map[string]string{ + "message": "CSV data successfully imported", + } + responseJSON, err := json.Marshal(response) + if err != nil { + http.Error(w, "failed to create response", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(responseJSON) +} + +// language-values mapping for the csv file object description +type csvEntry struct { + Langs []string + Fields map[string][]string +} + +// returns a list of models descriptions from the provided csv file data +// the incoming csv data should be in the following format: +// `idx;lang;the;rest;fields` +// idx — index of the object entry in the csv file, the same for all the languages +// lang — particular language for the object entry in the csv file +func getModelDataMapping(csvFileRows []string) ([]csvEntry, error) { + if len(csvFileRows) == 0 { + return nil, fmt.Errorf("no csv file rows") + } + + csvEntries := map[string]csvEntry{} + ids := []string{} + for _, row := range csvFileRows { + rowData := strings.Split(row, ";") + if len(rowData) < 3 { // expected at least 3 fields: row id, lang, model field (one or more) + return nil, fmt.Errorf("csv file row doesn't have any model data") + } + + // collect all the languages and data for the same object + rowID := rowData[0] + rowLang := rowData[1] + if entry, ok := csvEntries[rowID]; ok { + // add another one language + entry.Langs = append(entry.Langs, rowLang) + entry.Fields[rowLang] = rowData[2:] + csvEntries[rowID] = entry + } else { + ids = append(ids, rowID) + // add a new entry + csvEntries[rowID] = csvEntry{ + Langs: []string{rowLang}, + Fields: map[string][]string{rowLang: rowData[2:]}, + } + } + } + + data := []csvEntry{} + for _, id := range ids { + data = append(data, csvEntries[id]) + } + + return data, nil +} + +type fieldDescriptor struct { + Name string + Type string + FK bool + FKModelName string +} + +// returns a list of a model fields in order they are defined, marks foreign keys in the list +func getFieldsList(s ModelSchema) []fieldDescriptor { + var list []fieldDescriptor + for _, f := range s.Fields { + if f.Name == "ID" { // skip the base model field + continue + } + fd := fieldDescriptor{ + Name: f.Name, + Type: f.Type, + } + if f.Type == "fk" { + //TODO: this is a struct! How to find out what a struct's field is used in a csv file?.. + // f.Name here is the name for this FK in the parent model, it's not the same as f.TypeName (FK struct's name, + // lower case of which is the fk's model name) + fd.FK = true + fd.FKModelName = strings.ToLower(f.TypeName) + } + list = append(list, fd) + } + return list +} + +func objectExists(modelName string, objectDescription csvEntry, fieldsList []fieldDescriptor) (bool, error) { + lang := objectDescription.Langs[0] + fields := objectDescription.Fields[lang] + q := "" + for idx, fieldDesc := range fieldsList { + if fieldDesc.Type == "fk" { + continue + } + if idx != 0 { + q += " AND " + } + + q += fmt.Sprintf("%s::jsonb->>'%s' = '%s'", toSnakeCase(fieldDesc.Name), lang, fields[idx]) + } + + var model reflect.Value + model, ok := NewModel(modelName, true) + if !ok { + return false, fmt.Errorf("bad model: %s", modelName) + } + + err := Get(model.Interface(), q) + if err != nil && err.Error() != "record not found" { + Trail(ERROR, "query '%s' is failed: %v", q, err) + return false, err + } + if err == nil && GetID(model) != 0 { + return true, nil + } + + return false, nil +} + +func getPopulatedModel(modelName string, objectDescription csvEntry, fieldsList []fieldDescriptor) (reflect.Value, error) { + nilValue := reflect.ValueOf(nil) + model, ok := NewModel(modelName, true) + if !ok { + return nilValue, fmt.Errorf("bad model: %s", modelName) + } + + for idx, fieldDesc := range fieldsList { + if field := model.Elem().FieldByName(fieldDesc.Name); field.IsValid() && field.CanSet() { + langToFieldsMap := map[string]string{} // will be marshaled to a string like `{"en":"value"}` + for _, lang := range objectDescription.Langs { + fields := objectDescription.Fields[lang] // the values for all the fields of this model description in this lang + langToFieldsMap[lang] = fields[idx] + } + + fieldsMultilangValueJSON, err := json.Marshal(langToFieldsMap) + if err != nil { + return nilValue, err + } + + if fieldDesc.Type == "fk" { + m, ok := NewModel(fieldDesc.FKModelName, true) + if !ok { + return nilValue, fmt.Errorf("can't get %s model", fieldDesc.FKModelName) + } + + // TODO: this works for one use-case only. To make it general, we need a way to + // pass FK FieldName with this value (see `data[idx]` above) in a csv file + hardcoded := "name" + q := fmt.Sprintf("%s::jsonb->>'%s' = '%s'", hardcoded, objectDescription.Langs[0], langToFieldsMap[objectDescription.Langs[0]]) + err := Get(m.Interface(), q) + if err != nil && err.Error() != "record not found" { + Trail(ERROR, "query '%s' is failed: %v", q, err) + return nilValue, err + } + if (err != nil && err.Error() != "record not found") || GetID(m) == 0 { + // TODO: probably, we want to avoid creating FK object in this handler since such a struct might require data we don't have here + // TODO: this works for one use-case only. + Trail(INFO, "no record for the query: '%s', going to create a new one", q) + hardcodedFN := "Name" + // foreign key model's field + if field := m.Elem().FieldByName(hardcodedFN); field.IsValid() && field.CanSet() { + field.SetString(string(fieldsMultilangValueJSON)) + } + SaveRecord(m) + } + + field.Set(m.Elem()) + continue + } + + field.SetString(string(fieldsMultilangValueJSON)) + } + } + + return model, nil +} diff --git a/csv_importer_test.go b/csv_importer_test.go new file mode 100644 index 00000000..c7bdc86a --- /dev/null +++ b/csv_importer_test.go @@ -0,0 +1,34 @@ +package uadmin + +import ( + "testing" +) + +func Test_getModelDataMapping(t *testing.T) { + rows := []string{} + _, err := getModelDataMapping(rows) + if err == nil { + t.Errorf("expected error, got nil") + } + + rows = []string{"1;en"} + _, err = getModelDataMapping(rows) + if err == nil { + t.Errorf("expected error, got nil") + } + + rows = []string{"1;en;test;fields"} + entries, err := getModelDataMapping(rows) + if err != nil { + t.Errorf("got unexpected error: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Fields["en"][0] != "test" { + t.Errorf("expected 'test', got %s", entries[0].Fields["en"][0]) + } + if entries[0].Fields["en"][1] != "fields" { + t.Errorf("expected 'fields', got %s", entries[0].Fields["en"][1]) + } +} diff --git a/get_schema.go b/get_schema.go index c9385624..5ad8fb71 100644 --- a/get_schema.go +++ b/get_schema.go @@ -77,6 +77,14 @@ func getSchema(a interface{}) (s ModelSchema, ok bool) { continue } + // Check if the model marked as a CSV Importer + if t.Field(index).Anonymous && t.Field(index).Type.Name() == "Model" { + if strings.Contains(t.Field(index).Tag.Get("uadmin"), "csv_importer") { + s.CSVImporter = true + } + continue + } + // Initialize the field f := F{ Translations: []translation{}, diff --git a/process_form.go b/process_form.go index 78386166..f2a591c3 100644 --- a/process_form.go +++ b/process_form.go @@ -503,13 +503,7 @@ func processForm(modelName string, w http.ResponseWriter, r *http.Request, sessi } // Save the record - var saverI saver - saverI, ok = m.Interface().(saver) - if !ok { - Save(m.Elem().Addr().Interface()) - } else { - saverI.Save() - } + SaveRecord(m) // Save Approvals for _, approval := range appList { @@ -546,3 +540,16 @@ func processForm(modelName string, w http.ResponseWriter, r *http.Request, sessi http.Redirect(w, r, newURL, http.StatusSeeOther) return m } + +// SaveRecord saves the record, +// a model is represented by a pointer (see NewModel(arg, true) function) +func SaveRecord(model reflect.Value) { + var saverI saver + var ok bool + saverI, ok = model.Interface().(saver) + if !ok { + Save(model.Elem().Addr().Interface()) + } else { + saverI.Save() + } +} diff --git a/schema.go b/schema.go index b728a486..a9fcc968 100644 --- a/schema.go +++ b/schema.go @@ -26,6 +26,7 @@ type ModelSchema struct { ListModifier func(*ModelSchema, *User) (string, []interface{}) `json:"-"` FormTheme string ListTheme string + CSVImporter bool } // FieldByName returns a field from a ModelSchema by name or nil if @@ -85,6 +86,7 @@ func (s ModelSchema) MarshalJSON() ([]byte, error) { ListModifier *string FormTheme string ListTheme string + CSVImporter bool }{ Name: s.Name, DisplayName: s.DisplayName, @@ -110,8 +112,9 @@ func (s ModelSchema) MarshalJSON() ([]byte, error) { v := runtime.FuncForPC(reflect.ValueOf(s.ListModifier).Pointer()).Name() return &v }(), - FormTheme: s.FormTheme, - ListTheme: s.ListTheme, + FormTheme: s.FormTheme, + ListTheme: s.ListTheme, + CSVImporter: s.CSVImporter, }) } From cd6d40b524224c76e30dbed1ba210bd5af8a1059 Mon Sep 17 00:00:00 2001 From: awnzl <31739165+awnzl@users.noreply.github.com> Date: Sat, 22 Feb 2025 16:37:50 +0200 Subject: [PATCH 2/5] Use the proper way to generate a query --- csv_importer.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/csv_importer.go b/csv_importer.go index 7fd8b481..de32f0e2 100644 --- a/csv_importer.go +++ b/csv_importer.go @@ -32,9 +32,6 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session return } - modelName := r.FormValue("m") - s, _ := getSchema(modelName) - modelDataMapping, err := getModelDataMapping(csvFileRows) if err != nil { Trail(ERROR, err.Error()) @@ -42,10 +39,13 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session return } + modelName := r.FormValue("m") + s, _ := getSchema(modelName) + fields := getFieldsList(s) // we rely on this order + for _, objectDescription := range modelDataMapping { var model reflect.Value var err error - fields := getFieldsList(s) ok, err := objectExists(modelName, objectDescription, fields) if err != nil { @@ -96,6 +96,8 @@ func getModelDataMapping(csvFileRows []string) ([]csvEntry, error) { csvEntries := map[string]csvEntry{} ids := []string{} for _, row := range csvFileRows { + //TODO AW: first you need to add escape character to row for all the cases that can be a problem during query + // row = addEscapeCharacter(row) rowData := strings.Split(row, ";") if len(rowData) < 3 { // expected at least 3 fields: row id, lang, model field (one or more) return nil, fmt.Errorf("csv file row doesn't have any model data") @@ -134,7 +136,7 @@ type fieldDescriptor struct { FKModelName string } -// returns a list of a model fields in order they are defined, marks foreign keys in the list +// returns a list of a model fields in the order they are defined, marks foreign keys in the list func getFieldsList(s ModelSchema) []fieldDescriptor { var list []fieldDescriptor for _, f := range s.Fields { @@ -160,16 +162,15 @@ func getFieldsList(s ModelSchema) []fieldDescriptor { func objectExists(modelName string, objectDescription csvEntry, fieldsList []fieldDescriptor) (bool, error) { lang := objectDescription.Langs[0] fields := objectDescription.Fields[lang] - q := "" + + var conditions []string + var values []interface{} for idx, fieldDesc := range fieldsList { if fieldDesc.Type == "fk" { continue } - if idx != 0 { - q += " AND " - } - - q += fmt.Sprintf("%s::jsonb->>'%s' = '%s'", toSnakeCase(fieldDesc.Name), lang, fields[idx]) + conditions = append(conditions, fmt.Sprintf("%s::jsonb->>? = ?", toSnakeCase(fieldDesc.Name))) + values = append(values, lang, fields[idx]) } var model reflect.Value @@ -178,9 +179,10 @@ func objectExists(modelName string, objectDescription csvEntry, fieldsList []fie return false, fmt.Errorf("bad model: %s", modelName) } - err := Get(model.Interface(), q) + query := strings.Join(conditions, " AND ") + err := Get(model.Interface(), query, values...) if err != nil && err.Error() != "record not found" { - Trail(ERROR, "query '%s' is failed: %v", q, err) + Trail(ERROR, "query '%s' is failed: %v", query, err) return false, err } if err == nil && GetID(model) != 0 { From c8d8672f415990c9643bfa138f976a8283d0ae51 Mon Sep 17 00:00:00 2001 From: awnzl <31739165+awnzl@users.noreply.github.com> Date: Sat, 22 Feb 2025 17:05:35 +0200 Subject: [PATCH 3/5] Fix another one query --- csv_importer.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csv_importer.go b/csv_importer.go index de32f0e2..5a704e48 100644 --- a/csv_importer.go +++ b/csv_importer.go @@ -221,8 +221,8 @@ func getPopulatedModel(modelName string, objectDescription csvEntry, fieldsList // TODO: this works for one use-case only. To make it general, we need a way to // pass FK FieldName with this value (see `data[idx]` above) in a csv file hardcoded := "name" - q := fmt.Sprintf("%s::jsonb->>'%s' = '%s'", hardcoded, objectDescription.Langs[0], langToFieldsMap[objectDescription.Langs[0]]) - err := Get(m.Interface(), q) + q := fmt.Sprintf("%s::jsonb->>? = ?", toSnakeCase(hardcoded)) + err := Get(m.Interface(), q, objectDescription.Langs[0], langToFieldsMap[objectDescription.Langs[0]]) if err != nil && err.Error() != "record not found" { Trail(ERROR, "query '%s' is failed: %v", q, err) return nilValue, err From e70d13dde0a2676d42a48e03e4e948b5d8d61e7d Mon Sep 17 00:00:00 2001 From: awnzl <31739165+awnzl@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:03:01 +0200 Subject: [PATCH 4/5] address review comments --- csv_importer.go | 13 +++++-------- process_form.go | 5 +---- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/csv_importer.go b/csv_importer.go index 5a704e48..ea8cc05e 100644 --- a/csv_importer.go +++ b/csv_importer.go @@ -44,9 +44,6 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session fields := getFieldsList(s) // we rely on this order for _, objectDescription := range modelDataMapping { - var model reflect.Value - var err error - ok, err := objectExists(modelName, objectDescription, fields) if err != nil { Trail(ERROR, err.Error()) @@ -56,7 +53,8 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session continue } - if model, err = getPopulatedModel(modelName, objectDescription, fields); err != nil { + model, err := getPopulatedModel(modelName, objectDescription, fields) + if err != nil { Trail(ERROR, "failed to process model %v", err.Error()) http.Error(w, "failed to process model", http.StatusBadRequest) return @@ -96,8 +94,6 @@ func getModelDataMapping(csvFileRows []string) ([]csvEntry, error) { csvEntries := map[string]csvEntry{} ids := []string{} for _, row := range csvFileRows { - //TODO AW: first you need to add escape character to row for all the cases that can be a problem during query - // row = addEscapeCharacter(row) rowData := strings.Split(row, ";") if len(rowData) < 3 { // expected at least 3 fields: row id, lang, model field (one or more) return nil, fmt.Errorf("csv file row doesn't have any model data") @@ -222,7 +218,8 @@ func getPopulatedModel(modelName string, objectDescription csvEntry, fieldsList // pass FK FieldName with this value (see `data[idx]` above) in a csv file hardcoded := "name" q := fmt.Sprintf("%s::jsonb->>? = ?", toSnakeCase(hardcoded)) - err := Get(m.Interface(), q, objectDescription.Langs[0], langToFieldsMap[objectDescription.Langs[0]]) + fields := langToFieldsMap[objectDescription.Langs[0]] + err := Get(m.Interface(), q, objectDescription.Langs[0], fields) if err != nil && err.Error() != "record not found" { Trail(ERROR, "query '%s' is failed: %v", q, err) return nilValue, err @@ -230,7 +227,7 @@ func getPopulatedModel(modelName string, objectDescription csvEntry, fieldsList if (err != nil && err.Error() != "record not found") || GetID(m) == 0 { // TODO: probably, we want to avoid creating FK object in this handler since such a struct might require data we don't have here // TODO: this works for one use-case only. - Trail(INFO, "no record for the query: '%s', going to create a new one", q) + Trail(INFO, "no record for: '%s', going to create a new one", fields) hardcodedFN := "Name" // foreign key model's field if field := m.Elem().FieldByName(hardcodedFN); field.IsValid() && field.CanSet() { diff --git a/process_form.go b/process_form.go index f2a591c3..781b61c2 100644 --- a/process_form.go +++ b/process_form.go @@ -544,10 +544,7 @@ func processForm(modelName string, w http.ResponseWriter, r *http.Request, sessi // SaveRecord saves the record, // a model is represented by a pointer (see NewModel(arg, true) function) func SaveRecord(model reflect.Value) { - var saverI saver - var ok bool - saverI, ok = model.Interface().(saver) - if !ok { + if saverI, ok := model.Interface().(saver); !ok { Save(model.Elem().Addr().Interface()) } else { saverI.Save() From 3c635cbcfe4aacd2250e6eff396ca8028bbcf164 Mon Sep 17 00:00:00 2001 From: awnzl <31739165+awnzl@users.noreply.github.com> Date: Thu, 27 Feb 2025 16:03:41 +0200 Subject: [PATCH 5/5] check for received fields number --- csv_importer.go | 20 ++++++++++++++------ csv_importer_test.go | 8 ++++---- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/csv_importer.go b/csv_importer.go index ea8cc05e..860287f5 100644 --- a/csv_importer.go +++ b/csv_importer.go @@ -43,6 +43,14 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session s, _ := getSchema(modelName) fields := getFieldsList(s) // we rely on this order + csvItem := modelDataMapping[0] + csvItemFieldsNum := len(csvItem.LangFields[csvItem.Langs[0]]) + if len(fields) != csvItemFieldsNum { + Trail(ERROR, "received wrong number of fields. Model has %d fields, received %d", len(fields), csvItemFieldsNum) + http.Error(w, "received wrong number of fields", http.StatusBadRequest) + return + } + for _, objectDescription := range modelDataMapping { ok, err := objectExists(modelName, objectDescription, fields) if err != nil { @@ -77,8 +85,8 @@ func CSVImporterHandler(w http.ResponseWriter, r *http.Request, session *Session // language-values mapping for the csv file object description type csvEntry struct { - Langs []string - Fields map[string][]string + Langs []string + LangFields map[string][]string // map fields to a lang } // returns a list of models descriptions from the provided csv file data @@ -105,14 +113,14 @@ func getModelDataMapping(csvFileRows []string) ([]csvEntry, error) { if entry, ok := csvEntries[rowID]; ok { // add another one language entry.Langs = append(entry.Langs, rowLang) - entry.Fields[rowLang] = rowData[2:] + entry.LangFields[rowLang] = rowData[2:] csvEntries[rowID] = entry } else { ids = append(ids, rowID) // add a new entry csvEntries[rowID] = csvEntry{ Langs: []string{rowLang}, - Fields: map[string][]string{rowLang: rowData[2:]}, + LangFields: map[string][]string{rowLang: rowData[2:]}, } } } @@ -157,7 +165,7 @@ func getFieldsList(s ModelSchema) []fieldDescriptor { func objectExists(modelName string, objectDescription csvEntry, fieldsList []fieldDescriptor) (bool, error) { lang := objectDescription.Langs[0] - fields := objectDescription.Fields[lang] + fields := objectDescription.LangFields[lang] var conditions []string var values []interface{} @@ -199,7 +207,7 @@ func getPopulatedModel(modelName string, objectDescription csvEntry, fieldsList if field := model.Elem().FieldByName(fieldDesc.Name); field.IsValid() && field.CanSet() { langToFieldsMap := map[string]string{} // will be marshaled to a string like `{"en":"value"}` for _, lang := range objectDescription.Langs { - fields := objectDescription.Fields[lang] // the values for all the fields of this model description in this lang + fields := objectDescription.LangFields[lang] // the values for all the fields of this model description in this lang langToFieldsMap[lang] = fields[idx] } diff --git a/csv_importer_test.go b/csv_importer_test.go index c7bdc86a..ce3aa711 100644 --- a/csv_importer_test.go +++ b/csv_importer_test.go @@ -25,10 +25,10 @@ func Test_getModelDataMapping(t *testing.T) { if len(entries) != 1 { t.Errorf("expected 1 entry, got %d", len(entries)) } - if entries[0].Fields["en"][0] != "test" { - t.Errorf("expected 'test', got %s", entries[0].Fields["en"][0]) + if entries[0].LangFields["en"][0] != "test" { + t.Errorf("expected 'test', got %s", entries[0].LangFields["en"][0]) } - if entries[0].Fields["en"][1] != "fields" { - t.Errorf("expected 'fields', got %s", entries[0].Fields["en"][1]) + if entries[0].LangFields["en"][1] != "fields" { + t.Errorf("expected 'fields', got %s", entries[0].LangFields["en"][1]) } }