inou/tools/toolkit/main.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)
}