snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
32
server/pkg/locale/Makefile
Normal file
32
server/pkg/locale/Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
PATH_ROOT := ../../..
|
||||
PATH_INTERNAL := $(PATH_ROOT)/locale
|
||||
VERSION := 2022.9.x
|
||||
|
||||
EXTERNAL_PKG := corteza-locale-$(VERSION).zip
|
||||
EXTERNAL_DIR := corteza-locale-$(VERSION)
|
||||
|
||||
# Default task
|
||||
copy-english-from-root-locale-dir: src/en
|
||||
|
||||
# Copy internal language files (english) to pkg/locale/src
|
||||
#
|
||||
# This way languages will be embedded into the build
|
||||
src/en:
|
||||
rm -rf $*
|
||||
cp -r $(PATH_INTERNAL)/en ./src
|
||||
|
||||
# Catch-all for external locales
|
||||
src/%: unpack-external
|
||||
cp -r $(EXTERNAL_DIR)/src/$* ./src
|
||||
|
||||
unpack-external: $(EXTERNAL_DIR)
|
||||
|
||||
$(EXTERNAL_DIR): $(EXTERNAL_PKG)
|
||||
unzip $@
|
||||
|
||||
$(EXTERNAL_PKG):
|
||||
curl -L https://github.com/cortezaproject/corteza-locale/archive/refs/heads/$(VERSION).zip -o $@
|
||||
|
||||
clean:
|
||||
rm -rf $(EXTERNAL_PKG) $(EXTERNAL_DIR)
|
||||
35
server/pkg/locale/context.go
Normal file
35
server/pkg/locale/context.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func SetAcceptLanguageToContext(ctx context.Context, code language.Tag) context.Context {
|
||||
return context.WithValue(ctx, language.Tag{}, code)
|
||||
}
|
||||
|
||||
// GetAcceptLanguageFromContext always returns language, either valid or default
|
||||
func GetAcceptLanguageFromContext(ctx context.Context) language.Tag {
|
||||
if tag, ok := ctx.Value(language.Tag{}).(language.Tag); ok {
|
||||
return tag
|
||||
} else {
|
||||
return defaultLanguage
|
||||
}
|
||||
}
|
||||
|
||||
type contentLanguageCtx struct{}
|
||||
|
||||
func SetContentLanguageToContext(ctx context.Context, code language.Tag) context.Context {
|
||||
return context.WithValue(ctx, contentLanguageCtx{}, code)
|
||||
}
|
||||
|
||||
// GetContentLanguageFromContext always returns language, either valid or default
|
||||
func GetContentLanguageFromContext(ctx context.Context) language.Tag {
|
||||
if tag, ok := ctx.Value(contentLanguageCtx{}).(language.Tag); ok {
|
||||
return tag
|
||||
} else {
|
||||
return language.Und
|
||||
}
|
||||
}
|
||||
20
server/pkg/locale/embeded.go
Normal file
20
server/pkg/locale/embeded.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package locale
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////////
|
||||
// This helps us import translations into corteza-server as a module
|
||||
// dependency
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed src/*
|
||||
var languages embed.FS
|
||||
|
||||
// LoadEmbedded returns embedded translation files
|
||||
// as a virtual filesystem
|
||||
func LoadEmbedded() fs.FS {
|
||||
sub, _ := fs.Sub(languages, "src")
|
||||
return sub
|
||||
}
|
||||
132
server/pkg/locale/http.go
Normal file
132
server/pkg/locale/http.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const AcceptLanguageHeader = "Accept-Language"
|
||||
const ContentLanguageHeader = "Content-Language"
|
||||
|
||||
func DetectLanguage(ll *service) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var ctx = r.Context()
|
||||
|
||||
// resolve accept-language header
|
||||
// Accept-Language specifies the language of the response payload.
|
||||
ctx = SetAcceptLanguageToContext(ctx, resolveAcceptLanguageHeaders(r, ll))
|
||||
|
||||
// resolve content-language header
|
||||
// Content-Language specifies the language of the request payload.
|
||||
ctx = SetContentLanguageToContext(ctx, resolveContentLanguageHeaders(r.Header, ll))
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// reads Content-Language headers from the request and returns
|
||||
// parsed value as language.Tag
|
||||
//
|
||||
// There are 4 valid scenarios for :
|
||||
// - lang == 'skip': returns und & services will (likely) ignore all translatable content
|
||||
// - invalid language: (same as skip)
|
||||
// - valid language: returns valid language; services will treat translatable content from the payload as translations
|
||||
// - no header: returns default language; (same as valid language)
|
||||
func resolveContentLanguageHeaders(h http.Header, ll *service) language.Tag {
|
||||
if ll == nil || ll.Default() == nil {
|
||||
return language.Und
|
||||
}
|
||||
|
||||
var cLang = h.Get(ContentLanguageHeader)
|
||||
|
||||
if cLang == "" {
|
||||
return ll.Default().Tag
|
||||
}
|
||||
|
||||
if cLang == "skip" {
|
||||
// more than 1 header or value equal to skip
|
||||
return language.Und
|
||||
}
|
||||
|
||||
if tag, err := language.Parse(cLang); err != nil {
|
||||
return language.Und
|
||||
} else {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
|
||||
func resolveAcceptLanguageHeaders(r *http.Request, ll *service) (tag language.Tag) {
|
||||
if ll == nil || ll.Default() == nil {
|
||||
// locale service does not have anything loaded...
|
||||
return language.Und
|
||||
}
|
||||
|
||||
if ll.opt.DevelopmentMode {
|
||||
if err := ll.ReloadStatic(); err != nil {
|
||||
// when in development mode, refresh languages for every request
|
||||
ll.log.Error("failed to load locales", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
raw string
|
||||
)
|
||||
|
||||
// try to detect the language from the request's query string:
|
||||
if ll.opt.QueryStringParam != "" {
|
||||
raw = r.URL.Query().Get(ll.opt.QueryStringParam)
|
||||
}
|
||||
|
||||
// try to detect the language from the request's headers:
|
||||
if len(raw) == 0 {
|
||||
raw = r.Header.Get(AcceptLanguageHeader)
|
||||
}
|
||||
|
||||
if len(raw) == 0 {
|
||||
// no need for lang detection
|
||||
return ll.Default().Tag
|
||||
}
|
||||
|
||||
// parse & ignore errors
|
||||
var (
|
||||
preferred = ll.Default().Tag
|
||||
supported = ll.Tags()
|
||||
|
||||
accepted, _, err = language.ParseAcceptLanguage(raw)
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
// ignoring index & confidence
|
||||
preferred, _, _ = language.NewMatcher(supported).Match(accepted...)
|
||||
|
||||
var match bool
|
||||
for _, s := range supported {
|
||||
if s == preferred {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !match {
|
||||
base, _ := preferred.Base()
|
||||
preferred = language.MustParse(base.String())
|
||||
}
|
||||
}
|
||||
|
||||
if ll.opt.DevelopmentMode {
|
||||
ll.log.Debug(
|
||||
"language detected",
|
||||
zap.String("preferred", preferred.String()),
|
||||
zap.String("raw", raw),
|
||||
)
|
||||
}
|
||||
|
||||
// new request with new context
|
||||
return preferred
|
||||
|
||||
}
|
||||
9
server/pkg/locale/input.go
Normal file
9
server/pkg/locale/input.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/pkg/xss"
|
||||
)
|
||||
|
||||
func SanitizeMessage(in string) string {
|
||||
return xss.RichText(in)
|
||||
}
|
||||
28
server/pkg/locale/input_test.go
Normal file
28
server/pkg/locale/input_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_SanitizeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
out string
|
||||
}{
|
||||
{"simple", "abc", "abc"},
|
||||
{"accents", "čšž", "čšž"},
|
||||
{"safe html", "<b>čšž</b>", "<b>čšž</b>"},
|
||||
{"unsafe html", `<a href="javascript:document.location='https://cortezaproject.org/'">XSS</A>`, "XSS"},
|
||||
|
||||
{"regular link", `<a href="https://cortezaproject.org/">home</a>`, `<a href="https://cortezaproject.org/" rel="nofollow">home</a>`},
|
||||
{"link with target", `<a href="https://cortezaproject.org/" target="_blank">home</a>`, `<a href="https://cortezaproject.org/" target="_blank" rel="nofollow noopener">home</a>`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.out, SanitizeMessage(tt.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
248
server/pkg/locale/load.go
Normal file
248
server/pkg/locale/load.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const serverApplication = "corteza-server"
|
||||
|
||||
var (
|
||||
defaultLanguage = language.English
|
||||
)
|
||||
|
||||
func loadConfigs(fsys fs.FS) (ll []*Language, err error) {
|
||||
var (
|
||||
sub fs.FS
|
||||
lang *Language
|
||||
configs = make([]string, 0)
|
||||
)
|
||||
|
||||
configs, err = fs.Glob(fsys, "*/config.yaml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, config := range configs {
|
||||
if sub, err = fs.Sub(fsys, filepath.Dir(config)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if lang, err = loadConfig(sub, filepath.Base(config)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang.Tag = language.Make(filepath.Base(filepath.Dir(config)))
|
||||
lang.src = filepath.Dir(config)
|
||||
|
||||
ll = append(ll, lang)
|
||||
}
|
||||
|
||||
return ll, nil
|
||||
}
|
||||
|
||||
func loadConfig(fsys fs.FS, p string) (lang *Language, err error) {
|
||||
var f fs.File
|
||||
lang = &Language{
|
||||
Tag: language.Make(filepath.Base(filepath.Dir(p))),
|
||||
fs: fsys,
|
||||
}
|
||||
|
||||
f, err = fsys.Open(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read %s: %v", p, err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
if err = yaml.NewDecoder(f).Decode(&lang); err != nil {
|
||||
return nil, fmt.Errorf("could not decode %s: %v", p, err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func loadTranslations(lang *Language) (err error) {
|
||||
lang.internal = make(internal)
|
||||
lang.external = make(external)
|
||||
|
||||
var (
|
||||
// this will help us collect structured keys from YAML files
|
||||
// for external translations
|
||||
auxExternal = make(map[string]map[string]map[string]interface{})
|
||||
)
|
||||
|
||||
err = fs.WalkDir(lang.fs, ".", func(p string, entry fs.DirEntry, err error) error {
|
||||
if err != nil || entry.IsDir() {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
f fs.File
|
||||
firstSep = strings.Index(p, string(filepath.Separator))
|
||||
)
|
||||
|
||||
if firstSep == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// From this point on, we're assuming we are at least 1 level deep
|
||||
// and the name of the 1st directory is the name of the application
|
||||
// that the content is for
|
||||
var (
|
||||
namespace string
|
||||
keyPath string
|
||||
|
||||
ext = path.Ext(p)
|
||||
appDir = p[:firstSep]
|
||||
subpath = p[len(appDir)+1:]
|
||||
nsDirIndex = strings.Index(subpath, string(filepath.Separator))
|
||||
nsDotIndex = strings.LastIndex(subpath, ".")
|
||||
|
||||
isYAML = ext == ".yaml" || ext == ".yml"
|
||||
isJSON = ext == ".json"
|
||||
)
|
||||
|
||||
if !isYAML && !isJSON {
|
||||
// unsupported format, skipping
|
||||
return nil
|
||||
}
|
||||
|
||||
if appDir == serverApplication && isJSON {
|
||||
return fmt.Errorf("expecting YAML translation files for server translations")
|
||||
}
|
||||
|
||||
// namespace is whatever we put on the 1st level - either filename or directory
|
||||
if nsDirIndex > 0 {
|
||||
namespace = subpath[:nsDirIndex]
|
||||
|
||||
keyPath = subpath[nsDirIndex+1:]
|
||||
keyPath = keyPath[:len(keyPath)-len(ext)]
|
||||
keyPath = strings.ReplaceAll(keyPath, string(filepath.Separator), ".")
|
||||
} else if nsDotIndex > 0 {
|
||||
namespace = subpath[:nsDotIndex]
|
||||
}
|
||||
|
||||
if f, err = lang.fs.Open(p); err != nil {
|
||||
return fmt.Errorf("could not open %s: %w", p, err)
|
||||
}
|
||||
|
||||
defer f.Close()
|
||||
|
||||
if appDir == serverApplication {
|
||||
if lang.internal[namespace] == nil {
|
||||
lang.internal[namespace] = make(map[string]string)
|
||||
}
|
||||
|
||||
if err = procInternal(lang.internal[namespace], keyPath, f); err != nil {
|
||||
return fmt.Errorf("could not process %s: %v", p, err)
|
||||
}
|
||||
} else {
|
||||
if isJSON {
|
||||
if lang.external[appDir] != nil {
|
||||
return fmt.Errorf("only one JSON file per namespace supported")
|
||||
}
|
||||
|
||||
var buf = &bytes.Buffer{}
|
||||
if _, err = io.Copy(buf, f); err != nil {
|
||||
return fmt.Errorf("could not copy buffer: %v", err)
|
||||
}
|
||||
|
||||
lang.external[appDir] = bytes.NewReader(buf.Bytes())
|
||||
} else {
|
||||
if auxExternal[appDir] == nil {
|
||||
auxExternal[appDir] = make(map[string]map[string]interface{})
|
||||
}
|
||||
if auxExternal[appDir][namespace] == nil {
|
||||
auxExternal[appDir][namespace] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
if err = procExternal(auxExternal[appDir][namespace], keyPath, f); err != nil {
|
||||
return fmt.Errorf("could not process %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// encode all external (for webapps, clientS) translations
|
||||
// into buffers that will be copied directly into responses
|
||||
for app, nss := range auxExternal {
|
||||
var (
|
||||
buf = &bytes.Buffer{}
|
||||
)
|
||||
|
||||
if err = json.NewEncoder(buf).Encode(nss); err != nil {
|
||||
return
|
||||
}
|
||||
lang.external[app] = bytes.NewReader(buf.Bytes())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (svc *service) LoadResourceTranslations(ctx context.Context, tg language.Tag) (out map[string]map[string]*ResourceTranslation, err error) {
|
||||
return svc.s.TransformResource(ctx, tg)
|
||||
}
|
||||
|
||||
// procInternal reads internal YAML translation files and converts it into
|
||||
// simple key/value structure
|
||||
func procInternal(ns map[string]string, prefix string, f io.Reader) (err error) {
|
||||
var (
|
||||
aux = make(map[string]interface{})
|
||||
|
||||
// this allows us to do recursive calls to lambda
|
||||
flatten func(prefix string, sub map[string]interface{})
|
||||
)
|
||||
|
||||
if err = yaml.NewDecoder(f).Decode(&aux); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
flatten = func(prefix string, sub map[string]interface{}) {
|
||||
if len(prefix) > 0 {
|
||||
prefix += "."
|
||||
}
|
||||
|
||||
for k, v := range sub {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
ns[prefix+k] = val
|
||||
case map[string]interface{}:
|
||||
flatten(prefix+k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(prefix, aux)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func procExternal(kv map[string]interface{}, prefix string, f io.Reader) error {
|
||||
// get to the level we want to be:
|
||||
if len(prefix) > 0 {
|
||||
for _, p := range strings.Split(prefix, ".") {
|
||||
if kv[p] == nil {
|
||||
kv[p] = make(map[string]interface{})
|
||||
kv = kv[p].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return yaml.NewDecoder(f).Decode(&kv)
|
||||
}
|
||||
126
server/pkg/locale/locale.go
Normal file
126
server/pkg/locale/locale.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type (
|
||||
// Internal translations (application == corteza-server)
|
||||
// map contains namespace => key => text
|
||||
internal map[string]map[string]string
|
||||
|
||||
// External translations (webapps)
|
||||
// map structure:
|
||||
// application => namespace => buffered JSON docs
|
||||
external map[string]io.ReadSeeker
|
||||
|
||||
// resource => key => ResourceTranslation
|
||||
resources map[string]map[string]*ResourceTranslation
|
||||
|
||||
Language struct {
|
||||
l sync.RWMutex
|
||||
|
||||
// location of the language files
|
||||
// this is mainly for logging/debugging purposes
|
||||
//
|
||||
// This value is empty when loading
|
||||
// embedded languages
|
||||
src string
|
||||
|
||||
// pointer to the place we loaded files from
|
||||
fs fs.FS
|
||||
|
||||
Tag language.Tag `json:"tag,string"`
|
||||
Name string `json:"name"`
|
||||
LocalizedName string `json:"localizedName"`
|
||||
|
||||
// Language (tag) that this language is extending
|
||||
// @todo
|
||||
Extends language.Tag `json:"-"`
|
||||
|
||||
// Points to extended language
|
||||
// @todo
|
||||
extends *Language
|
||||
|
||||
// Internal translations (application == corteza-server)
|
||||
internal internal
|
||||
|
||||
// External translations (webapps)
|
||||
external external
|
||||
resources resources
|
||||
}
|
||||
|
||||
ErrorMetaNamespace struct{}
|
||||
ErrorMetaKey struct{}
|
||||
)
|
||||
|
||||
// t returns the translated string for internal content
|
||||
func (l *Language) t(ns, key string, rr ...string) string {
|
||||
l.l.RLock()
|
||||
defer l.l.RUnlock()
|
||||
|
||||
msg, has := l.internal[ns][key]
|
||||
if has {
|
||||
for r := 0; r < len(rr); r += 2 {
|
||||
rr[r] = fmt.Sprintf("{{%s}}", rr[r])
|
||||
}
|
||||
|
||||
return strings.NewReplacer(rr...).Replace(msg)
|
||||
}
|
||||
|
||||
// If translation is not found, try with language
|
||||
// this language extends
|
||||
if l.extends != nil {
|
||||
return l.extends.t(ns, key, rr...)
|
||||
}
|
||||
|
||||
// In case of untranslated string / missing translations,
|
||||
// translation key is returned
|
||||
return key
|
||||
}
|
||||
|
||||
// tResource returns the translated string for resource translations
|
||||
func (l *Language) tResource(ns, key string, rr ...string) string {
|
||||
l.l.RLock()
|
||||
defer l.l.RUnlock()
|
||||
|
||||
rt, has := l.resources[ns][key]
|
||||
if has {
|
||||
for r := 0; r < len(rr); r += 2 {
|
||||
rr[r] = fmt.Sprintf("{{%s}}", rr[r])
|
||||
}
|
||||
|
||||
return strings.NewReplacer(rr...).Replace(rt.Msg)
|
||||
}
|
||||
|
||||
// When translating resources we do not utilize
|
||||
// language extensions like with static translations
|
||||
|
||||
// In case of untranslated string / missing translations,
|
||||
// only empty string is returned (in contrast to static translations
|
||||
// where a string with key is returned
|
||||
return ""
|
||||
}
|
||||
|
||||
// resourceTranslations returns all resource translations for the specified resource
|
||||
func (l *Language) resourceTranslations(resource string) ResourceTranslationIndex {
|
||||
l.l.RLock()
|
||||
defer l.l.RUnlock()
|
||||
|
||||
out := make(ResourceTranslationIndex)
|
||||
if l.resources == nil {
|
||||
return out
|
||||
}
|
||||
|
||||
for k, rt := range l.resources[resource] {
|
||||
out[k] = rt
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
47
server/pkg/locale/resource.go
Normal file
47
server/pkg/locale/resource.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package locale
|
||||
|
||||
import "golang.org/x/text/language"
|
||||
|
||||
type (
|
||||
ResourceTranslation struct {
|
||||
Resource string `json:"resource"`
|
||||
Lang string `json:"lang"`
|
||||
Key string `json:"key"`
|
||||
Msg string `json:"message"`
|
||||
}
|
||||
|
||||
ResourceTranslationIndex map[string]*ResourceTranslation
|
||||
|
||||
ResourceTranslationSet []*ResourceTranslation
|
||||
)
|
||||
|
||||
func ContentID(cID uint64, i int) uint64 {
|
||||
if cID == 0 && i > 0 {
|
||||
return uint64(i)
|
||||
}
|
||||
return cID
|
||||
}
|
||||
|
||||
func (rr ResourceTranslationSet) SetLanguage(tag language.Tag) {
|
||||
str := tag.String()
|
||||
for _, r := range rr {
|
||||
r.Lang = str
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if resource translation set contains foreign (non-native) languages
|
||||
func (rr ResourceTranslationSet) ContainsForeign(native language.Tag) bool {
|
||||
str := native.String()
|
||||
|
||||
for _, r := range rr {
|
||||
if r.Lang != str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (rx ResourceTranslationIndex) FindByKey(k string) *ResourceTranslation {
|
||||
return rx[k]
|
||||
}
|
||||
503
server/pkg/locale/service.go
Normal file
503
server/pkg/locale/service.go
Normal file
@@ -0,0 +1,503 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/language/display"
|
||||
)
|
||||
|
||||
type (
|
||||
resourceStore interface {
|
||||
TransformResource(context.Context, language.Tag) (map[string]map[string]*ResourceTranslation, error)
|
||||
}
|
||||
|
||||
Locale interface {
|
||||
T(ctx context.Context, ns, key string, rr ...string) string
|
||||
TFor(tag language.Tag, ns, key string, rr ...string) string
|
||||
Tags() []language.Tag
|
||||
}
|
||||
|
||||
Resource interface {
|
||||
TResource(ctx context.Context, ns, key string, rr ...string) string
|
||||
TResourceFor(tag language.Tag, ns, key string, rr ...string) string
|
||||
Tags() []language.Tag
|
||||
SupportedLang(language.Tag) bool
|
||||
ResourceTranslations(code language.Tag, resource string) ResourceTranslationIndex
|
||||
Default() *Language
|
||||
}
|
||||
|
||||
service struct {
|
||||
l sync.RWMutex
|
||||
s resourceStore
|
||||
|
||||
// logger facility for the locale service
|
||||
log *zap.Logger
|
||||
|
||||
// options
|
||||
opt options.LocaleOpt
|
||||
|
||||
// configured sources where we can load languages from
|
||||
src []string
|
||||
|
||||
// all configured tags
|
||||
tags []language.Tag
|
||||
|
||||
// set of all known languages
|
||||
set map[language.Tag]*Language
|
||||
|
||||
// default language
|
||||
def *Language
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
// Global locales service
|
||||
global *service
|
||||
)
|
||||
|
||||
// Global returns global RBAC service
|
||||
func Global() *service {
|
||||
return global
|
||||
}
|
||||
|
||||
// SetGlobal re-sets global service
|
||||
func SetGlobal(ll *service) {
|
||||
global = ll
|
||||
}
|
||||
|
||||
func Static(ll ...*Language) (svc *service) {
|
||||
svc = &service{
|
||||
log: zap.NewNop(),
|
||||
set: make(map[language.Tag]*Language),
|
||||
}
|
||||
|
||||
for _, l := range ll {
|
||||
svc.set[l.Tag] = l
|
||||
svc.tags = append(svc.tags, l.Tag)
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func Service(log *zap.Logger, opt options.LocaleOpt) (*service, error) {
|
||||
svc := &service{
|
||||
opt: opt,
|
||||
src: strings.Split(opt.Path, ":"),
|
||||
log: log.Named("locale"),
|
||||
}
|
||||
for _, lang := range strings.Split(opt.Languages, ",") {
|
||||
lang = strings.TrimSpace(lang)
|
||||
tag := language.Make(lang)
|
||||
if tag.IsRoot() {
|
||||
return nil, fmt.Errorf("failed to parse language '%s' (did you use comma to separate them?)", lang)
|
||||
}
|
||||
|
||||
svc.tags = append(svc.tags, tag)
|
||||
}
|
||||
|
||||
return svc, svc.ReloadStatic()
|
||||
}
|
||||
|
||||
func (svc *service) BindStore(s resourceStore) {
|
||||
svc.s = s
|
||||
}
|
||||
|
||||
// Default language
|
||||
func (svc *service) Default() *Language {
|
||||
return svc.def
|
||||
}
|
||||
|
||||
// Tags of all loaded languages
|
||||
func (svc *service) Tags() (tt []language.Tag) {
|
||||
svc.l.RLock()
|
||||
defer svc.l.RUnlock()
|
||||
return svc.tags
|
||||
}
|
||||
|
||||
func (svc *service) SupportedLang(tag language.Tag) bool {
|
||||
return svc.set[tag] != nil
|
||||
}
|
||||
|
||||
// ReloadStatic all language configurations (as configured via path options) and
|
||||
// all translation files
|
||||
func (svc *service) ReloadStatic() (err error) {
|
||||
var (
|
||||
ll, aux []*Language
|
||||
|
||||
logFields = make([]zap.Field, 0)
|
||||
)
|
||||
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
if len(svc.tags) == 0 {
|
||||
return fmt.Errorf("no supported languages (LOCALE_LANGUAGES is empty)")
|
||||
}
|
||||
|
||||
svc.log.Info("reloading static",
|
||||
zap.Strings("path", svc.src),
|
||||
zap.Strings("tags", tagsToStrings(svc.tags)),
|
||||
)
|
||||
|
||||
// In case where this is already initialized, we need to make sure to
|
||||
// preserve any resource translations.
|
||||
// The preservation is handled below, before we update svc.set.
|
||||
if svc.set == nil {
|
||||
svc.set = make(map[language.Tag]*Language)
|
||||
}
|
||||
|
||||
// load embedded locales from the corteza-locale package
|
||||
if ll, err = loadConfigs(LoadEmbedded()); err != nil {
|
||||
return fmt.Errorf("could not load embedded locales: %w", err)
|
||||
} else {
|
||||
// cleanup src and effectively mark the
|
||||
// loaded languages as embedded
|
||||
for _, l := range ll {
|
||||
l.src = ""
|
||||
}
|
||||
}
|
||||
|
||||
// load imported locales from the configured (LOCALE_PATH) paths
|
||||
for _, p := range svc.src {
|
||||
aux, err = loadConfigs(os.DirFS(p))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load imported locales: %w", err)
|
||||
}
|
||||
|
||||
if len(aux) == 0 {
|
||||
svc.log.Warn(
|
||||
"no languages found in path, future versions of Corteza will require at least one language to be present",
|
||||
zap.String("path", p),
|
||||
)
|
||||
}
|
||||
|
||||
for _, l := range aux {
|
||||
l.src = p + "/" + l.src
|
||||
}
|
||||
|
||||
ll = append(ll, aux...)
|
||||
}
|
||||
|
||||
for _, lang := range ll {
|
||||
logFields = []zap.Field{
|
||||
zap.Stringer("tag", lang.Tag),
|
||||
}
|
||||
|
||||
if !hasTag(lang.Tag, svc.tags) {
|
||||
svc.log.Debug(
|
||||
"language skipped (not in LOCALE_LANGUAGES)",
|
||||
zap.Stringer("tag", lang.Tag),
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !lang.Extends.IsRoot() {
|
||||
logFields = append(logFields, zap.Stringer("extends", lang.Extends))
|
||||
}
|
||||
|
||||
if lang.src != "" {
|
||||
logFields = append(logFields, zap.String("imported", lang.src))
|
||||
} else {
|
||||
logFields = append(logFields, zap.Bool("embedded", true))
|
||||
}
|
||||
|
||||
if err = loadTranslations(lang); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if svc.set[lang.Tag] != nil {
|
||||
svc.log.Info(
|
||||
"language overloaded",
|
||||
logFields...,
|
||||
)
|
||||
} else {
|
||||
svc.log.Info(
|
||||
"language loaded",
|
||||
logFields...,
|
||||
)
|
||||
}
|
||||
|
||||
// Preserve current resource translations.
|
||||
// Resource translations are updated when they change so these are
|
||||
// already up to date.
|
||||
if existing, ok := svc.set[lang.Tag]; ok {
|
||||
lang.resources = existing.resources
|
||||
}
|
||||
|
||||
svc.set[lang.Tag] = lang
|
||||
}
|
||||
|
||||
svc.log.Debug("languages loaded",
|
||||
zap.Strings("path", svc.src),
|
||||
zap.Int("count", len(svc.set)),
|
||||
)
|
||||
|
||||
// Do another pass and link all extended languages
|
||||
for _, lang := range svc.set {
|
||||
if lang.Extends.IsRoot() {
|
||||
continue
|
||||
}
|
||||
|
||||
if svc.set[lang.Extends] == nil {
|
||||
return fmt.Errorf("could not extend langage %q from an unknown language %q", lang.Tag, lang.Extends)
|
||||
}
|
||||
|
||||
lang.extends = svc.set[lang.Extends]
|
||||
}
|
||||
|
||||
if svc.def == nil {
|
||||
first := svc.tags[0]
|
||||
|
||||
if svc.set[first] == nil {
|
||||
return fmt.Errorf("could not find %s in loaded languages", first)
|
||||
}
|
||||
|
||||
svc.def = svc.set[first]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadResourceTranslations all language configurations (as configured via path options) and
|
||||
// all translation files
|
||||
func (svc *service) ReloadResourceTranslations(ctx context.Context) (err error) {
|
||||
if svc.s == nil {
|
||||
return fmt.Errorf("store for locale service not set")
|
||||
}
|
||||
|
||||
svc.l.RLock()
|
||||
defer svc.l.RUnlock()
|
||||
|
||||
svc.log.Info("reloading resource translations",
|
||||
zap.Strings("tags", tagsToStrings(svc.tags)),
|
||||
)
|
||||
|
||||
for _, tag := range svc.tags {
|
||||
lang, ok := svc.set[tag]
|
||||
if !ok {
|
||||
lang = &Language{
|
||||
Tag: tag,
|
||||
// @todo find a better name for this, because it's not the
|
||||
// language that it's loaded from the store, the resource translations are
|
||||
src: "store",
|
||||
}
|
||||
|
||||
svc.set[tag] = lang
|
||||
}
|
||||
|
||||
if lang.resources, err = svc.LoadResourceTranslations(ctx, lang.Tag); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
svc.log.Info(
|
||||
"resource translations loaded",
|
||||
zap.Stringer("tag", lang.Tag),
|
||||
zap.String("src", lang.src),
|
||||
zap.Int("translations", len(lang.resources)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns list of all languages
|
||||
func (svc *service) List() []*Language {
|
||||
svc.l.RLock()
|
||||
defer svc.l.RUnlock()
|
||||
|
||||
ll := make([]*Language, 0, len(svc.set))
|
||||
for _, tag := range svc.tags {
|
||||
if svc.set[tag] != nil {
|
||||
ll = append(ll, svc.set[tag])
|
||||
}
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
|
||||
// List localized list of all languages
|
||||
func (svc *service) LocalizedList(ctx context.Context) []*Language {
|
||||
svc.l.RLock()
|
||||
defer svc.l.RUnlock()
|
||||
|
||||
var (
|
||||
l *Language
|
||||
ll = make([]*Language, 0, len(svc.set))
|
||||
reqLang = GetAcceptLanguageFromContext(ctx)
|
||||
)
|
||||
|
||||
for _, tag := range svc.tags {
|
||||
l = svc.set[tag]
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
l = &Language{
|
||||
Tag: l.Tag,
|
||||
Name: l.Name,
|
||||
}
|
||||
|
||||
if nl := display.Languages(reqLang); nl != nil {
|
||||
l.LocalizedName = nl.Name(l.Tag)
|
||||
}
|
||||
|
||||
if l.Name == "" {
|
||||
if nl := display.Languages(l.Tag); nl != nil {
|
||||
// for cases when language name is not configured
|
||||
l.Name = nl.Name(l.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
ll = append(ll, l)
|
||||
}
|
||||
|
||||
return ll
|
||||
}
|
||||
|
||||
func (svc *service) HasLanguage(lang language.Tag) bool {
|
||||
return svc.set[lang] != nil
|
||||
}
|
||||
|
||||
func (svc *service) HasApplication(lang language.Tag, app string) bool {
|
||||
return svc.set[lang] != nil && svc.set[lang].external[app] != nil
|
||||
}
|
||||
|
||||
// EncodeExternal writes into
|
||||
func (svc *service) EncodeExternal(w io.Writer, app string, ll ...language.Tag) (err error) {
|
||||
svc.l.RLock()
|
||||
defer svc.l.RUnlock()
|
||||
|
||||
_, _ = fmt.Fprint(w, "{")
|
||||
for i, lang := range ll {
|
||||
if i > 0 {
|
||||
_, _ = fmt.Fprint(w, ",")
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(w, "%q:", lang)
|
||||
if svc.HasApplication(lang, app) {
|
||||
svc.set[lang].external[app].Seek(0, 0)
|
||||
_, _ = io.Copy(w, svc.set[lang].external[app])
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, "null")
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "}")
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// NS returns translator function for the current language (from context) and a specific namespace
|
||||
//
|
||||
// Language is picked from the context
|
||||
func (svc *service) NS(ctx context.Context, ns string) func(key string, rr ...string) string {
|
||||
var (
|
||||
tag = GetAcceptLanguageFromContext(ctx)
|
||||
)
|
||||
|
||||
return func(key string, rr ...string) string {
|
||||
return svc.t(tag, ns, key, rr...)
|
||||
}
|
||||
}
|
||||
|
||||
// T returns translated key from namespaces using list of replacement pairs
|
||||
//
|
||||
// Language is picked from the context
|
||||
func (svc *service) T(ctx context.Context, ns, key string, rr ...string) string {
|
||||
return svc.t(GetAcceptLanguageFromContext(ctx), ns, key, rr...)
|
||||
}
|
||||
|
||||
// T returns translated key from namespaces using list of replacement pairs
|
||||
//
|
||||
// Language is specified
|
||||
func (svc *service) TFor(tag language.Tag, ns, key string, rr ...string) string {
|
||||
return svc.t(tag, ns, key, rr...)
|
||||
}
|
||||
|
||||
// TResource returns translated key for resource using list of replacement pairs
|
||||
//
|
||||
// Language is picked from the context
|
||||
func (svc *service) TResource(ctx context.Context, ns, key string, rr ...string) string {
|
||||
|
||||
return svc.tResource(GetAcceptLanguageFromContext(ctx), ns, key, rr...)
|
||||
}
|
||||
|
||||
// TResourceFor returns translated key for resource using list of replacement pairs
|
||||
//
|
||||
// Language is picked from the context
|
||||
func (svc *service) TResourceFor(tag language.Tag, ns, key string, rr ...string) string {
|
||||
return svc.tResource(tag, ns, key, rr...)
|
||||
}
|
||||
|
||||
// ResourceTranslations returns all translations for the given language for the
|
||||
// given resource.
|
||||
//
|
||||
// The response is indexed by translation key for nicer lookups.
|
||||
func (svc *service) ResourceTranslations(tag language.Tag, resource string) ResourceTranslationIndex {
|
||||
out := make(ResourceTranslationIndex)
|
||||
|
||||
svc.l.Lock()
|
||||
defer svc.l.Unlock()
|
||||
|
||||
if svc != nil && svc.set != nil {
|
||||
if l, has := svc.set[tag]; has {
|
||||
return l.resourceTranslations(resource)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Finds language and uses it to translate the given key
|
||||
func (svc *service) t(tag language.Tag, ns, key string, rr ...string) string {
|
||||
if svc != nil && svc.set != nil {
|
||||
if l, has := svc.set[tag]; has {
|
||||
return l.t(ns, key, rr...)
|
||||
}
|
||||
}
|
||||
|
||||
// In case of a missing language, translation
|
||||
// key is returned
|
||||
return key
|
||||
}
|
||||
|
||||
// Finds language and uses it to translate the given key for resource
|
||||
func (svc *service) tResource(tag language.Tag, ns, key string, rr ...string) string {
|
||||
if svc != nil && svc.set != nil {
|
||||
if l, has := svc.set[tag]; has {
|
||||
return l.tResource(ns, key, rr...)
|
||||
}
|
||||
}
|
||||
|
||||
// In case of a missing language, only empty string is
|
||||
// returned (in contrast to static translations
|
||||
// where a string with key is returned)
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasTag(t language.Tag, tt []language.Tag) bool {
|
||||
for _, tag := range tt {
|
||||
if tag.String() == t.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func tagsToStrings(tt []language.Tag) []string {
|
||||
out := make([]string, len(tt))
|
||||
for t := range tt {
|
||||
out[t] = tt[t].String()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
28
server/pkg/locale/service_test.go
Normal file
28
server/pkg/locale/service_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package locale
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// tested with
|
||||
// go test -count 10 -race -run TestServiceReloadAndTranslate ./pkg/locale/...
|
||||
func TestServiceReloadAndTranslate(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
svc, err = Service(zap.NewNop(), options.LocaleOpt{
|
||||
Languages: "en",
|
||||
})
|
||||
|
||||
tag = language.English
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
req.NotNil(svc)
|
||||
go svc.ResourceTranslations(tag, "resource")
|
||||
go svc.ReloadStatic()
|
||||
}
|
||||
3
server/pkg/locale/src/.gitignore
vendored
Normal file
3
server/pkg/locale/src/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!Readme.md
|
||||
Reference in New Issue
Block a user