github.com/richardwilkes/toolbox@v1.121.0/i18n/localization.go (about)

     1  // Copyright (c) 2016-2024 by Richard A. Wilkes. All rights reserved.
     2  //
     3  // This Source Code Form is subject to the terms of the Mozilla Public
     4  // License, version 2.0. If a copy of the MPL was not distributed with
     5  // this file, You can obtain one at http://mozilla.org/MPL/2.0/.
     6  //
     7  // This Source Code Form is "Incompatible With Secondary Licenses", as
     8  // defined by the Mozilla Public License, version 2.0.
     9  
    10  // Package i18n provides internationalization support for applications.
    11  package i18n
    12  
    13  import (
    14  	"bufio"
    15  	"errors"
    16  	"fmt"
    17  	"log/slog"
    18  	"os"
    19  	"path/filepath"
    20  	"strings"
    21  	"sync"
    22  	"sync/atomic"
    23  
    24  	"github.com/richardwilkes/toolbox/errs"
    25  	"github.com/richardwilkes/toolbox/xio"
    26  	"github.com/richardwilkes/toolbox/xio/fs"
    27  )
    28  
    29  const (
    30  	// Extension is the file name extension required on localization files.
    31  	Extension = ".i18n"
    32  )
    33  
    34  var (
    35  	// Dir is the directory to scan for localization files. This will occur only once, the first time a call to Text()
    36  	// is made. If you do not set this prior to the first call, a directory in the same location as the executable with
    37  	// "_i18n" appended to the executable name (sans any extension) will be used.
    38  	Dir string
    39  	// Language is the language that should be used for text returned from calls to Text(). It is initialized to the
    40  	// result of calling Locale(). You may set this at runtime, forcing a particular language for all subsequent calls
    41  	// to Text().
    42  	Language = Locale()
    43  	// Languages is a slice of languages to fall back to should the one specified in the Language variable not be
    44  	// available. It is initialized to the value of the LANGUAGE environment variable.
    45  	Languages    = strings.Split(os.Getenv("LANGUAGE"), ":")
    46  	altLocalizer atomic.Pointer[localizer]
    47  	once         sync.Once
    48  	langMap      = make(map[string]map[string]string)
    49  	hierLock     sync.Mutex
    50  	hierMap      = make(map[string][]string)
    51  )
    52  
    53  type localizer struct {
    54  	Text func(string) string
    55  }
    56  
    57  // SetLocalizer sets the function to use for localizing text. If this is not set or explicitly set to nil, the default
    58  // localization mechanism will be used.
    59  func SetLocalizer(f func(string) string) {
    60  	var trampoline *localizer
    61  	if f != nil {
    62  		trampoline = &localizer{Text: f}
    63  	}
    64  	altLocalizer.Store(trampoline)
    65  }
    66  
    67  // Text returns a localized version of the text if one exists, or the original text if not.
    68  func Text(text string) string {
    69  	if f := altLocalizer.Load(); f != nil {
    70  		return f.Text(text)
    71  	}
    72  	once.Do(func() {
    73  		if Dir == "" {
    74  			path, err := os.Executable()
    75  			if err != nil {
    76  				return
    77  			}
    78  			path, err = filepath.EvalSymlinks(path)
    79  			if err != nil {
    80  				return
    81  			}
    82  			path, err = filepath.Abs(fs.TrimExtension(path) + "_i18n")
    83  			if err != nil {
    84  				return
    85  			}
    86  			Dir = path
    87  		}
    88  		dirEntry, err := os.ReadDir(Dir)
    89  		if err != nil {
    90  			return
    91  		}
    92  		for _, one := range dirEntry {
    93  			if !one.IsDir() {
    94  				name := one.Name()
    95  				if filepath.Ext(name) == Extension {
    96  					load(name)
    97  				}
    98  			}
    99  		}
   100  	})
   101  
   102  	var result string
   103  	if result = lookup(text, Language); result != "" {
   104  		return result
   105  	}
   106  	for _, language := range Languages {
   107  		if result = lookup(text, language); result != "" {
   108  			return result
   109  		}
   110  	}
   111  	return text
   112  }
   113  
   114  func lookup(text, language string) string {
   115  	for _, lang := range hierarchy(language) {
   116  		if translations := langMap[lang]; translations != nil {
   117  			if str, ok := translations[text]; ok {
   118  				return str
   119  			}
   120  		}
   121  	}
   122  	return ""
   123  }
   124  
   125  func hierarchy(language string) []string {
   126  	lang := strings.ToLower(language)
   127  	hierLock.Lock()
   128  	defer hierLock.Unlock()
   129  	if s, ok := hierMap[lang]; ok {
   130  		return s
   131  	}
   132  	one := strings.ReplaceAll(strings.ReplaceAll(lang, "-", "_"), ".", "_")
   133  	var s []string
   134  	for {
   135  		s = append(s, one)
   136  		if i := strings.LastIndex(one, "_"); i != -1 {
   137  			one = one[:i]
   138  		} else {
   139  			break
   140  		}
   141  	}
   142  	hierMap[lang] = s
   143  	return s
   144  }
   145  
   146  func load(name string) {
   147  	path := filepath.Join(Dir, name)
   148  	f, err := os.Open(path)
   149  	if err != nil {
   150  		if errors.Is(err, os.ErrNotExist) {
   151  			return
   152  		}
   153  		errs.Log(errs.NewWithCause("i18n: unable to load", err), "path", path)
   154  		return
   155  	}
   156  	defer xio.CloseIgnoringErrors(f)
   157  	lineNum := 1
   158  	lastKeyLineStart := 1
   159  	translations := make(map[string]string)
   160  	var key, value string
   161  	var hasKey, hasValue bool
   162  	s := bufio.NewScanner(f)
   163  	for s.Scan() {
   164  		line := s.Text()
   165  		if strings.HasPrefix(line, "k:") {
   166  			if hasValue {
   167  				if _, exists := translations[key]; !exists {
   168  					translations[key] = value
   169  				} else {
   170  					slog.Warn("i18n: ignoring duplicate key", "line", lastKeyLineStart, "file", path)
   171  				}
   172  				hasKey = false
   173  				hasValue = false
   174  			}
   175  			var buffer string
   176  			if _, err = fmt.Sscanf(line, "k:%q", &buffer); err != nil {
   177  				slog.Warn("i18n: ignoring invalid key", "line", lineNum, "file", path)
   178  			} else {
   179  				if hasKey {
   180  					key += "\n" + buffer
   181  				} else {
   182  					key = buffer
   183  					hasKey = true
   184  					lastKeyLineStart = lineNum
   185  				}
   186  			}
   187  		} else if strings.HasPrefix(line, "v:") {
   188  			if hasKey {
   189  				var buffer string
   190  				if _, err = fmt.Sscanf(line, "v:%q", &buffer); err != nil {
   191  					slog.Warn("i18n: ignoring invalid value", "line", lineNum, "file", path)
   192  				} else {
   193  					if hasValue {
   194  						value += "\n" + buffer
   195  					} else {
   196  						value = buffer
   197  						hasValue = true
   198  					}
   199  				}
   200  			} else {
   201  				slog.Warn("i18n: ignoring value with no previous key", "line", lineNum, "file", path)
   202  			}
   203  		}
   204  		lineNum++
   205  	}
   206  	if hasKey {
   207  		if hasValue {
   208  			if _, exists := translations[key]; !exists {
   209  				translations[key] = value
   210  			} else {
   211  				slog.Warn("i18n: ignoring duplicate key", "line", lastKeyLineStart, "file", path)
   212  			}
   213  		} else {
   214  			slog.Warn("i18n: ignoring key with missing value", "line", lastKeyLineStart, "file", path)
   215  		}
   216  	}
   217  	key = strings.ToLower(name[:len(name)-len(Extension)])
   218  	key = strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_")
   219  	langMap[key] = translations
   220  }