/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "time" "github.com/schollz/progressbar/v3" ) type Locale struct { Code string Name string NativeName string } type OpenRouterRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` } type Message struct { Role string `json:"role"` Content string `json:"content"` } type OpenRouterResponse struct { Choices []Choice `json:"choices"` } type Choice struct { Message Message `json:"message"` } type TranslatableString struct { MsgID string MsgIDPlural string Context string Comments []string Files []FileLocation IsPlural bool HasContext bool } type FileLocation struct { FilePath string Line int } type POEntry struct { Context string Comments []string Files []string MsgID string MsgIDPlural string MsgStr []string IsPlural bool HasContext bool } type TranslationJob struct { Locale string FilePath string Entries []POEntry Language string TotalCount int } type TranslationResult struct { Locale string FilePath string Success bool Translated int Error error } type DocTranslationResult struct { Locale string Folder string Success bool Error error } type FileMetadata struct { Hash string `json:"hash"` LastModified time.Time `json:"last_modified"` } type MetadataFile struct { Files map[string]FileMetadata `json:"files"` } type DocTranslationJob struct { Locale string Folder string SourcePath string TargetPath string Content string Language string NativeName string DocType string } const ( apiURL = "https://openrouter.ai/api/v1/chat/completions" batchSize = 10 maxWorkers = 3 ) var ( gFuncRegex = regexp.MustCompile(`(?s)g_\s*\(\s*.*?,\s*"([^"]+)"`) nFuncRegex = regexp.MustCompile(`(?s)n_\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"`) pFuncRegex = regexp.MustCompile(`(?s)p_\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"`) npFuncRegex = regexp.MustCompile(`(?s)np_\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]+)"`) msgIDRegex = regexp.MustCompile(`^msgid\s+"(.*)"\s*$`) msgIDPluralRegex = regexp.MustCompile(`^msgid_plural\s+"(.*)"\s*$`) msgStrRegex = regexp.MustCompile(`^msgstr(?:\[(\d+)\])?\s+"(.*)"\s*$`) msgCtxtRegex = regexp.MustCompile(`^msgctxt\s+"(.*)"\s*$`) commentRegex = regexp.MustCompile(`^#\.\s+(.*)$`) fileRefRegex = regexp.MustCompile(`^#:\s+(.*)$`) continueRegex = regexp.MustCompile(`^"(.*)"\s*$`) supportedLocales = []Locale{ {Code: "ar", Name: "Arabic", NativeName: "العربية"}, {Code: "bg", Name: "Bulgarian", NativeName: "Български"}, {Code: "cs", Name: "Czech", NativeName: "Čeština"}, {Code: "da", Name: "Danish", NativeName: "Dansk"}, {Code: "de", Name: "German", NativeName: "Deutsch"}, {Code: "el", Name: "Greek", NativeName: "Ελληνικά"}, {Code: "en-GB", Name: "English", NativeName: "English"}, {Code: "es-ES", Name: "Spanish (Spain)", NativeName: "Español (España)"}, {Code: "es-419", Name: "Spanish (Latin America)", NativeName: "Español (Latinoamérica)"}, {Code: "fi", Name: "Finnish", NativeName: "Suomi"}, {Code: "fr", Name: "French", NativeName: "Français"}, {Code: "he", Name: "Hebrew", NativeName: "עברית"}, {Code: "hi", Name: "Hindi", NativeName: "हिन्दी"}, {Code: "hr", Name: "Croatian", NativeName: "Hrvatski"}, {Code: "hu", Name: "Hungarian", NativeName: "Magyar"}, {Code: "id", Name: "Indonesian", NativeName: "Bahasa Indonesia"}, {Code: "it", Name: "Italian", NativeName: "Italiano"}, {Code: "ja", Name: "Japanese", NativeName: "日本語"}, {Code: "ko", Name: "Korean", NativeName: "한국어"}, {Code: "lt", Name: "Lithuanian", NativeName: "Lietuvių"}, {Code: "nl", Name: "Dutch", NativeName: "Nederlands"}, {Code: "no", Name: "Norwegian", NativeName: "Norsk"}, {Code: "pl", Name: "Polish", NativeName: "Polski"}, {Code: "pt-BR", Name: "Portuguese (Brazil)", NativeName: "Português (Brasil)"}, {Code: "ro", Name: "Romanian", NativeName: "Română"}, {Code: "ru", Name: "Russian", NativeName: "Русский"}, {Code: "sv-SE", Name: "Swedish", NativeName: "Svenska"}, {Code: "th", Name: "Thai", NativeName: "ไทย"}, {Code: "tr", Name: "Turkish", NativeName: "Türkçe"}, {Code: "uk", Name: "Ukrainian", NativeName: "Українська"}, {Code: "vi", Name: "Vietnamese", NativeName: "Tiếng Việt"}, {Code: "zh-CN", Name: "Chinese (Simplified)", NativeName: "中文 (简体)"}, {Code: "zh-TW", Name: "Chinese (Traditional)", NativeName: "中文 (繁體)"}, } languageMap = make(map[string]string) ) func init() { for _, locale := range supportedLocales { languageMap[locale.Code] = locale.NativeName } } func main() { fmt.Printf("Fluxer Localization Tool\n") fmt.Printf("========================\n\n") apiKey := os.Getenv("OPENROUTER_API_KEY") if apiKey == "" { fmt.Printf("Error: OPENROUTER_API_KEY environment variable not set\n") os.Exit(1) } startTime := time.Now() processCodeLocalization(apiKey) fmt.Printf("%s", "\n"+strings.Repeat("=", 50)+"\n\n") duration := time.Since(startTime) fmt.Printf("\n========================\n") fmt.Printf("Localization complete in %v\n", duration.Round(time.Second)) } func processCodeLocalization(apiKey string) { fmt.Printf("Processing code localization...\n\n") fmt.Printf("Step 1: Extracting translatable strings from Gleam files...\n") strings := extractStringsFromGleam("src") if len(strings) == 0 { fmt.Printf("No translatable strings found\n") return } fmt.Printf("Found %d translatable strings\n\n", len(strings)) fmt.Printf("Step 2: Creating POT template file...\n") potFile := "locales/messages.pot" if err := createPOTFile(potFile, strings); err != nil { fmt.Printf("Failed to create POT file: %v\n", err) return } fmt.Printf("Created %s\n\n", potFile) fmt.Printf("Step 3: Processing translations for all locales...\n") processAllTranslations(strings, apiKey) fmt.Printf("\n") fmt.Printf("Step 4: Compiling PO files to MO files...\n") if err := compileMOFiles(); err != nil { fmt.Printf("Warning: Failed to compile MO files: %v\n", err) fmt.Printf("Make sure msgfmt is installed (part of gettext package)\n") } else { fmt.Printf("Successfully compiled all MO files\n") } } func extractStringsFromGleam(srcDir string) []TranslatableString { tempPOT := "temp_extracted.pot" defer os.Remove(tempPOT) var gleamFiles []string err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if strings.HasSuffix(path, ".gleam") { gleamFiles = append(gleamFiles, path) } return nil }) if err != nil { fmt.Printf("Error walking directory: %v\n", err) return nil } if len(gleamFiles) == 0 { return nil } args := []string{ "--language=C", "--keyword=g_:2", "--keyword=n_:2,3,4", "--keyword=p_:1c,3", "--keyword=np_:1c,3,4", "--from-code=UTF-8", "--output=" + tempPOT, "--no-wrap", } args = append(args, gleamFiles...) cmd := exec.Command("xgettext", args...) if output, err := cmd.CombinedOutput(); err != nil { fmt.Printf("xgettext failed: %v\nOutput: %s\n", err, output) return extractStringsFromGleamRegex(srcDir) } entries, err := parsePOFile(tempPOT) if err != nil { fmt.Printf("Failed to parse generated POT file: %v\n", err) return extractStringsFromGleamRegex(srcDir) } var result []TranslatableString for _, entry := range entries { if entry.MsgID == "" { continue } var files []FileLocation for _, file := range entry.Files { parts := strings.Split(file, ":") if len(parts) >= 2 { line := 0 if _, err := fmt.Sscanf(parts[1], "%d", &line); err != nil { fmt.Printf("Warning: Failed to parse line number: %v\n", err) } files = append(files, FileLocation{FilePath: parts[0], Line: line}) } } result = append(result, TranslatableString{ MsgID: entry.MsgID, MsgIDPlural: entry.MsgIDPlural, Context: entry.Context, Comments: entry.Comments, Files: files, IsPlural: entry.IsPlural, HasContext: entry.HasContext, }) } sort.Slice(result, func(i, j int) bool { if result[i].MsgID != result[j].MsgID { return result[i].MsgID < result[j].MsgID } return result[i].Context < result[j].Context }) return result } func extractStringsFromGleamRegex(srcDir string) []TranslatableString { stringsMap := make(map[string]*TranslatableString) err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !strings.HasSuffix(path, ".gleam") { return nil } content, err := os.ReadFile(path) if err != nil { fmt.Printf("Error reading %s: %v\n", path, err) return nil } extractFromFile(string(content), path, stringsMap) return nil }) if err != nil { fmt.Printf("Error walking directory: %v\n", err) } var result []TranslatableString for _, str := range stringsMap { result = append(result, *str) } sort.Slice(result, func(i, j int) bool { if result[i].MsgID != result[j].MsgID { return result[i].MsgID < result[j].MsgID } return result[i].Context < result[j].Context }) return result } func extractFromFile(content, filePath string, stringsMap map[string]*TranslatableString) { lines := strings.Split(content, "\n") for lineNum, line := range lines { if matches := gFuncRegex.FindAllStringSubmatch(line, -1); matches != nil { for _, match := range matches { key := match[1] addString(stringsMap, key, "", "", false, false, filePath, lineNum+1) } } if matches := nFuncRegex.FindAllStringSubmatch(line, -1); matches != nil { for _, match := range matches { singular := match[2] plural := match[3] addString(stringsMap, singular, plural, "", true, false, filePath, lineNum+1) } } if matches := pFuncRegex.FindAllStringSubmatch(line, -1); matches != nil { for _, match := range matches { context := match[1] msgid := match[3] addString(stringsMap, msgid, "", context, false, true, filePath, lineNum+1) } } if matches := npFuncRegex.FindAllStringSubmatch(line, -1); matches != nil { for _, match := range matches { context := match[1] singular := match[3] plural := match[4] addString(stringsMap, singular, plural, context, true, true, filePath, lineNum+1) } } } } func addString(stringsMap map[string]*TranslatableString, msgID, msgIDPlural, context string, isPlural, hasContext bool, filePath string, line int) { key := msgID if hasContext { key = context + "|" + msgID } if existing, ok := stringsMap[key]; ok { existing.Files = append(existing.Files, FileLocation{FilePath: filePath, Line: line}) } else { stringsMap[key] = &TranslatableString{ MsgID: msgID, MsgIDPlural: msgIDPlural, Context: context, IsPlural: isPlural, HasContext: hasContext, Files: []FileLocation{{FilePath: filePath, Line: line}}, } } } func createPOTFile(filename string, translatableStrings []TranslatableString) error { if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { return err } existingTimestamp := time.Now().Format("2006-01-02 15:04-0700") if existingContent, err := os.ReadFile(filename); err == nil { lines := strings.Split(string(existingContent), "\n") for _, line := range lines { if strings.Contains(line, "POT-Creation-Date:") { if matches := regexp.MustCompile(`POT-Creation-Date: ([^\\]+)\\n`).FindStringSubmatch(line); len(matches) > 1 { existingTimestamp = matches[1] break } } } } var content strings.Builder content.WriteString(`# SOME DESCRIPTIVE TITLE. # Copyright (C) 2026 Fluxer Contributors # This file is distributed under the same license as the Fluxer Marketing package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: Fluxer Marketing 1.0.0\n" "Report-Msgid-Bugs-To: support@fluxer.app\n" "POT-Creation-Date: ` + existingTimestamp + `\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" `) for _, str := range translatableStrings { for _, file := range str.Files { content.WriteString(fmt.Sprintf("#: %s:%d\n", file.FilePath, file.Line)) } if str.HasContext { content.WriteString(fmt.Sprintf("msgctxt \"%s\"\n", escapeString(str.Context))) } content.WriteString(fmt.Sprintf("msgid \"%s\"\n", escapeString(str.MsgID))) if str.IsPlural { content.WriteString(fmt.Sprintf("msgid_plural \"%s\"\n", escapeString(str.MsgIDPlural))) content.WriteString("msgstr[0] \"\"\n") content.WriteString("msgstr[1] \"\"\n") } else { content.WriteString("msgstr \"\"\n") } content.WriteString("\n") } newContent := content.String() if existingContent, err := os.ReadFile(filename); err == nil { existingNormalized := normalizeContentForComparison(string(existingContent)) newNormalized := normalizeContentForComparison(newContent) if existingNormalized == newNormalized { return nil } } return os.WriteFile(filename, []byte(newContent), 0644) } func normalizeContentForComparison(content string) string { lines := strings.Split(content, "\n") var normalized []string for _, line := range lines { if strings.Contains(line, "POT-Creation-Date:") { continue } normalized = append(normalized, line) } return strings.Join(normalized, "\n") } func processAllTranslations(referenceStrings []TranslatableString, apiKey string) { referenceEntries := convertToPoEntries(referenceStrings) jobs, totalMissing := scanForMissingTranslations("locales", referenceEntries) if totalMissing == 0 { fmt.Printf("All translations are complete! No work needed.\n") return } fmt.Printf("Found %d missing translations across %d locales\n", totalMissing, len(jobs)) fmt.Printf("Processing with %d workers, batch size %d\n", maxWorkers, batchSize) fmt.Printf("===================================\n\n") results := processTranslationJobs(jobs, totalMissing, apiKey) printSummary(results) } func convertToPoEntries(strings []TranslatableString) []POEntry { var entries []POEntry for _, str := range strings { var files []string for _, file := range str.Files { files = append(files, fmt.Sprintf("%s:%d", file.FilePath, file.Line)) } entry := POEntry{ Context: str.Context, Comments: str.Comments, Files: files, MsgID: str.MsgID, MsgIDPlural: str.MsgIDPlural, IsPlural: str.IsPlural, HasContext: str.HasContext, } if str.IsPlural { entry.MsgStr = []string{"", ""} } else { entry.MsgStr = []string{""} } entries = append(entries, entry) } return entries } func scanForMissingTranslations(localesDir string, referenceEntries []POEntry) ([]TranslationJob, int) { var jobs []TranslationJob totalMissing := 0 for locale, language := range languageMap { localeDir := filepath.Join(localesDir, locale) if err := os.MkdirAll(localeDir, 0755); err != nil { fmt.Printf("Failed to create directory %s: %v\n", localeDir, err) continue } poFile := filepath.Join(localeDir, "messages.po") needsUpdate := false if _, err := os.Stat(poFile); os.IsNotExist(err) { if err := createLocaleFileFromReference(poFile, referenceEntries, language, locale); err != nil { fmt.Printf("Failed to create %s: %v\n", poFile, err) continue } } else { needsUpdate = syncWithReference(poFile, referenceEntries) } fmt.Printf("Scanning %s (%s)...", locale, language) currentEntries, err := parsePOFile(poFile) if err != nil { fmt.Printf(" Error: %v\n", err) continue } missingEntries := findMissingTranslations(currentEntries, referenceEntries) if len(missingEntries) == 0 && !needsUpdate { fmt.Printf(" Complete!\n") continue } if needsUpdate { fmt.Printf(" %d missing (synced with source)", len(missingEntries)) } else { fmt.Printf(" %d missing", len(missingEntries)) } if len(missingEntries) == 1 { fmt.Printf(" (msgid: %q)", missingEntries[0].MsgID) } fmt.Printf("\n") jobs = append(jobs, TranslationJob{ Locale: locale, FilePath: poFile, Entries: missingEntries, Language: language, TotalCount: len(missingEntries), }) totalMissing += len(missingEntries) } sort.Slice(jobs, func(i, j int) bool { return jobs[i].TotalCount > jobs[j].TotalCount }) return jobs, totalMissing } func syncWithReference(filename string, referenceEntries []POEntry) bool { currentEntries, err := parsePOFile(filename) if err != nil { return false } currentMap := make(map[string]*POEntry) obsoleteMap := make(map[string]*POEntry) for i := range currentEntries { key := currentEntries[i].MsgID if currentEntries[i].HasContext { key = currentEntries[i].Context + "|" + currentEntries[i].MsgID } currentMap[key] = ¤tEntries[i] } refMap := make(map[string]*POEntry) for i := range referenceEntries { key := referenceEntries[i].MsgID if referenceEntries[i].HasContext { key = referenceEntries[i].Context + "|" + referenceEntries[i].MsgID } refMap[key] = &referenceEntries[i] } var updatedEntries []POEntry needsUpdate := false for _, entry := range currentEntries { if entry.MsgID == "" { updatedEntries = append(updatedEntries, entry) break } } for _, refEntry := range referenceEntries { if refEntry.MsgID == "" { continue } refKey := refEntry.MsgID if refEntry.HasContext { refKey = refEntry.Context + "|" + refEntry.MsgID } if _, exists := currentMap[refKey]; !exists { for currKey, currEntry := range currentMap { if currEntry.MsgID == "" { continue } if !allEmpty(currEntry.MsgStr) { lenDiff := len(refEntry.MsgID) - len(currEntry.MsgID) if lenDiff >= -20 && lenDiff <= 20 { obsoleteMap[currKey] = currEntry } } } } } for _, refEntry := range referenceEntries { if refEntry.MsgID == "" { continue } key := refEntry.MsgID if refEntry.HasContext { key = refEntry.Context + "|" + refEntry.MsgID } if currentEntry, exists := currentMap[key]; exists { newEntry := refEntry newEntry.MsgStr = currentEntry.MsgStr updatedEntries = append(updatedEntries, newEntry) } else { newEntry := refEntry if refEntry.IsPlural { newEntry.MsgStr = []string{"", ""} } else { newEntry.MsgStr = []string{""} } updatedEntries = append(updatedEntries, newEntry) needsUpdate = true } } removedCount := 0 for key, entry := range currentMap { if _, exists := refMap[key]; !exists && key != "" && entry.MsgID != "" { removedCount++ needsUpdate = true if !allEmpty(entry.MsgStr) { if _, isObsolete := obsoleteMap[key]; isObsolete { fmt.Printf(" [obsolete: %q]", entry.MsgID) } } } } if needsUpdate { if err := writePOFile(filename, updatedEntries); err != nil { fmt.Printf("Warning: Failed to write PO file: %v\n", err) } } return needsUpdate } func createLocaleFileFromReference(filename string, referenceEntries []POEntry, language, locale string) error { var content strings.Builder pluralForm := getPluralForm(locale) content.WriteString(fmt.Sprintf(`# %s translations for Fluxer Marketing # Copyright (C) 2026 Fluxer Contributors msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Language: %s\n" "Plural-Forms: %s\n" `, language, locale, pluralForm)) for _, entry := range referenceEntries { if entry.MsgID == "" { continue } for _, file := range entry.Files { content.WriteString(fmt.Sprintf("#: %s\n", file)) } for _, comment := range entry.Comments { content.WriteString(fmt.Sprintf("#. %s\n", comment)) } if entry.HasContext { content.WriteString(fmt.Sprintf("msgctxt \"%s\"\n", escapeString(entry.Context))) } content.WriteString(fmt.Sprintf("msgid \"%s\"\n", escapeString(entry.MsgID))) if entry.IsPlural { content.WriteString(fmt.Sprintf("msgid_plural \"%s\"\n", escapeString(entry.MsgIDPlural))) content.WriteString("msgstr[0] \"\"\n") content.WriteString("msgstr[1] \"\"\n") } else { content.WriteString("msgstr \"\"\n") } content.WriteString("\n") } return os.WriteFile(filename, []byte(content.String()), 0644) } func getPluralForm(locale string) string { pluralForms := map[string]string{ "ar": "nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);", "cs": "nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;", "pl": "nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "ru": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "uk": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "hr": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);", "lt": "nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2);", "ro": "nplurals=3; plural=(n==1?0:(((n%100>19)||((n%100==0)&&(n!=0)))?2:1));", "ja": "nplurals=1; plural=0;", "ko": "nplurals=1; plural=0;", "zh-CN": "nplurals=1; plural=0;", "zh-TW": "nplurals=1; plural=0;", "vi": "nplurals=1; plural=0;", "th": "nplurals=1; plural=0;", "id": "nplurals=1; plural=0;", "tr": "nplurals=1; plural=0;", "hu": "nplurals=2; plural=(n != 1);", } if form, ok := pluralForms[locale]; ok { return form } return "nplurals=2; plural=(n != 1);" } func parsePOFile(filename string) ([]POEntry, error) { file, err := os.Open(filename) if err != nil { return nil, err } defer file.Close() var entries []POEntry var current POEntry var inMsgID, inMsgIDPlural, inMsgStr bool var currentMsgStrIndex int scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if trimmed == "" { if current.MsgID != "" { entries = append(entries, current) } current = POEntry{MsgStr: []string{}} inMsgID = false inMsgIDPlural = false inMsgStr = false currentMsgStrIndex = 0 continue } if strings.HasPrefix(trimmed, "#.") { if match := commentRegex.FindStringSubmatch(trimmed); match != nil { current.Comments = append(current.Comments, match[1]) } } else if strings.HasPrefix(trimmed, "#:") { if match := fileRefRegex.FindStringSubmatch(trimmed); match != nil { current.Files = append(current.Files, match[1]) } } else if strings.HasPrefix(trimmed, "msgctxt") { if match := msgCtxtRegex.FindStringSubmatch(trimmed); match != nil { current.Context = unescapeString(match[1]) current.HasContext = true } } else if strings.HasPrefix(trimmed, "msgid_plural") { if match := msgIDPluralRegex.FindStringSubmatch(trimmed); match != nil { current.MsgIDPlural = unescapeString(match[1]) current.IsPlural = true inMsgIDPlural = true inMsgID = false inMsgStr = false } } else if strings.HasPrefix(trimmed, "msgid") { if match := msgIDRegex.FindStringSubmatch(trimmed); match != nil { current.MsgID = unescapeString(match[1]) inMsgID = true inMsgIDPlural = false inMsgStr = false } } else if strings.HasPrefix(trimmed, "msgstr") { if match := msgStrRegex.FindStringSubmatch(trimmed); match != nil { if match[1] != "" { index := 0 if _, err := fmt.Sscanf(match[1], "%d", &index); err != nil { fmt.Printf("Warning: Failed to parse msgstr index: %v\n", err) } currentMsgStrIndex = index for len(current.MsgStr) <= index { current.MsgStr = append(current.MsgStr, "") } current.MsgStr[index] = unescapeString(match[2]) } else { currentMsgStrIndex = 0 if len(current.MsgStr) == 0 { current.MsgStr = []string{unescapeString(match[2])} } else { current.MsgStr[0] = unescapeString(match[2]) } } inMsgID = false inMsgIDPlural = false inMsgStr = true } } else if strings.HasPrefix(trimmed, "\"") { if match := continueRegex.FindStringSubmatch(trimmed); match != nil { if inMsgID { current.MsgID += unescapeString(match[1]) } else if inMsgIDPlural { current.MsgIDPlural += unescapeString(match[1]) } else if inMsgStr && currentMsgStrIndex < len(current.MsgStr) { current.MsgStr[currentMsgStrIndex] += unescapeString(match[1]) } } } } if current.MsgID != "" { entries = append(entries, current) } return entries, scanner.Err() } func findMissingTranslations(current, reference []POEntry) []POEntry { currentMap := make(map[string]POEntry) for _, entry := range current { key := entry.MsgID if entry.HasContext { key = entry.Context + "|" + entry.MsgID } currentMap[key] = entry } var missing []POEntry for _, refEntry := range reference { if refEntry.MsgID == "" { continue } key := refEntry.MsgID if refEntry.HasContext { key = refEntry.Context + "|" + refEntry.MsgID } if currentEntry, exists := currentMap[key]; !exists || allEmpty(currentEntry.MsgStr) { missing = append(missing, refEntry) } } return missing } func allEmpty(strs []string) bool { for _, s := range strs { if s != "" { return false } } return true } func processTranslationJobs(jobs []TranslationJob, totalMissing int, apiKey string) []TranslationResult { ctx := context.Background() overallBar := progressbar.NewOptions(totalMissing, progressbar.OptionSetDescription("Overall Progress"), progressbar.OptionSetWidth(50), progressbar.OptionShowCount(), progressbar.OptionShowIts(), progressbar.OptionSetItsString("translations"), progressbar.OptionThrottle(100*time.Millisecond), progressbar.OptionShowElapsedTimeOnFinish(), ) jobChan := make(chan TranslationJob, len(jobs)) resultChan := make(chan TranslationResult, len(jobs)) var wg sync.WaitGroup for i := 0; i < maxWorkers; i++ { wg.Add(1) go func(workerID int) { defer wg.Done() for job := range jobChan { result := processLocale(ctx, job, workerID, overallBar, apiKey) resultChan <- result } }(i) } for _, job := range jobs { jobChan <- job } close(jobChan) go func() { wg.Wait() close(resultChan) }() var results []TranslationResult for result := range resultChan { results = append(results, result) } _ = overallBar.Finish() fmt.Println() return results } func processLocale(ctx context.Context, job TranslationJob, workerID int, overallBar *progressbar.ProgressBar, apiKey string) TranslationResult { fmt.Printf("Worker %d: Starting %s (%s) - %d translations\n", workerID+1, job.Locale, job.Language, job.TotalCount) translations := make(map[string][]string) translatedCount := 0 for i := 0; i < len(job.Entries); i += batchSize { end := min(i+batchSize, len(job.Entries)) batch := job.Entries[i:end] batchTranslations, err := translateBatch(ctx, batch, job.Language, apiKey) if err != nil { fmt.Printf("Worker %d: Batch translation failed for %s: %v\n", workerID+1, job.Locale, err) continue } for key, translation := range batchTranslations { translations[key] = translation translatedCount++ _ = overallBar.Add(1) } time.Sleep(500 * time.Millisecond) } if translatedCount == 0 { return TranslationResult{ Locale: job.Locale, FilePath: job.FilePath, Success: false, Translated: 0, Error: fmt.Errorf("no translations completed"), } } err := updatePOFile(job.FilePath, translations) success := err == nil if success { fmt.Printf("Worker %d: Completed %s (%d/%d translations)\n", workerID+1, job.Locale, translatedCount, job.TotalCount) } else { fmt.Printf("Worker %d: Failed to update %s: %v\n", workerID+1, job.Locale, err) } return TranslationResult{ Locale: job.Locale, FilePath: job.FilePath, Success: success, Translated: translatedCount, Error: err, } } func translateBatch(ctx context.Context, entries []POEntry, targetLanguage string, apiKey string) (map[string][]string, error) { if len(entries) == 0 { return make(map[string][]string), nil } prompt := fmt.Sprintf(`You are a professional translator. Translate the following English text snippets to %s. CRITICAL LANGUAGE REQUIREMENTS: - ONLY translate to %s - never use any other language - If the target language is "English (UK)", translate from American English to British English spelling and terminology - Double-check that your response is entirely in the target language %s - Never mix languages in your response IMPORTANT FORMATTING RULES: 1. Preserve ALL formatting, placeholders, and special syntax exactly as shown 2. Keep {0}, {1}, etc. placeholders unchanged 3. Preserve plural forms syntax like {0, plural, =0 {text} one {text} other {text}} 4. Maintain HTML tags and markdown formatting 5. Keep technical terms and brand names unchanged when appropriate 6. Provide natural, contextually appropriate translations 7. For plural forms, provide appropriate translations for both singular and plural 8. Return ONLY the translation for each text, no explanations 9. PRESERVE ABBREVIATIONS: If the source uses abbreviations like "Docs" instead of "Documentation", maintain the same level of abbreviation in the target language 10. NEVER TRANSLATE OR CONVERT CURRENCIES: ALWAYS preserve currency symbols and amounts exactly as they appear in the source text. For USD ($): always keep the dollar sign before the amount with no space ("$49.99/year"). For EUR (€): follow EU Interinstitutional Style Guide - place euro sign BEFORE the amount in English ("€100 EUR") but AFTER the amount with a space in all other languages ("100 € EUR"). NEVER convert between different currencies or change currency symbols under any circumstances 11. PRESERVE WHITESPACE: Maintain the same spacing, line breaks, and indentation as the source 12. KEEP SPECIAL CHARACTERS: Preserve symbols, emojis, and special punctuation marks 13. MAINTAIN CAPITALIZATION PATTERNS: Follow target language conventions while preserving intentional capitalization (like ALL CAPS for emphasis) 14. CONSISTENT TERMINOLOGY: Use the same translation for recurring terms throughout the text 15. NO MACHINE TRANSLATION ARTIFACTS: Avoid awkward phrasing that sounds like machine translation 16. VERIFY COMPLETENESS: Ensure no part of the source text is omitted in the translation TARGET LANGUAGE: %s SOURCE LANGUAGE: English Translate these texts: `, targetLanguage, targetLanguage, targetLanguage, targetLanguage) for i, entry := range entries { contextInfo := "" if entry.Context != "" { contextInfo = fmt.Sprintf(" (Context: %s)", entry.Context) } if len(entry.Comments) > 0 { contextInfo += fmt.Sprintf(" (Note: %s)", strings.Join(entry.Comments, "; ")) } if entry.IsPlural { prompt += fmt.Sprintf("%d. Singular: \"%s\", Plural: \"%s\"%s\n", i+1, entry.MsgID, entry.MsgIDPlural, contextInfo) } else { prompt += fmt.Sprintf("%d. \"%s\"%s\n", i+1, entry.MsgID, contextInfo) } } if entries[0].IsPlural { prompt += "\nRespond with translations in the format:\n1. Singular: [translation], Plural: [translation]\n2. Singular: [translation], Plural: [translation]\n..." } else { prompt += "\nRespond with translations in the format:\n1. [translation]\n2. [translation]\n..." } reqBody := OpenRouterRequest{ Model: "gpt-4o-mini", Messages: []Message{ {Role: "user", Content: prompt}, }, } jsonData, err := json.Marshal(reqBody) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) } var apiResp OpenRouterResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } if len(apiResp.Choices) == 0 { return nil, fmt.Errorf("no response from API") } return parseTranslationResponse(apiResp.Choices[0].Message.Content, entries) } func parseTranslationResponse(response string, entries []POEntry) (map[string][]string, error) { lines := strings.Split(strings.TrimSpace(response), "\n") translations := make(map[string][]string) numberRegex := regexp.MustCompile(`^\d+\.\s*(.*)$`) pluralRegex := regexp.MustCompile(`Singular:\s*"?([^",]*)"?,\s*Plural:\s*"?([^"]*)"?`) entryIndex := 0 for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } var translation string if match := numberRegex.FindStringSubmatch(line); match != nil { translation = match[1] } else { continue } if entryIndex < len(entries) { entry := entries[entryIndex] key := entry.MsgID if entry.HasContext { key = entry.Context + "|" + entry.MsgID } if entry.IsPlural { if match := pluralRegex.FindStringSubmatch(translation); match != nil { singular := strings.Trim(match[1], `"`) plural := strings.Trim(match[2], `"`) translations[key] = []string{singular, plural} } } else { cleanTranslation := strings.Trim(translation, `"`) translations[key] = []string{cleanTranslation} } entryIndex++ } } return translations, nil } func updatePOFile(filename string, translations map[string][]string) error { entries, err := parsePOFile(filename) if err != nil { return err } for i, entry := range entries { key := entry.MsgID if entry.HasContext { key = entry.Context + "|" + entry.MsgID } if translation, exists := translations[key]; exists { entries[i].MsgStr = translation } } return writePOFile(filename, entries) } func writePOFile(filename string, entries []POEntry) error { var content strings.Builder content.WriteString(`# SOME DESCRIPTIVE TITLE. # Copyright (C) 2026 Fluxer Contributors msgid "" msgstr "" "Content-Type: text/plain; charset=UTF-8\n" `) for _, entry := range entries { if entry.MsgID == "" { continue } for _, file := range entry.Files { content.WriteString(fmt.Sprintf("#: %s\n", file)) } for _, comment := range entry.Comments { content.WriteString(fmt.Sprintf("#. %s\n", comment)) } if entry.HasContext { content.WriteString(fmt.Sprintf("msgctxt \"%s\"\n", escapeString(entry.Context))) } content.WriteString(fmt.Sprintf("msgid \"%s\"\n", escapeString(entry.MsgID))) if entry.IsPlural { content.WriteString(fmt.Sprintf("msgid_plural \"%s\"\n", escapeString(entry.MsgIDPlural))) for i, str := range entry.MsgStr { content.WriteString(fmt.Sprintf("msgstr[%d] \"%s\"\n", i, escapeString(str))) } } else { if len(entry.MsgStr) > 0 { content.WriteString(fmt.Sprintf("msgstr \"%s\"\n", escapeString(entry.MsgStr[0]))) } else { content.WriteString("msgstr \"\"\n") } } content.WriteString("\n") } return os.WriteFile(filename, []byte(content.String()), 0644) } func escapeString(s string) string { s = strings.ReplaceAll(s, "\\", "\\\\") s = strings.ReplaceAll(s, "\"", "\\\"") s = strings.ReplaceAll(s, "\n", "\\n") s = strings.ReplaceAll(s, "\r", "\\r") s = strings.ReplaceAll(s, "\t", "\\t") return s } func unescapeString(s string) string { s = strings.ReplaceAll(s, "\\\"", "\"") s = strings.ReplaceAll(s, "\\n", "\n") s = strings.ReplaceAll(s, "\\r", "\r") s = strings.ReplaceAll(s, "\\t", "\t") s = strings.ReplaceAll(s, "\\\\", "\\") return s } func compileMOFiles() error { localesDir := "locales" privDir := "priv/locales" entries, err := os.ReadDir(localesDir) if err != nil { return err } compiledCount := 0 for _, entry := range entries { if !entry.IsDir() || entry.Name() == "." || entry.Name() == ".." { continue } locale := entry.Name() poFile := filepath.Join(localesDir, locale, "messages.po") if _, err := os.Stat(poFile); os.IsNotExist(err) { continue } moDir := filepath.Join(privDir, locale, "LC_MESSAGES") if err := os.MkdirAll(moDir, 0755); err != nil { fmt.Printf("Failed to create directory %s: %v\n", moDir, err) continue } moFile := filepath.Join(moDir, "messages.mo") cmd := exec.Command("msgfmt", poFile, "-o", moFile) if output, err := cmd.CombinedOutput(); err != nil { fmt.Printf("Failed to compile %s: %v\nOutput: %s\n", locale, err, output) continue } fmt.Printf("Compiled %s/messages.po → %s\n", locale, moFile) compiledCount++ } if compiledCount > 0 { fmt.Printf("Successfully compiled %d locale files\n", compiledCount) return nil } return fmt.Errorf("no files compiled") } func printSummary(results []TranslationResult) { fmt.Printf("\n===================================\n") fmt.Printf("TRANSLATION SUMMARY\n") fmt.Printf("===================================\n\n") totalProcessed := 0 totalSuccess := 0 totalTranslations := 0 var successful []TranslationResult var failed []TranslationResult for _, result := range results { totalProcessed++ totalTranslations += result.Translated if result.Success { totalSuccess++ successful = append(successful, result) } else { failed = append(failed, result) } } if len(successful) > 0 { fmt.Printf("SUCCESSFUL LOCALIZATIONS:\n") for _, result := range successful { lang := languageMap[result.Locale] fmt.Printf(" %s (%s): %d translations\n", result.Locale, lang, result.Translated) } fmt.Println() } if len(failed) > 0 { fmt.Printf("FAILED LOCALIZATIONS:\n") for _, result := range failed { lang := languageMap[result.Locale] fmt.Printf(" %s (%s): %v\n", result.Locale, lang, result.Error) } fmt.Println() } fmt.Printf("STATISTICS:\n") fmt.Printf(" Total Locales Processed: %d\n", totalProcessed) fmt.Printf(" Successful: %d\n", totalSuccess) fmt.Printf(" Failed: %d\n", len(failed)) fmt.Printf(" Total Translations: %d\n", totalTranslations) if totalSuccess == totalProcessed && totalProcessed > 0 { fmt.Printf("\nAll localizations completed successfully!\n") } else if totalProcessed > 0 { fmt.Printf("\n%d of %d localizations completed successfully.\n", totalSuccess, totalProcessed) } } func min(a, b int) int { if a < b { return a } return b }