mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-08 22:08:03 -05:00
• Add Brazilian Portuguese (pt-BR) translation file • Add European Portuguese (pt-PT) translation file • Implement BCP 47 locale normalization system • Create fallback chain for language variants • Add default variant mapping for Portuguese • Update help text to show variant examples • Add comprehensive test suite for variants • Create documentation for i18n variant architecture
241 lines
7.3 KiB
Go
241 lines
7.3 KiB
Go
package i18n
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
// embedded default locales
|
|
//
|
|
//go:embed locales/*.json
|
|
var localeFS embed.FS
|
|
|
|
var (
|
|
translator *i18n.Localizer
|
|
initOnce sync.Once
|
|
)
|
|
|
|
// defaultLanguageVariants maps language codes without regions to their default regional variants.
|
|
// This is used when a language without a base file is requested.
|
|
var defaultLanguageVariants = map[string]string{
|
|
"pt": "pt-BR", // Portuguese defaults to Brazilian Portuguese for backward compatibility
|
|
// Note: We currently have base files for these languages, but if we add regional variants
|
|
// in the future, these defaults will be used:
|
|
// "de": "de-DE", // German would default to Germany German
|
|
// "en": "en-US", // English would default to US English
|
|
// "es": "es-ES", // Spanish would default to Spain Spanish
|
|
// "fa": "fa-IR", // Persian would default to Iran Persian
|
|
// "fr": "fr-FR", // French would default to France French
|
|
// "it": "it-IT", // Italian would default to Italy Italian
|
|
// "ja": "ja-JP", // Japanese would default to Japan Japanese
|
|
// "zh": "zh-CN", // Chinese would default to Simplified Chinese
|
|
}
|
|
|
|
// Init initializes the i18n bundle and localizer. It loads the specified locale
|
|
// and falls back to English if loading fails.
|
|
// Translation files are searched in the user config directory and downloaded
|
|
// from GitHub if missing.
|
|
//
|
|
// If locale is empty, it will attempt to detect the system locale from
|
|
// environment variables (LC_ALL, LC_MESSAGES, LANG) following POSIX standards.
|
|
func Init(locale string) (*i18n.Localizer, error) {
|
|
// Use preferred locale detection if no explicit locale provided
|
|
locale = getPreferredLocale(locale)
|
|
// Normalize the locale to BCP 47 format (with hyphens)
|
|
locale = normalizeToBCP47(locale)
|
|
if locale == "" {
|
|
locale = "en"
|
|
}
|
|
|
|
bundle := i18n.NewBundle(language.English)
|
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
|
|
|
// Build a list of locale candidates to try
|
|
locales := getLocaleCandidates(locale)
|
|
|
|
// Try to load embedded translations for each candidate
|
|
embedded := false
|
|
for _, candidate := range locales {
|
|
if data, err := localeFS.ReadFile("locales/" + candidate + ".json"); err == nil {
|
|
_, _ = bundle.ParseMessageFileBytes(data, candidate+".json")
|
|
embedded = true
|
|
locale = candidate // Update locale to what was actually loaded
|
|
break
|
|
}
|
|
}
|
|
|
|
// Fall back to English if nothing was loaded
|
|
if !embedded {
|
|
if data, err := localeFS.ReadFile("locales/en.json"); err == nil {
|
|
_, _ = bundle.ParseMessageFileBytes(data, "en.json")
|
|
}
|
|
}
|
|
|
|
// load locale from disk or download when not embedded
|
|
path := filepath.Join(userLocaleDir(), locale+".json")
|
|
if _, err := os.Stat(path); os.IsNotExist(err) && !embedded {
|
|
if err := downloadLocale(path, locale); err != nil {
|
|
// if download fails, still continue with embedded translations
|
|
fmt.Fprintf(os.Stderr, "%s\n", fmt.Sprintf(getErrorMessage("i18n_download_failed", "Failed to download translation for language '%s': %v"), locale, err))
|
|
}
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
if _, err := bundle.LoadMessageFile(path); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s\n", fmt.Sprintf(getErrorMessage("i18n_load_failed", "Failed to load translation file: %v"), err))
|
|
}
|
|
}
|
|
|
|
translator = i18n.NewLocalizer(bundle, locale)
|
|
return translator, nil
|
|
}
|
|
|
|
// T returns the localized string for the given message id.
|
|
// If the translator is not initialized, it will automatically initialize
|
|
// with system locale detection.
|
|
func T(messageID string) string {
|
|
initOnce.Do(func() {
|
|
if translator == nil {
|
|
Init("") // Empty string triggers system locale detection
|
|
}
|
|
})
|
|
return translator.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID})
|
|
}
|
|
|
|
func userLocaleDir() string {
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
dir = "."
|
|
}
|
|
path := filepath.Join(dir, "fabric", "locales")
|
|
os.MkdirAll(path, 0o755)
|
|
return path
|
|
}
|
|
|
|
func downloadLocale(path, locale string) error {
|
|
url := fmt.Sprintf("https://raw.githubusercontent.com/danielmiessler/Fabric/main/internal/i18n/locales/%s.json", locale)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("unexpected status: %s", resp.Status)
|
|
}
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, resp.Body)
|
|
return err
|
|
}
|
|
|
|
// getErrorMessage tries to get a translated error message, falling back to system locale
|
|
// and then to the provided fallback message. This is used during initialization when
|
|
// the translator may not be fully ready.
|
|
func getErrorMessage(messageID, fallback string) string {
|
|
// Try to get system locale for error messages
|
|
systemLocale := getPreferredLocale("")
|
|
if systemLocale == "" {
|
|
systemLocale = "en"
|
|
}
|
|
|
|
// First try the system locale
|
|
if msg := tryGetMessage(systemLocale, messageID); msg != "" {
|
|
return msg
|
|
}
|
|
|
|
// Fall back to English
|
|
if systemLocale != "en" {
|
|
if msg := tryGetMessage("en", messageID); msg != "" {
|
|
return msg
|
|
}
|
|
}
|
|
|
|
// Final fallback to hardcoded message
|
|
return fallback
|
|
}
|
|
|
|
// tryGetMessage attempts to get a message from embedded locale files
|
|
func tryGetMessage(locale, messageID string) string {
|
|
if data, err := localeFS.ReadFile("locales/" + locale + ".json"); err == nil {
|
|
var messages map[string]string
|
|
if json.Unmarshal(data, &messages) == nil {
|
|
if msg, exists := messages[messageID]; exists {
|
|
return msg
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// normalizeToBCP47 normalizes a locale string to BCP 47 format.
|
|
// Converts underscores to hyphens and ensures proper casing (language-REGION).
|
|
func normalizeToBCP47(locale string) string {
|
|
if locale == "" {
|
|
return ""
|
|
}
|
|
|
|
// Replace underscores with hyphens
|
|
locale = strings.ReplaceAll(locale, "_", "-")
|
|
|
|
// Split into parts
|
|
parts := strings.Split(locale, "-")
|
|
if len(parts) == 1 {
|
|
// Language only, lowercase it
|
|
return strings.ToLower(parts[0])
|
|
} else if len(parts) >= 2 {
|
|
// Language and region (and possibly more)
|
|
// Lowercase language, uppercase region
|
|
parts[0] = strings.ToLower(parts[0])
|
|
parts[1] = strings.ToUpper(parts[1])
|
|
return strings.Join(parts[:2], "-") // Return only language-REGION
|
|
}
|
|
|
|
return locale
|
|
}
|
|
|
|
// getLocaleCandidates returns a list of locale candidates to try, in order of preference.
|
|
// For example, for "pt-PT" it returns ["pt-PT", "pt", "pt-BR"] (where pt-BR is the default for pt).
|
|
func getLocaleCandidates(locale string) []string {
|
|
candidates := []string{}
|
|
|
|
if locale == "" {
|
|
return candidates
|
|
}
|
|
|
|
// First candidate is always the requested locale
|
|
candidates = append(candidates, locale)
|
|
|
|
// If it's a regional variant, add the base language as a candidate
|
|
if strings.Contains(locale, "-") {
|
|
baseLang := strings.Split(locale, "-")[0]
|
|
candidates = append(candidates, baseLang)
|
|
|
|
// Also check if the base language has a default variant
|
|
if defaultVariant, exists := defaultLanguageVariants[baseLang]; exists {
|
|
// Only add if it's different from what we already have
|
|
if defaultVariant != locale {
|
|
candidates = append(candidates, defaultVariant)
|
|
}
|
|
}
|
|
} else {
|
|
// If this is a base language without a region, check for default variant
|
|
if defaultVariant, exists := defaultLanguageVariants[locale]; exists {
|
|
candidates = append(candidates, defaultVariant)
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|