323 lines
7.4 KiB
Go
323 lines
7.4 KiB
Go
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 <command> <subcommand> [args]
|
|
|
|
Commands:
|
|
translate add <key> <english> [context] Add translation to all languages (auto-translates via Gemini)
|
|
translate get <key> Show translation across all languages
|
|
translate delete <key> 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 <key> <english> [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>")
|
|
}
|
|
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>")
|
|
}
|
|
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)
|
|
}
|