GUI packages, examples, and documentation for building desktop applications with Go and web technologies. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
238 lines
6.6 KiB
Go
238 lines
6.6 KiB
Go
// Package i18n provides internationalization and localization services.
|
|
//
|
|
// It is designed to be a simple, straightforward i18n solution that should be
|
|
// well-suited to most applications.
|
|
//
|
|
// # Getting Started
|
|
//
|
|
// To use the i18n service, you first need to create a new instance:
|
|
//
|
|
// i18nService, err := i18n.New()
|
|
// if err != nil {
|
|
// log.Fatal(err)
|
|
// }
|
|
//
|
|
// Once you have a service instance, you can set the language and translate
|
|
// messages.
|
|
//
|
|
// # Locales
|
|
//
|
|
// The i18n service loads locales from the `locales` directory. Locales are JSON
|
|
// files with the language code as the filename (e.g., `en.json`, `es.json`).
|
|
// The service uses the `embed` package to bundle the locales into the binary,
|
|
// so you don't need to worry about distributing the locale files with your
|
|
// application.
|
|
package i18n
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
//go:embed locales/*.json
|
|
var localeFS embed.FS
|
|
|
|
// Options holds configuration for the i18n service.
|
|
// This is a placeholder for future configuration options.
|
|
type Options struct{}
|
|
|
|
// Service provides internationalization and localization.
|
|
// It is the primary entrypoint for the i18n package.
|
|
type Service struct {
|
|
bundle *i18n.Bundle
|
|
localizer *i18n.Localizer
|
|
availableLangs []language.Tag
|
|
}
|
|
|
|
// newI18nService contains the common logic for initializing a Service struct.
|
|
func newI18nService() (*Service, error) {
|
|
bundle := i18n.NewBundle(language.English)
|
|
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
|
|
|
availableLangs, err := getAvailableLanguages()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, lang := range availableLangs {
|
|
filePath := fmt.Sprintf("locales/%s.json", lang.String())
|
|
if _, err := bundle.LoadMessageFileFS(localeFS, filePath); err != nil {
|
|
return nil, fmt.Errorf("failed to load message file %s: %w", filePath, err)
|
|
}
|
|
}
|
|
|
|
s := &Service{
|
|
bundle: bundle,
|
|
availableLangs: availableLangs,
|
|
}
|
|
// Language will be set during ServiceStartup after config is available.
|
|
return s, nil
|
|
}
|
|
|
|
// New creates a new i18n service.
|
|
// The service is initialized with the English language as the default.
|
|
func New() (*Service, error) {
|
|
s, err := newI18nService()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
err = s.SetLanguage("en")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// --- Language Management ---
|
|
|
|
func getAvailableLanguages() ([]language.Tag, error) {
|
|
files, err := localeFS.ReadDir("locales")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read embedded locales directory: %w", err)
|
|
}
|
|
|
|
var availableLangs []language.Tag
|
|
for _, file := range files {
|
|
lang := strings.TrimSuffix(file.Name(), ".json")
|
|
tag := language.Make(lang)
|
|
availableLangs = append(availableLangs, tag)
|
|
}
|
|
return availableLangs, nil
|
|
}
|
|
|
|
func detectLanguage(supported []language.Tag) (string, error) {
|
|
langEnv := os.Getenv("LANG")
|
|
if langEnv == "" {
|
|
return "", nil
|
|
}
|
|
|
|
baseLang := strings.Split(langEnv, ".")[0]
|
|
parsedLang, err := language.Parse(baseLang)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse language tag '%s': %w", baseLang, err)
|
|
}
|
|
|
|
if len(supported) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
matcher := language.NewMatcher(supported)
|
|
_, index, confidence := matcher.Match(parsedLang)
|
|
|
|
if confidence >= language.Low {
|
|
return supported[index].String(), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// --- Public Service Methods ---
|
|
|
|
// SetLanguage sets the language for the i18n service.
|
|
// The language tag should be a valid BCP 47 language tag (e.g., "en", "en-US").
|
|
// If the language is not supported, an error is returned.
|
|
func (s *Service) SetLanguage(lang string) error {
|
|
requestedLang, err := language.Parse(lang)
|
|
if err != nil {
|
|
return fmt.Errorf("i18n: failed to parse language tag \"%s\": %w", lang, err)
|
|
}
|
|
|
|
if len(s.availableLangs) == 0 {
|
|
return fmt.Errorf("i18n: no available languages loaded in the bundle")
|
|
}
|
|
|
|
matcher := language.NewMatcher(s.availableLangs)
|
|
bestMatch, _, confidence := matcher.Match(requestedLang)
|
|
|
|
if confidence == language.No {
|
|
return fmt.Errorf("i18n: unsupported language: %s", lang)
|
|
}
|
|
|
|
s.localizer = i18n.NewLocalizer(s.bundle, bestMatch.String())
|
|
return nil
|
|
}
|
|
|
|
// Translate translates a message by its ID.
|
|
// It accepts an optional template data argument to interpolate into the translation.
|
|
// If the message is not found, the message ID is returned.
|
|
func (s *Service) Translate(messageID string, args ...interface{}) string {
|
|
config := &i18n.LocalizeConfig{MessageID: messageID}
|
|
if len(args) > 0 {
|
|
config.TemplateData = args[0]
|
|
if len(args) > 1 {
|
|
fmt.Fprintf(os.Stderr, "i18n: Translate called with %d arguments, expected at most 1 (template data)\n", len(args))
|
|
}
|
|
}
|
|
|
|
translation, err := s.localizer.Localize(config)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "i18n: translation for key \"%s\" not found\n", messageID)
|
|
return messageID
|
|
}
|
|
return translation
|
|
}
|
|
|
|
// SetBundle is a test helper to inject a bundle.
|
|
func (s *Service) SetBundle(bundle *i18n.Bundle) {
|
|
s.bundle = bundle
|
|
}
|
|
|
|
// AvailableLanguages returns a list of available language codes.
|
|
func (s *Service) AvailableLanguages() []string {
|
|
langs := make([]string, len(s.availableLangs))
|
|
for i, tag := range s.availableLangs {
|
|
langs[i] = tag.String()
|
|
}
|
|
return langs
|
|
}
|
|
|
|
// GetAllMessages returns all translation messages for the specified language.
|
|
// The keys are message IDs and values are the translated strings.
|
|
// If lang is empty, it uses the current language.
|
|
func (s *Service) GetAllMessages(lang string) (map[string]string, error) {
|
|
messages := make(map[string]string)
|
|
|
|
// Default to English if no language specified
|
|
if lang == "" {
|
|
lang = "en"
|
|
}
|
|
|
|
// Try to find the locale file for the specified language
|
|
filePath := fmt.Sprintf("locales/%s.json", lang)
|
|
data, err := localeFS.ReadFile(filePath)
|
|
if err != nil {
|
|
// Try without region code (e.g., "en-US" -> "en")
|
|
if strings.Contains(lang, "-") {
|
|
baseLang := strings.Split(lang, "-")[0]
|
|
filePath = fmt.Sprintf("locales/%s.json", baseLang)
|
|
data, err = localeFS.ReadFile(filePath)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read locale file for language %s: %w", lang, err)
|
|
}
|
|
}
|
|
|
|
var rawMessages map[string]interface{}
|
|
if err := json.Unmarshal(data, &rawMessages); err != nil {
|
|
return nil, fmt.Errorf("failed to parse locale file: %w", err)
|
|
}
|
|
|
|
// Extract messages - handle both simple strings and complex message objects
|
|
for key, value := range rawMessages {
|
|
switch v := value.(type) {
|
|
case string:
|
|
messages[key] = v
|
|
case map[string]interface{}:
|
|
if other, ok := v["other"].(string); ok {
|
|
messages[key] = other
|
|
}
|
|
}
|
|
}
|
|
|
|
return messages, nil
|
|
}
|