diff --git a/changelog/unreleased/added-generyc-translator.md b/changelog/unreleased/added-generyc-translator.md new file mode 100644 index 000000000..60cc9ee8f --- /dev/null +++ b/changelog/unreleased/added-generyc-translator.md @@ -0,0 +1,8 @@ +Enhancement: Added generic way to translate composite entities + +Added a generic way to translate the necessary fields in composite entities. +The function takes the entity, translation function and fields to translate that are described by the TranslateField function. +The function supports nested structs and slices of structs. + +https://github.com/owncloud/ocis/pull/9722 +https://github.com/owncloud/ocis/issues/9700 diff --git a/ocis-pkg/l10n/l10n.go b/ocis-pkg/l10n/l10n.go index 5c99c399a..58680666f 100644 --- a/ocis-pkg/l10n/l10n.go +++ b/ocis-pkg/l10n/l10n.go @@ -6,6 +6,7 @@ import ( "errors" "io/fs" "os" + "reflect" "github.com/leonelquinteros/gotext" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" @@ -14,8 +15,13 @@ import ( micrometadata "go-micro.dev/v4/metadata" ) -// HeaderAcceptLanguage is the header key for the accept-language header -var HeaderAcceptLanguage = "Accept-Language" +var ( + // HeaderAcceptLanguage is the header key for the accept-language header + HeaderAcceptLanguage = "Accept-Language" + + // ErrUnsupportedType is returned when the type is not supported + ErrUnsupportedType = errors.New("unsupported type") +) // Template marks a string as translatable func Template(s string) string { return s } @@ -63,6 +69,61 @@ func (t Translator) Locale(locale string) *gotext.Locale { return l } +// TranslateEntity function provides the generic way to translate a struct, array or slice. +// Support for maps is also provided, but non-pointer values will not work. +// The function also takes the entity with fields to translate. +// The function supports nested structs and slices of structs. +/* +tr := NewTranslator("en", _domain, _fsys) + +// a slice of translatables can be passed directly +val := []string{"description", "display name"} +err := tr.TranslateEntity(tr, s, val) + +// string maps work the same way +val := map[string]string{ + "entryOne": "description", + "entryTwo": "display name", +} +err := TranslateEntity(tr, val) + +// struct fields need to be specified +type Struct struct { + Description string + DisplayName string + MetaInformation string +} +val := Struct{} +err := TranslateEntity(tr, val, + l10n.TranslateField("Description"), + l10n.TranslateField("DisplayName"), +) + +// nested structures are supported +type InnerStruct struct { + Description string + Roles []string +} +type OuterStruct struct { + DisplayName string + First InnerStruct + Others map[string]InnerStruct +} +val := OuterStruct{} +err := TranslateEntity(tr, val, + l10n.TranslateField("DisplayName"), + l10n.TranslateStruct("First", + l10n.TranslateField("Description"), + l10n.TranslateEach("Roles"), + ), + l10n.TranslateMap("Others", + l10n.TranslateField("Description"), + }, +*/ +func (t Translator) TranslateEntity(locale string, entity any, opts ...TranslateOption) error { + return TranslateEntity(t.Locale(locale).Get, entity, opts...) +} + // MustGetUserLocale returns the locale the user wants to use, omitting errors func MustGetUserLocale(ctx context.Context, userID string, preferedLang string, vc settingssvc.ValueService) string { if preferedLang != "" { @@ -91,3 +152,194 @@ func GetUserLocale(ctx context.Context, userID string, vc settingssvc.ValueServi } return val[0].GetStringValue(), nil } + +// TranslateOption is used to specify fields in structs to translate +type TranslateOption func() (string, FieldType, []TranslateOption) + +// FieldType is used to specify the type of field to translate +type FieldType int + +const ( + // FieldTypeString is a string field + FieldTypeString FieldType = iota + // FieldTypeStruct is a struct field + FieldTypeStruct + // FieldTypeIterable is a slice or array field + FieldTypeIterable + // FieldTypeMap is a map field + FieldTypeMap +) + +// TranslateField function provides the generic way to translate the necessary field in composite entities. +func TranslateField(fieldName string) TranslateOption { + return func() (string, FieldType, []TranslateOption) { + return fieldName, FieldTypeString, nil + } +} + +// TranslateStruct function provides the generic way to translate the nested fields in composite entities. +func TranslateStruct(fieldName string, args ...TranslateOption) TranslateOption { + return func() (string, FieldType, []TranslateOption) { + return fieldName, FieldTypeStruct, args + } +} + +// TranslateEach function provides the generic way to translate the necessary fields in slices or nested entities. +func TranslateEach(fieldName string, args ...TranslateOption) TranslateOption { + return func() (string, FieldType, []TranslateOption) { + return fieldName, FieldTypeIterable, args + } +} + +// TranslateMap function provides the generic way to translate the necessary fields in maps. +func TranslateMap(fieldName string, args ...TranslateOption) TranslateOption { + return func() (string, FieldType, []TranslateOption) { + return fieldName, FieldTypeMap, args + } +} + +// TranslateEntity translates a slice, array or struct +// See Translator.TranslateEntity for more information +func TranslateEntity(tr func(string, ...any) string, entity any, opts ...TranslateOption) error { + value := reflect.ValueOf(entity) + + value, ok := cleanValue(value) + if !ok { + return errors.New("entity is not valid") + } + + switch value.Kind() { + case reflect.Struct: + rangeOverArgs(tr, value, opts...) + case reflect.Slice, reflect.Array, reflect.Map: + translateEach(tr, value, opts...) + case reflect.String: + translateField(tr, value) + default: + return ErrUnsupportedType + } + return nil +} + +func translateEach(tr func(string, ...any) string, value reflect.Value, args ...TranslateOption) { + value, ok := cleanValue(value) + if !ok { + return + } + + switch value.Kind() { + case reflect.Array, reflect.Slice: + for i := 0; i < value.Len(); i++ { + v := value.Index(i) + switch v.Kind() { + case reflect.Struct, reflect.Ptr: + rangeOverArgs(tr, v, args...) + case reflect.String: + translateField(tr, v) + case reflect.Slice, reflect.Array, reflect.Map: + translateEach(tr, v, args...) + } + } + case reflect.Map: + for _, k := range value.MapKeys() { + v := value.MapIndex(k) + switch v.Kind() { + case reflect.Struct: + // FIXME: add support for non-pointer values + case reflect.Pointer: + rangeOverArgs(tr, v, args...) + case reflect.String: + if nv := tr(v.String()); nv != "" { + value.SetMapIndex(k, reflect.ValueOf(nv)) + } + case reflect.Slice, reflect.Array, reflect.Map: + translateEach(tr, v, args...) + } + } + } +} + +func rangeOverArgs(tr func(string, ...any) string, value reflect.Value, args ...TranslateOption) { + value, ok := cleanValue(value) + if !ok { + return + } + + for _, arg := range args { + fieldName, fieldType, opts := arg() + + switch fieldType { + case FieldTypeString: + f := value.FieldByName(fieldName) + translateField(tr, f) + case FieldTypeStruct: + innerValue := value.FieldByName(fieldName) + if !innerValue.IsValid() || !isStruct(innerValue) { + return + } + rangeOverArgs(tr, innerValue, opts...) + case FieldTypeIterable: + innerValue := value.FieldByName(fieldName) + if !innerValue.IsValid() { + return + } + if kind := innerValue.Kind(); kind != reflect.Array && kind != reflect.Slice { + return + } + translateEach(tr, innerValue, opts...) + case FieldTypeMap: + innerValue := value.FieldByName(fieldName) + if !innerValue.IsValid() { + return + } + if kind := innerValue.Kind(); kind != reflect.Map { + return + } + translateEach(tr, innerValue, opts...) + } + } +} + +func translateField(tr func(string, ...any) string, f reflect.Value) { + if f.IsValid() { + if f.Kind() == reflect.Ptr { + if f.IsNil() { + return + } + f = f.Elem() + } + // A Value can be changed only if it is + // addressable and was not obtained by + // the use of unexported struct fields. + if f.CanSet() { + // change value + if f.Kind() == reflect.String { + val := tr(f.String()) + if val == "" { + return + } + f.SetString(val) + } + } + } +} + +func isStruct(r reflect.Value) bool { + if r.Kind() == reflect.Ptr { + r = r.Elem() + } + return r.Kind() == reflect.Struct +} + +func cleanValue(v reflect.Value) (reflect.Value, bool) { + if v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + if v.IsNil() { + return v, false + } + v = v.Elem() + } + if !v.IsValid() { + return v, false + } + return v, true +} diff --git a/ocis-pkg/l10n/l10n_test.go b/ocis-pkg/l10n/l10n_test.go new file mode 100644 index 000000000..603177e57 --- /dev/null +++ b/ocis-pkg/l10n/l10n_test.go @@ -0,0 +1,414 @@ +package l10n + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTranslateStruct(t *testing.T) { + + type InnerStruct struct { + Description string + DisplayName *string + } + + type TopLevelStruct struct { + Description string + DisplayName *string + SubStruct *InnerStruct + } + + type WrapperStruct struct { + Description string + StructList []*InnerStruct + } + + toStrPointer := func(str string) *string { + return &str + } + + tests := []struct { + name string + entity any + args []TranslateOption + expected any + wantErr bool + }{ + { + name: "top level slice of struct", + entity: []*InnerStruct{ + { + Description: "inner 1", + DisplayName: toStrPointer("innerDisplayName 1"), + }, + { + Description: "inner 2", + DisplayName: toStrPointer("innerDisplayName 2"), + }, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + }, + expected: []*InnerStruct{ + { + Description: "new Inner 1", + DisplayName: toStrPointer("new InnerDisplayName 1"), + }, + { + Description: "new Inner 2", + DisplayName: toStrPointer("new InnerDisplayName 2"), + }, + }, + }, + { + name: "top level slice of string", + entity: []string{ + "inner 1", + "inner 2", + }, + expected: []string{ + "new Inner 1", + "new Inner 2", + }, + }, + { + name: "top level slice of struct", + entity: []*TopLevelStruct{ + { + Description: "inner 1", + DisplayName: toStrPointer("innerDisplayName 1"), + SubStruct: &InnerStruct{ + Description: "inner", + DisplayName: toStrPointer("innerDisplayName"), + }, + }, + { + Description: "inner 2", + DisplayName: toStrPointer("innerDisplayName 2"), + }, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("SubStruct", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: []*TopLevelStruct{ + { + Description: "new Inner 1", + DisplayName: toStrPointer("new InnerDisplayName 1"), + SubStruct: &InnerStruct{ + Description: "new Inner", + DisplayName: toStrPointer("new InnerDisplayName"), + }, + }, + { + Description: "new Inner 2", + DisplayName: toStrPointer("new InnerDisplayName 2"), + }, + }, + }, + { + name: "wrapped struct full", + entity: &WrapperStruct{ + StructList: []*InnerStruct{ + { + Description: "inner 1", + DisplayName: toStrPointer("innerDisplayName 1"), + }, + { + Description: "inner 2", + DisplayName: toStrPointer("innerDisplayName 2"), + }, + }, + }, + args: []TranslateOption{ + TranslateEach("StructList", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &WrapperStruct{ + StructList: []*InnerStruct{ + { + Description: "new Inner 1", + DisplayName: toStrPointer("new InnerDisplayName 1"), + }, + { + Description: "new Inner 2", + DisplayName: toStrPointer("new InnerDisplayName 2"), + }, + }, + }, + }, + { + name: "empty struct, NotExistingSubStructName", + entity: &TopLevelStruct{}, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("NotExistingSubStructName", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &TopLevelStruct{}, + }, + { + name: "empty struct", + entity: &TopLevelStruct{}, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("SubStruct", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &TopLevelStruct{}, + }, + { + name: "empty struct, not existing field", + entity: &TopLevelStruct{ + Description: "description", + DisplayName: toStrPointer("displayName"), + }, + args: []TranslateOption{ + TranslateField("NotExistingFieldName"), + TranslateStruct("SubStruct", + TranslateField("NotExistingFieldName"), + ), + }, + expected: &TopLevelStruct{ + Description: "description", + DisplayName: toStrPointer("displayName"), + }, + }, + { + name: "inner struct DisplayName empy", + entity: &TopLevelStruct{ + Description: "description", + DisplayName: toStrPointer("displayName"), + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("SubStruct", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &TopLevelStruct{ + Description: "new Description", + DisplayName: toStrPointer("new DisplayName"), + }, + }, + { + name: "inner struct full", + entity: &TopLevelStruct{ + Description: "description", + DisplayName: toStrPointer("displayName"), + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("SubStruct", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &TopLevelStruct{ + Description: "new Description", + DisplayName: toStrPointer("new DisplayName"), + }, + }, + { + name: "full struct", + entity: &TopLevelStruct{ + Description: "description", + DisplayName: toStrPointer("displayName"), + SubStruct: &InnerStruct{ + Description: "inner", + DisplayName: toStrPointer("innerDisplayName"), + }, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + TranslateStruct("SubStruct", + TranslateField("Description"), + TranslateField("DisplayName"), + ), + }, + expected: &TopLevelStruct{ + Description: "new Description", + DisplayName: toStrPointer("new DisplayName"), + SubStruct: &InnerStruct{ + Description: "new Inner", + DisplayName: toStrPointer("new InnerDisplayName"), + }, + }, + }, + { + name: "nil", + wantErr: true, + }, + { + name: "empty slice", + wantErr: true, + }, + { + name: "string slice", + entity: []string{"description", "inner"}, + expected: []string{"new Description", "new Inner"}, + }, + { + name: "string map", + entity: map[string]string{ + "entryOne": "description", + "entryTwo": "inner", + }, + expected: map[string]string{ + "entryOne": "new Description", + "entryTwo": "new Inner", + }, + }, + { + name: "pointer struct map", + entity: map[string]*InnerStruct{ + "entryOne": {Description: "description", DisplayName: toStrPointer("displayName")}, + "entryTwo": {Description: "inner", DisplayName: toStrPointer("innerDisplayName")}, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + }, + expected: map[string]*InnerStruct{ + "entryOne": {Description: "new Description", DisplayName: toStrPointer("new DisplayName")}, + "entryTwo": {Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")}, + }, + }, + /* FIXME: non pointer maps are currently not working + { + name: "struct map", + entity: map[string]InnerStruct{ + "entryOne": {Description: "description", DisplayName: toStrPointer("displayName")}, + "entryTwo": {Description: "inner", DisplayName: toStrPointer("innerDisplayName")}, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + }, + expected: map[string]InnerStruct{ + "entryOne": {Description: "new Description", DisplayName: toStrPointer("new DisplayName")}, + "entryTwo": {Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")}, + }, + }, + */ + { + name: "slice map", + entity: map[string][]string{ + "entryOne": {"description", "inner"}, + "entryTwo": {"inner 2", "innerDisplayName 2"}, + }, + expected: map[string][]string{ + "entryOne": {"new Description", "new Inner"}, + "entryTwo": {"new Inner 2", "new InnerDisplayName 2"}, + }, + }, + { + name: "double slice", + entity: [][]string{ + {"description", "inner"}, + {"inner 2", "innerDisplayName 2"}, + }, + expected: [][]string{ + {"new Description", "new Inner"}, + {"new Inner 2", "new InnerDisplayName 2"}, + }, + }, + { + name: "nested structs", + entity: [][]*InnerStruct{ + { + &InnerStruct{Description: "description", DisplayName: toStrPointer("displayName")}, + &InnerStruct{Description: "inner", DisplayName: toStrPointer("innerDisplayName")}, + }, + { + &InnerStruct{Description: "inner 2", DisplayName: toStrPointer("innerDisplayName 2")}, + }, + }, + args: []TranslateOption{ + TranslateField("Description"), + TranslateField("DisplayName"), + }, + expected: [][]*InnerStruct{ + { + &InnerStruct{Description: "new Description", DisplayName: toStrPointer("new DisplayName")}, + &InnerStruct{Description: "new Inner", DisplayName: toStrPointer("new InnerDisplayName")}, + }, + { + &InnerStruct{Description: "new Inner 2", DisplayName: toStrPointer("new InnerDisplayName 2")}, + }, + }, + }, + { + name: "double mapslices", + entity: []map[string][]string{ + { + "entryOne": {"inner 1", "innerDisplayName 1"}, + "entryTwo": {"inner 2", "innerDisplayName 2"}, + }, + { + "entryOne": {"description", "displayName"}, + }, + }, + expected: []map[string][]string{ + { + "entryOne": {"new Inner 1", "new InnerDisplayName 1"}, + "entryTwo": {"new Inner 2", "new InnerDisplayName 2"}, + }, + { + "entryOne": {"new Description", "new DisplayName"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := TranslateEntity(mock(), tt.entity, tt.args...) + if (err != nil) != tt.wantErr { + t.Errorf("TranslateEntity() error = %v, wantErr %v", err, tt.wantErr) + } + assert.Equal(t, tt.expected, tt.entity) + }) + } +} + +func mock() func(string, ...interface{}) string { + return func(s string, i ...interface{}) string { + switch s { + case "description": + return "new Description" + case "displayName": + return "new DisplayName" + case "inner": + return "new Inner" + case "innerDisplayName": + return "new InnerDisplayName" + case "inner 1": + return "new Inner 1" + case "innerDisplayName 1": + return "new InnerDisplayName 1" + case "inner 2": + return "new Inner 2" + case "innerDisplayName 2": + return "new InnerDisplayName 2" + } + return s + } +} diff --git a/services/graph/Makefile b/services/graph/Makefile index 60a640b06..2119f6ed5 100644 --- a/services/graph/Makefile +++ b/services/graph/Makefile @@ -2,8 +2,8 @@ SHELL := bash NAME := graph # Where to write the files generated by this makefile. -OUTPUT_DIR = ./pkg/service/v0/l10n -TEMPLATE_FILE = ./pkg/service/v0/l10n/graph.pot +OUTPUT_DIR = ./pkg/l10n +TEMPLATE_FILE = ./pkg/l10n/graph.pot include ../../.make/recursion.mk @@ -45,7 +45,7 @@ l10n-push: .PHONY: l10n-read l10n-read: $(GO_XGETTEXT) - go-xgettext -o $(OUTPUT_DIR)/graph.pot --keyword=l10n.Template --add-comments -s pkg/service/v0/spacetemplates.go + go-xgettext -o $(OUTPUT_DIR)/graph.pot --keyword=l10n.Template --add-comments -s pkg/service/v0/spacetemplates.go -s pkg/unifiedrole/unifiedrole.go .PHONY: l10n-write l10n-write: diff --git a/services/graph/pkg/service/v0/l10n/.tx/config b/services/graph/pkg/l10n/.tx/config similarity index 100% rename from services/graph/pkg/service/v0/l10n/.tx/config rename to services/graph/pkg/l10n/.tx/config diff --git a/services/graph/pkg/service/v0/l10n/locale/bg/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/bg/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/bg/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/bg/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/de/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/de/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/de/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/de/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/en_GB/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/en_GB/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/es/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/es/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/es/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/es/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/fr/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/fr/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/fr/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/fr/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/fr_FR/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/fr_FR/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/fr_FR/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/fr_FR/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/ja_JP/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ja_JP/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/ja_JP/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/ja_JP/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/ko/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ko/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/ko/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/ko/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/nl/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/nl/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/nl/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/nl/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/pt_BR/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/pt_BR/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/pt_BR/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/pt_BR/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po similarity index 99% rename from services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po index 0eea275dc..239b3eb78 100644 --- a/services/graph/pkg/service/v0/l10n/locale/ru/LC_MESSAGES/graph.po +++ b/services/graph/pkg/l10n/locale/ru/LC_MESSAGES/graph.po @@ -2,10 +2,10 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Alex , 2024 -# +# #, fuzzy msgid "" msgstr "" diff --git a/services/graph/pkg/service/v0/l10n/locale/ru_RU/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/ru_RU/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/ru_RU/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/ru_RU/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/sq/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/sq/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/sq/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/sq/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/tr/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/tr/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/tr/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/tr/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/vi/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/vi/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/vi/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/vi/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/zh/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/zh/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/zh/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/zh/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/service/v0/l10n/locale/zh_HK/LC_MESSAGES/graph.po b/services/graph/pkg/l10n/locale/zh_HK/LC_MESSAGES/graph.po similarity index 100% rename from services/graph/pkg/service/v0/l10n/locale/zh_HK/LC_MESSAGES/graph.po rename to services/graph/pkg/l10n/locale/zh_HK/LC_MESSAGES/graph.po diff --git a/services/graph/pkg/l10n/translation.go b/services/graph/pkg/l10n/translation.go new file mode 100644 index 000000000..d2f7c0994 --- /dev/null +++ b/services/graph/pkg/l10n/translation.go @@ -0,0 +1,32 @@ +package l10n + +import ( + "embed" + + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" +) + +var ( + //go:embed locale + _localeFS embed.FS +) + +const ( + // subfolder where the translation files are stored + _localeSubPath = "locale" + + // domain of the graph service (transifex) + _domain = "graph" +) + +// Translate translates a string based on the locale and default locale +func Translate(content, locale, defaultLocale string) string { + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath) + return t.Translate(content, locale) +} + +// TranslateEntity returns a function that translates a struct or slice based on the locale +func TranslateEntity(locale, defaultLocale string, entity any, opts ...l10n.TranslateOption) error { + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath) + return t.TranslateEntity(locale, entity, opts...) +} diff --git a/services/graph/pkg/service/v0/api_driveitem_permissions.go b/services/graph/pkg/service/v0/api_driveitem_permissions.go index 4c169b1d2..cca9e3565 100644 --- a/services/graph/pkg/service/v0/api_driveitem_permissions.go +++ b/services/graph/pkg/service/v0/api_driveitem_permissions.go @@ -25,6 +25,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/render" libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" + l10n_pkg "github.com/owncloud/ocis/v2/services/graph/pkg/l10n" "github.com/owncloud/ocis/v2/ocis-pkg/conversions" "github.com/owncloud/ocis/v2/ocis-pkg/log" @@ -632,6 +634,20 @@ func (api DriveItemPermissionsApi) ListPermissions(w http.ResponseWriter, r *htt return } + loc := r.Header.Get(l10n.HeaderAcceptLanguage) + w.Header().Add("Content-Language", loc) + if loc != "" && loc != "en" { + err := l10n_pkg.TranslateEntity(loc, "en", permissions, + l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues", + l10n.TranslateField("Description"), + l10n.TranslateField("DisplayName"), + ), + ) + if err != nil { + api.logger.Error().Err(err).Msg("tranlation error") + } + } + render.Status(r, http.StatusOK) render.JSON(w, r, permissions) } @@ -653,6 +669,20 @@ func (api DriveItemPermissionsApi) ListSpaceRootPermissions(w http.ResponseWrite return } + loc := r.Header.Get(l10n.HeaderAcceptLanguage) + w.Header().Add("Content-Language", loc) + if loc != "" && loc != "en" { + err := l10n_pkg.TranslateEntity(loc, "en", permissions, + l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues", + l10n.TranslateField("Description"), + l10n.TranslateField("DisplayName"), + ), + ) + if err != nil { + api.logger.Error().Err(err).Msg("tranlation error") + } + } + render.Status(r, http.StatusOK) render.JSON(w, r, permissions) } diff --git a/services/graph/pkg/service/v0/spacetemplates.go b/services/graph/pkg/service/v0/spacetemplates.go index 1a7e0ea0f..75da3a728 100644 --- a/services/graph/pkg/service/v0/spacetemplates.go +++ b/services/graph/pkg/service/v0/spacetemplates.go @@ -15,18 +15,13 @@ import ( "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" "github.com/owncloud/ocis/v2/ocis-pkg/l10n" + l10n_pkg "github.com/owncloud/ocis/v2/services/graph/pkg/l10n" ) var ( //go:embed spacetemplate/* _spaceTemplateFS embed.FS - //go:embed l10n/locale - _localeFS embed.FS - - // subfolder where the translation files are stored - _localeSubPath = "l10n/locale" - // name of the secret space folder _spaceFolderName = ".space" @@ -39,9 +34,6 @@ var ( // name of the readme.md file _readmeName = "readme.md" - // domain of the graph service (transifex) - _domain = "graph" - // HeaderAcceptLanguage is the header key for the accept-language header HeaderAcceptLanguage = "Accept-Language" @@ -121,10 +113,9 @@ func imageUpload(ctx context.Context, mdc *metadata.CS3) (string, error) { } func readmeUpload(ctx context.Context, mdc *metadata.CS3, locale string, defaultLocale string) (string, error) { - t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath) res, err := mdc.Upload(ctx, metadata.UploadRequest{ Path: filepath.Join(_spaceFolderName, _readmeName), - Content: []byte(t.Translate(_readmeText, locale)), + Content: []byte(l10n_pkg.Translate(_readmeText, locale, defaultLocale)), }) if err != nil { return "", err diff --git a/services/graph/pkg/unifiedrole/unifiedrole.go b/services/graph/pkg/unifiedrole/unifiedrole.go index 4a6d5123d..4c373b532 100644 --- a/services/graph/pkg/unifiedrole/unifiedrole.go +++ b/services/graph/pkg/unifiedrole/unifiedrole.go @@ -7,6 +7,7 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" libregraph "github.com/owncloud/libre-graph-api-go" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" "google.golang.org/protobuf/proto" "github.com/cs3org/reva/v2/pkg/conversions" @@ -71,12 +72,54 @@ var legacyNames map[string]string = map[string]string{ UnifiedRoleSecureViewerID: conversions.RoleSecureViewer, } +var ( + // UnifiedRole Viewer, Role Description (resolves directly) + _viewerUnifiedRoleDescription = l10n.Template("View and download.") + // UnifiedRole Viewer, Role DisplayName (resolves directly) + _viewerUnifiedRoleDisplayName = l10n.Template("Can view") + + // UnifiedRole SpaceViewer, Role Description (resolves directly) + _spaceViewerUnifiedRoleDescription = l10n.Template("View and download.") + // UnifiedRole SpaseViewer, Role DisplayName (resolves directly) + _spaceViewerUnifiedRoleDisplayName = l10n.Template("Can view") + + // UnifiedRole Editor, Role Description (resolves directly) + _editorUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add and delete.") + // UnifiedRole Editor, Role DisplayName (resolves directly) + _editorUnifiedRoleDisplayName = l10n.Template("Can edit") + + // UnifiedRole SpaseEditor, Role Description (resolves directly) + _spaceEditorUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add and delete.") + // UnifiedRole SpaseEditor, Role DisplayName (resolves directly) + _spaceEditorUnifiedRoleDisplayName = l10n.Template("Can edit") + + // UnifiedRole FileEditor, Role Description (resolves directly) + _fileEditorUnifiedRoleDescription = l10n.Template("View, download and edit.") + // UnifiedRole FileEditor, Role DisplayName (resolves directly) + _fileEditorUnifiedRoleDisplayName = l10n.Template("Can edit") + + // UnifiedRole EditorLite, Role Description (resolves directly) + _editorLiteUnifiedRoleDescription = l10n.Template("View, download and upload.") + // UnifiedRole EditorLite, Role DisplayName (resolves directly) + _editorLiteUnifiedRoleDisplayName = l10n.Template("Can upload") + + // UnifiedRole Manager, Role Description (resolves directly) + _managerUnifiedRoleDescription = l10n.Template("View, download, upload, edit, add, delete and manage members.") + // UnifiedRole Manager, Role DisplayName (resolves directly) + _managerUnifiedRoleDisplayName = l10n.Template("Can manage") + + // UnifiedRole SecureViewer, Role Description (resolves directly) + _secureViewerUnifiedRoleDescription = l10n.Template("View only documents, images and PDFs. Watermarks will be applied.") + // UnifiedRole SecureViewer, Role DisplayName (resolves directly) + _secureViewerUnifiedRoleDisplayName = l10n.Template("Can view (secure)") +) + // NewViewerUnifiedRole creates a viewer role. func NewViewerUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewViewerRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleViewerID), - Description: proto.String("View and download."), + Description: proto.String(_viewerUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -97,7 +140,7 @@ func NewSpaceViewerUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewSpaceViewerRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleSpaceViewerID), - Description: proto.String("View and download."), + Description: proto.String(_spaceViewerUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -114,7 +157,7 @@ func NewEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewEditorRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleEditorID), - Description: proto.String("View, download, upload, edit, add and delete."), + Description: proto.String(_editorUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -131,7 +174,7 @@ func NewSpaceEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewSpaceEditorRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleSpaceEditorID), - Description: proto.String("View, download, upload, edit, add and delete."), + Description: proto.String(_spaceEditorUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -148,7 +191,7 @@ func NewFileEditorUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewFileEditorRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleFileEditorID), - Description: proto.String("View, download and edit."), + Description: proto.String(_fileEditorUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -165,7 +208,7 @@ func NewEditorLiteUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewEditorLiteRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleEditorLiteID), - Description: proto.String("View, download and upload."), + Description: proto.String(_editorLiteUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -182,7 +225,7 @@ func NewManagerUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewManagerRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleManagerID), - Description: proto.String("View, download, upload, edit, add, delete and manage members."), + Description: proto.String(_managerUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -199,7 +242,7 @@ func NewSecureViewerUnifiedRole() *libregraph.UnifiedRoleDefinition { r := conversions.NewSecureViewerRole() return &libregraph.UnifiedRoleDefinition{ Id: proto.String(UnifiedRoleSecureViewerID), - Description: proto.String("View only documents, images and PDFs. Watermarks will be applied."), + Description: proto.String(_secureViewerUnifiedRoleDescription), DisplayName: displayName(r), RolePermissions: []libregraph.UnifiedRolePermission{ { @@ -482,27 +525,24 @@ func displayName(role *conversions.Role) *string { return nil } - // linter wants this to be a var - canEdit := "Can edit" - var displayName string switch role.Name { case conversions.RoleViewer: - displayName = "Can view" + displayName = _viewerUnifiedRoleDisplayName case conversions.RoleSpaceViewer: - displayName = "Can view" + displayName = _spaceViewerUnifiedRoleDisplayName case conversions.RoleEditor: - displayName = canEdit + displayName = _editorUnifiedRoleDisplayName case conversions.RoleSpaceEditor: - displayName = canEdit + displayName = _spaceEditorUnifiedRoleDisplayName case conversions.RoleFileEditor: - displayName = canEdit + displayName = _fileEditorUnifiedRoleDisplayName case conversions.RoleEditorLite: - displayName = "Can upload" + displayName = _editorLiteUnifiedRoleDisplayName case conversions.RoleManager: - displayName = "Can manage" + displayName = _managerUnifiedRoleDisplayName case conversions.RoleSecureViewer: - displayName = "Can view (secure)" + displayName = _secureViewerUnifiedRoleDisplayName default: return nil }