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 }