package main import ( "bufio" "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "sync" "inou/lib" ) var langDir string // Language codes mapped to full names for Gemini prompts var languages = map[string]string{ "da": "Danish", "de": "German", "es": "Spanish", "fi": "Finnish", "fr": "French", "it": "Italian", "ja": "Japanese", "ko": "Korean", "nl": "Dutch", "no": "Norwegian", "pt": "Portuguese", "ru": "Russian", "sv": "Swedish", "zh": "Chinese (Simplified)", } func main() { // Lang directory is always in dev tree — tool runs locally, deploy rsyncs to staging/prod langDir = "portal/lang" if info, err := os.Stat(langDir); err != nil || !info.IsDir() { fatal("cannot find %s — run from project root", langDir) } if len(os.Args) < 2 { usage() } switch os.Args[1] { case "translate": if len(os.Args) < 3 { usage() } switch os.Args[2] { case "add": cmdTranslateAdd() case "get": cmdTranslateGet() case "delete": cmdTranslateDelete() default: usage() } default: usage() } } func usage() { fmt.Fprintln(os.Stderr, `Usage: toolkit [args] Commands: translate add [context] Add translation to all languages (auto-translates via Gemini) translate get Show translation across all languages translate delete Remove translation from all languages`) os.Exit(1) } func fatal(msg string, args ...any) { fmt.Fprintf(os.Stderr, "error: "+msg+"\n", args...) os.Exit(1) } // cmdTranslateAdd adds a key with English value and auto-translates to all languages func cmdTranslateAdd() { if len(os.Args) < 5 { fatal("usage: toolkit translate add [context]") } key := os.Args[3] english := os.Args[4] context := "" if len(os.Args) > 5 { context = os.Args[5] } // Write English if err := yamlSet(filepath.Join(langDir, "en.yaml"), key, english); err != nil { fatal("en.yaml: %v", err) } fmt.Printf("en: %s\n", english) // Load Gemini key from env or anthropic.env initGeminiKey() if lib.GeminiKey == "" { fatal("GEMINI_API_KEY not set (env or /tank/inou/anthropic.env)") } // Translate to all other languages in parallel type result struct { lang, translation string err error } results := make(chan result, len(languages)) var wg sync.WaitGroup for code, name := range languages { wg.Add(1) go func(code, name string) { defer wg.Done() t, err := translate(english, name, context) results <- result{code, t, err} }(code, name) } go func() { wg.Wait() close(results) }() // Collect and write var errors []string for r := range results { if r.err != nil { errors = append(errors, fmt.Sprintf("%s: %v", r.lang, r.err)) continue } if err := yamlSet(filepath.Join(langDir, r.lang+".yaml"), key, r.translation); err != nil { errors = append(errors, fmt.Sprintf("%s: write: %v", r.lang, err)) continue } fmt.Printf("%s: %s\n", r.lang, r.translation) } if len(errors) > 0 { fmt.Fprintf(os.Stderr, "\nErrors:\n") for _, e := range errors { fmt.Fprintf(os.Stderr, " %s\n", e) } os.Exit(1) } } // cmdTranslateGet shows a key across all languages func cmdTranslateGet() { if len(os.Args) < 4 { fatal("usage: toolkit translate get ") } key := os.Args[3] files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml")) sort.Strings(files) found := false for _, f := range files { lang := strings.TrimSuffix(filepath.Base(f), ".yaml") if val, ok := yamlGet(f, key); ok { fmt.Printf("%s: %s\n", lang, val) found = true } } if !found { fatal("key %q not found", key) } } // cmdTranslateDelete removes a key from all languages func cmdTranslateDelete() { if len(os.Args) < 4 { fatal("usage: toolkit translate delete ") } key := os.Args[3] files, _ := filepath.Glob(filepath.Join(langDir, "*.yaml")) for _, f := range files { lang := strings.TrimSuffix(filepath.Base(f), ".yaml") if err := yamlDelete(f, key); err != nil { fmt.Fprintf(os.Stderr, "%s: %v\n", lang, err) } else { fmt.Printf("%s: deleted\n", lang) } } } // translate calls Gemini to translate a short UI string func translate(english, targetLang, context string) (string, error) { prompt := fmt.Sprintf( "Translate this UI label to %s. Reply with ONLY the translated text, nothing else.\n\nText: %s", targetLang, english) if context != "" { prompt = fmt.Sprintf( "Translate this UI label to %s. Context: %s. Reply with ONLY the translated text, nothing else.\n\nText: %s", targetLang, context, english) } // Override defaults: plain text response, low temperature temp := 0.1 tokens := 100 mime := "text/plain" resp, err := lib.CallGeminiMultimodal( []lib.GeminiPart{{Text: prompt}}, &lib.GeminiConfig{Temperature: &temp, MaxOutputTokens: &tokens, ResponseMimeType: &mime}, ) if err != nil { return "", err } // Gemini sometimes wraps in quotes resp = strings.Trim(resp, "\"'") // If response came back as JSON (model ignored mime override), try to extract if strings.HasPrefix(resp, "{") { var m map[string]string if json.Unmarshal([]byte(resp), &m) == nil { for _, v := range m { return v, nil } } } return resp, nil } func initGeminiKey() { if key := os.Getenv("GEMINI_API_KEY"); key != "" { lib.GeminiKey = key return } for _, path := range []string{"anthropic.env", "/tank/inou/anthropic.env"} { data, err := os.ReadFile(path) if err != nil { continue } for _, line := range strings.Split(string(data), "\n") { parts := strings.SplitN(line, "=", 2) if len(parts) == 2 && strings.TrimSpace(parts[0]) == "GEMINI_API_KEY" { lib.GeminiKey = strings.TrimSpace(parts[1]) return } } } } // Simple YAML operations — our files are flat key: "value" or key: value func yamlGet(path, key string) (string, bool) { f, err := os.Open(path) if err != nil { return "", false } defer f.Close() prefix := key + ":" scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, prefix) { val := strings.TrimSpace(strings.TrimPrefix(trimmed, prefix)) val = strings.Trim(val, "\"") return val, true } } return "", false } func yamlSet(path, key, value string) error { lines, err := readLines(path) if err != nil { return err } newLine := fmt.Sprintf("%s: %q", key, value) prefix := key + ":" // Replace if exists for i, line := range lines { if strings.HasPrefix(strings.TrimSpace(line), prefix) { lines[i] = newLine return writeLines(path, lines) } } // Append before last empty line or at end lines = append(lines, newLine) return writeLines(path, lines) } func yamlDelete(path, key string) error { lines, err := readLines(path) if err != nil { return err } prefix := key + ":" var out []string for _, line := range lines { if !strings.HasPrefix(strings.TrimSpace(line), prefix) { out = append(out, line) } } if len(out) == len(lines) { return fmt.Errorf("key %q not found", key) } return writeLines(path, out) } func readLines(path string) ([]string, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() var lines []string scanner := bufio.NewScanner(f) for scanner.Scan() { lines = append(lines, scanner.Text()) } return lines, scanner.Err() } func writeLines(path string, lines []string) error { content := strings.Join(lines, "\n") + "\n" return os.WriteFile(path, []byte(content), 0644) }