504 lines
11 KiB
Go
504 lines
11 KiB
Go
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
|
|
}
|