feat: multi-language with FuncMap + lng/ files

This commit is contained in:
Claus Lohmar 2026-07-10 08:15:14 +01:00
parent 45f2509fe4
commit bde90a8186
2 changed files with 73 additions and 59 deletions

View file

@ -185,6 +185,11 @@ else
echo "[4/5] Swapping binary..." echo "[4/5] Swapping binary..."
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
# Copy language files
if [ -d "$SCRIPT_DIR/lng" ]; then
cp -r "$SCRIPT_DIR/lng" "$TARGET_DIR/"
fi
# Ensure AUTHELIA_SECRET is in .env # Ensure AUTHELIA_SECRET is in .env
AUTHELIA_SECRET=$(grep -oP 'session_secret: \K.*' "$TARGET_DIR/config/authelia/configuration.yml" 2>/dev/null || echo "") AUTHELIA_SECRET=$(grep -oP 'session_secret: \K.*' "$TARGET_DIR/config/authelia/configuration.yml" 2>/dev/null || echo "")
if [ -n "$AUTHELIA_SECRET" ]; then if [ -n "$AUTHELIA_SECRET" ]; then

115
main.go
View file

@ -190,13 +190,12 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
} }
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User") user := r.Header.Get("Remote-User")
groupsHeader := r.Header.Get("Remote-Groups") groupsHeader := r.Header.Get("Remote-Groups")
userGroups := strings.Split(groupsHeader, ",") userGroups := strings.Split(groupsHeader, ",")
settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
lang := getUserLang(r)
var allowedApps []AppEntry var allowedApps []AppEntry
for _, app := range apps { for _, app := range apps {
@ -213,6 +212,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
Apps []AppEntry Apps []AppEntry
CompanyLogo string CompanyLogo string
Subtitle string Subtitle string
Lang string
}{ }{
AppName: cfg.App.Name, AppName: cfg.App.Name,
Description: cfg.App.Description, Description: cfg.App.Description,
@ -221,9 +221,10 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
Apps: allowedApps, Apps: allowedApps,
CompanyLogo: settings.Company.Logo, CompanyLogo: settings.Company.Logo,
Subtitle: settings.Company.Subtitle, Subtitle: settings.Company.Subtitle,
Lang: lang,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data) launcherTmpl.Execute(w, data)
} }
} }
@ -280,12 +281,6 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
funcMap := template.FuncMap{
"t": func(key string) string {
return T(settings.Company.Language, key)
},
}
tmpl := template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
autheliaUp := false autheliaUp := false
@ -294,11 +289,20 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
resp.Body.Close() resp.Body.Close()
} }
tmpl.Execute(w, map[string]interface{}{ userLang := os.Getenv("LANG")
if userLang == "" {
userLang = "en"
}
if settings != nil && settings.Company.Language != "" {
userLang = settings.Company.Language
}
adminTmpl.Execute(w, map[string]interface{}{
"Settings": settings, "Settings": settings,
"Domain": domain, "Domain": domain,
"AdminEmail": adminEmail, "AdminEmail": adminEmail,
"AutheliaUp": autheliaUp, "AutheliaUp": autheliaUp,
"Lang": userLang,
}) })
case http.MethodPost: case http.MethodPost:
@ -465,59 +469,53 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
// --- Translation system --- // --- Translation system ---
type Translations map[string]string var translations = make(map[string]map[string]string)
var launcherTmpl *template.Template
var adminTmpl *template.Template
var translationCache map[string]Translations func loadTranslations(lngDir string) {
langs := []string{"en", "de"}
func loadTranslations(lang string) Translations {
if translationCache != nil {
if cached, ok := translationCache[lang]; ok {
return cached
}
}
basePath := filepath.Join("/opt/nextworkspace", "lng", lang)
result := Translations{}
files := []string{"launcher.yaml", "admin.yaml"} files := []string{"launcher.yaml", "admin.yaml"}
for _, f := range files {
data, err := os.ReadFile(filepath.Join(basePath, f)) for _, lang := range langs {
if translations[lang] == nil {
translations[lang] = make(map[string]string)
}
for _, file := range files {
path := filepath.Join(lngDir, lang, file)
data, err := os.ReadFile(path)
if err != nil { if err != nil {
continue continue
} }
var m map[string]string var keys map[string]string
yaml.Unmarshal(data, &m) if err := yaml.Unmarshal(data, &keys); err != nil {
for k, v := range m { continue
result[k] = v }
for k, v := range keys {
translations[lang][k] = v
} }
} }
if translationCache == nil {
translationCache = make(map[string]Translations)
} }
translationCache[lang] = result
return result
} }
func T(lang, key string, args ...string) string { func t(lang, key string, args ...string) string {
t := loadTranslations(lang) text := translations[lang][key]
text, ok := t[key] if text == "" {
if !ok { text = translations["en"][key]
en := loadTranslations("en") }
text = en[key]
if text == "" { if text == "" {
return key return key
} }
}
for i := 0; i < len(args); i += 2 { for i := 0; i < len(args); i += 2 {
if i+1 < len(args) {
text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1]) text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1])
} }
}
return text return text
} }
func getUserLang(r *http.Request) string { func getUserLang(r *http.Request) string {
headerLang := r.Header.Get("Remote-Lang") lang := r.Header.Get("Accept-Language")
if headerLang == "de" || headerLang == "en" { if strings.HasPrefix(lang, "de") {
return headerLang return "de"
} }
settings, _ := loadSettings(os.Getenv("CONFIG_DIR")) settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
if settings != nil && settings.Company.Language == "de" { if settings != nil && settings.Company.Language == "de" {
@ -620,9 +618,9 @@ const launcherHTML = `<!DOCTYPE html>
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}} {{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
</header> </header>
<div class="user-banner"> <div class="user-banner">
<span>Welcome, {{.User}}</span> <span>{{t .Lang "welcome" "user" .User}}</span>
<a href="/settings">Settings</a> <a href="/settings">{{t .Lang "settings"}}</a>
<a href="https://auth.nextwks.eu/logout">Logout</a> <a href="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a>
</div> </div>
<div class="grid"> <div class="grid">
{{range .Apps}} {{range .Apps}}
@ -897,15 +895,15 @@ const adminHTML = `<!DOCTYPE html>
<div class="layout"> <div class="layout">
<nav class="sidebar"> <nav class="sidebar">
<div class="section-label">Core Settings</div> <div class="section-label">Core Settings</div>
<a href="#" class="active" data-section="global" onclick="return switchSection('global')"><span class="icon">&#9881;</span> Global</a> <a href="#" class="active" data-section="global" onclick="return switchSection('global')"><span class="icon">&#9881;</span> {{t .Lang "nav_global"}}</a>
<a href="#" data-section="access" onclick="return switchSection('access')"><span class="icon">&#128101;</span> Access</a> <a href="#" data-section="access" onclick="return switchSection('access')"><span class="icon">&#128101;</span> {{t .Lang "nav_access"}}</a>
<a href="#" data-section="security" onclick="return switchSection('security')"><span class="icon">&#128274;</span> Security</a> <a href="#" data-section="security" onclick="return switchSection('security')"><span class="icon">&#128274;</span> {{t .Lang "nav_security"}}</a>
<a href="#" data-section="domain" onclick="return switchSection('domain')"><span class="icon">&#127760;</span> Domain</a> <a href="#" data-section="domain" onclick="return switchSection('domain')"><span class="icon">&#127760;</span> {{t .Lang "nav_domain"}}</a>
<div class="section-label">App Settings</div> <div class="section-label">App Settings</div>
<a href="#" class="disabled"><span class="icon">&#128230;</span> Mail</a> <a href="#" class="disabled"><span class="icon">&#128230;</span> {{t .Lang "nav_mail"}}</a>
<a href="#" class="disabled"><span class="icon">&#128196;</span> Docs</a> <a href="#" class="disabled"><span class="icon">&#128196;</span> {{t .Lang "nav_docs"}}</a>
<a href="#" class="disabled"><span class="icon">&#128197;</span> Calendar</a> <a href="#" class="disabled"><span class="icon">&#128197;</span> {{t .Lang "nav_calendar"}}</a>
<div class="spacer"></div> <div class="spacer"></div>
<div class="footer">NextWorkspace v0.1.0.0011</div> <div class="footer">NextWorkspace v0.1.0.0011</div>
@ -1128,6 +1126,17 @@ func main() {
companyName = settings.Company.Name companyName = settings.Company.Name
} }
// Load translations from lng/ directory
lngDir := filepath.Join(configDir, "../../lng")
loadTranslations(lngDir)
// Create template FuncMap for translations
funcMap := template.FuncMap{"t": t}
// Parse templates with FuncMap
launcherTmpl = template.Must(template.New("launcher").Funcs(funcMap).Parse(launcherHTML))
adminTmpl = template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
mux := http.NewServeMux() mux := http.NewServeMux()