Skip to content
English
On this page

QuickTranslate CLI: Traducción Instantánea con AWS Translate

Descripción del Escenario

Desarrollaremos una aplicación CLI en Go que permite:

  • Capturar texto seleccionado del sistema operativo
  • Escuchar combinaciones de teclas globalmente
  • Traducir texto usando AWS Translate
  • Mostrar ventana emergente con resultados
  • Configuración persistente de preferencias
  • Soporte para diferentes idiomas

Estructura del Directorio

quick-translate/
├── cmd/
│   └── translate/
│       └── main.go
├── internal/
│   ├── clipboard/
│   │   ├── monitor.go
│   │   └── utils.go
│   ├── hotkeys/
│   │   ├── listener.go
│   │   └── config.go
│   ├── translate/
│   │   ├── service.go
│   │   └── cache.go
│   ├── ui/
│   │   ├── window.go
│   │   └── styles.go
│   └── config/
│       ├── settings.go
│       └── persistence.go
├── pkg/
│   └── utils/
│       ├── aws.go
│       └── text.go
├── assets/
│   ├── icons/
│   └── styles/
├── config/
│   └── config.yaml
├── go.mod
└── go.sum

Etapa 1: Monitoreo del Portapapeles y Hotkeys

1.1 Monitor de Portapapeles

go
// internal/clipboard/monitor.go
package clipboard

import (
    "github.com/atotto/clipboard"
    "time"
)

type Monitor struct {
    lastContent string
    onChange    func(string)
    interval    time.Duration
}

func NewMonitor(interval time.Duration, onChange func(string)) *Monitor {
    return &Monitor{
        onChange: onChange,
        interval: interval,
    }
}

func (m *Monitor) Start() error {
    ticker := time.NewTicker(m.interval)
    
    go func() {
        for range ticker.C {
            content, err := clipboard.ReadAll()
            if err != nil {
                continue
            }

            if content != m.lastContent && content != "" {
                m.lastContent = content
                m.onChange(content)
            }
        }
    }()

    return nil
}

1.2 Captura de Hotkeys

go
// internal/hotkeys/listener.go
package hotkeys

import (
    "github.com/robotn/gohook"
)

type KeyCombination struct {
    Modifiers []string
    Key       string
}

type HotkeyListener struct {
    combinations map[KeyCombination]func()
}

func NewHotkeyListener() *HotkeyListener {
    return &HotkeyListener{
        combinations: make(map[KeyCombination]func()),
    }
}

func (h *HotkeyListener) Register(combo KeyCombination, callback func()) {
    h.combinations[combo] = callback
}

func (h *HotkeyListener) Start() error {
    hook.Register(hook.KeyDown, []string{"ctrl", "t"}, func(e hook.Event) {
        if callback, exists := h.combinations[KeyCombination{
            Modifiers: []string{"ctrl"},
            Key:      "t",
        }]; exists {
            callback()
        }
    })

    s := hook.Start()
    <-hook.Process(s)

    return nil
}

Etapa 2: Servicio de Traducción

2.1 Servicio AWS Translate

go
// internal/translate/service.go
package translate

import (
    "context"
    "github.com/aws/aws-sdk-go-v2/service/translate"
)

type Service struct {
    client *translate.Client
    cache  *Cache
}

func NewTranslateService(client *translate.Client) *Service {
    return &Service{
        client: client,
        cache:  NewCache(),
    }
}

func (s *Service) TranslateText(ctx context.Context, text string, targetLang string) (string, error) {
    // Verificar caché
    if translated, ok := s.cache.Get(text + targetLang); ok {
        return translated, nil
    }

    input := &translate.TranslateTextInput{
        Text:          &text,
        SourceLanguageCode: aws.String("auto"),
        TargetLanguageCode: aws.String(targetLang),
    }

    result, err := s.client.TranslateText(ctx, input)
    if err != nil {
        return "", err
    }

    // Guardar en caché
    s.cache.Set(text+targetLang, *result.TranslatedText)

    return *result.TranslatedText, nil
}

2.2 Caché de Traducciones

go
// internal/translate/cache.go
package translate

import (
    "sync"
    "time"
)

type cacheEntry struct {
    value     string
    timestamp time.Time
}

type Cache struct {
    entries map[string]cacheEntry
    mu      sync.RWMutex
    ttl     time.Duration
}

func NewCache() *Cache {
    return &Cache{
        entries: make(map[string]cacheEntry),
        ttl:     24 * time.Hour,
    }
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    if entry, ok := c.entries[key]; ok {
        if time.Since(entry.timestamp) < c.ttl {
            return entry.value, true
        }
        // Limpieza asíncrona de entradas expiradas
        go c.cleanup(key)
    }
    return "", false
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.entries[key] = cacheEntry{
        value:     value,
        timestamp: time.Now(),
    }
}

func (c *Cache) cleanup(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.entries, key)
}

Etapa 3: Interfaz de Usuario

3.1 Ventana Emergente

go
// internal/ui/window.go
package ui

import (
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/app"
    "fyne.io/fyne/v2/container"
    "fyne.io/fyne/v2/widget"
)

type Window struct {
    app    fyne.App
    window fyne.Window
}

func NewWindow() *Window {
    app := app.New()
    window := app.NewWindow("QuickTranslate")
    
    return &Window{
        app:    app,
        window: window,
    }
}

func (w *Window) ShowTranslation(original, translated string) {
    content := container.NewVBox(
        widget.NewLabel("Texto Original:"),
        widget.NewTextArea(original),
        widget.NewLabel("Traducción:"),
        widget.NewTextArea(translated),
    )

    w.window.SetContent(content)
    w.window.Resize(fyne.NewSize(400, 300))
    w.window.CenterOnScreen()
    w.window.Show()
}

func (w *Window) Hide() {
    w.window.Hide()
}

3.2 Estilos y Temas

go
// internal/ui/styles.go
package ui

import (
    "image/color"
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/theme"
)

type customTheme struct {
    fyne.Theme
}

func NewCustomTheme() fyne.Theme {
    return &customTheme{theme.DefaultTheme()}
}

func (t *customTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
    switch name {
    case theme.ColorNameBackground:
        return color.NRGBA{R: 240, G: 240, B: 250, A: 255}
    case theme.ColorNameText:
        return color.NRGBA{R: 40, G: 40, B: 40, A: 255}
    default:
        return t.Theme.Color(name, variant)
    }
}

Etapa 4: Configuración y Persistencia

4.1 Configuración

go
// internal/config/settings.go
package config

import (
    "gopkg.in/yaml.v2"
    "os"
    "path/filepath"
)

type Config struct {
    DefaultTargetLang string            `yaml:"defaultTargetLang"`
    HotKeys          map[string]string  `yaml:"hotKeys"`
    UISettings       UISettings         `yaml:"uiSettings"`
    AWS              AWSConfig         `yaml:"aws"`
}

type UISettings struct {
    WindowWidth  int    `yaml:"windowWidth"`
    WindowHeight int    `yaml:"windowHeight"`
    Theme        string `yaml:"theme"`
}

type AWSConfig struct {
    Region    string `yaml:"region"`
    Profile   string `yaml:"profile"`
}

func LoadConfig() (*Config, error) {
    homeDir, err := os.UserHomeDir()
    if err != nil {
        return nil, err
    }

    configPath := filepath.Join(homeDir, ".quicktranslate", "config.yaml")
    data, err := os.ReadFile(configPath)
    if err != nil {
        return getDefaultConfig(), nil
    }

    var config Config
    if err := yaml.Unmarshal(data, &config); err != nil {
        return nil, err
    }

    return &config, nil
}

4.2 Persistencia

go
// internal/config/persistence.go
package config

import (
    "os"
    "path/filepath"
    "gopkg.in/yaml.v2"
)

func SaveConfig(config *Config) error {
    homeDir, err := os.UserHomeDir()
    if err != nil {
        return err
    }

    configDir := filepath.Join(homeDir, ".quicktranslate")
    if err := os.MkdirAll(configDir, 0755); err != nil {
        return err
    }

    configPath := filepath.Join(configDir, "config.yaml")
    data, err := yaml.Marshal(config)
    if err != nil {
        return err
    }

    return os.WriteFile(configPath, data, 0644)
}

func getDefaultConfig() *Config {
    return &Config{
        DefaultTargetLang: "es",
        HotKeys: map[string]string{
            "translate": "ctrl+t",
        },
        UISettings: UISettings{
            WindowWidth:  400,
            WindowHeight: 300,
            Theme:        "light",
        },
        AWS: AWSConfig{
            Region:  "us-east-1",
            Profile: "default",
        },
    }
}

Etapa 5: Aplicación Principal

5.1 Main

go
// cmd/translate/main.go
package main

import (
    "context"
    "log"
    "github.com/yourusername/quick-translate/internal/clipboard"
    "github.com/yourusername/quick-translate/internal/hotkeys"
    "github.com/yourusername/quick-translate/internal/translate"
    "github.com/yourusername/quick-translate/internal/ui"
    "github.com/yourusername/quick-translate/internal/config"
)

func main() {
    // Cargar configuración
    cfg, err := config.LoadConfig()
    if err != nil {
        log.Fatal("Error loading config:", err)
    }

    // Inicializar servicios
    translateSvc := translate.NewTranslateService(getAWSClient(cfg))
    window := ui.NewWindow()
    
    // Configurar monitor de portapapeles
    monitor := clipboard.NewMonitor(100*time.Millisecond, func(text string) {
        // Guardar último texto seleccionado
        lastSelected = text
    })

    // Configurar hotkeys
    hotkeyListener := hotkeys.NewHotkeyListener()
    hotkeyListener.Register(hotkeys.KeyCombination{
        Modifiers: []string{"ctrl"},
        Key:       "t",
    }, func() {
        if lastSelected == "" {
            return
        }

        // Traducir texto
        translated, err := translateSvc.TranslateText(
            context.Background(),
            lastSelected,
            cfg.DefaultTargetLang,
        )
        if err != nil {
            log.Printf("Error translating text: %v", err)
            return
        }

        // Mostrar ventana
        window.ShowTranslation(lastSelected, translated)
    })

    // Iniciar servicios
    if err := monitor.Start(); err != nil {
        log.Fatal("Error starting clipboard monitor:", err)
    }

    if err := hotkeyListener.Start(); err != nil {
        log.Fatal("Error starting hotkey listener:", err)
    }

    // Mantener aplicación corriendo
    select {}
}

Continuaré con el desarrollo detallado del ejercicio.

Etapa 6: Tests y Documentación (continuación)

6.2 Integration Tests

go
// tests/integration/translation_test.go
package integration

import (
    "context"
    "testing"
    "time"
    
    "github.com/yourusername/quick-translate/internal/translate"
    "github.com/yourusername/quick-translate/internal/config"
    "github.com/stretchr/testify/require"
)

func TestEndToEndTranslation(t *testing.T) {
    if testing.Short() {
        t.Skip("Skipping integration tests")
    }

    cfg, err := config.LoadConfig()
    require.NoError(t, err)

    svc := translate.NewTranslateService(getAWSClient(cfg))
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    testCases := []struct {
        name       string
        input      string
        targetLang string
        validate   func(*testing.T, string)
    }{
        {
            name:       "traducción al español",
            input:      "Hello, how are you?",
            targetLang: "es",
            validate: func(t *testing.T, result string) {
                require.Contains(t, result, "¿")
                require.Contains(t, result, "?")
            },
        },
        {
            name:       "texto largo",
            input:      getLongText(),
            targetLang: "es",
            validate: func(t *testing.T, result string) {
                require.Greater(t, len(result), 100)
            },
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            result, err := svc.TranslateText(ctx, tc.input, tc.targetLang)
            require.NoError(t, err)
            tc.validate(t, result)
        })
    }
}

6.3 Performance Tests

go
// tests/performance/benchmark_test.go
package performance

import (
    "context"
    "testing"
    "time"
)

func BenchmarkTranslation(b *testing.B) {
    cfg, _ := config.LoadConfig()
    svc := translate.NewTranslateService(getAWSClient(cfg))
    ctx := context.Background()

    texts := []string{
        "Short text",
        "Medium length text for translation",
        getLongText(),
    }

    for _, text := range texts {
        b.Run(fmt.Sprintf("len_%d", len(text)), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                _, err := svc.TranslateText(ctx, text, "es")
                if err != nil {
                    b.Fatal(err)
                }
            }
        })
    }
}

func BenchmarkCacheEfficiency(b *testing.B) {
    cfg, _ := config.LoadConfig()
    svc := translate.NewTranslateService(getAWSClient(cfg))
    ctx := context.Background()
    text := "This text will be cached"

    // Primera traducción para cachear
    _, _ = svc.TranslateText(ctx, text, "es")

    b.ResetTimer()
    
    b.Run("cached_translation", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _, err := svc.TranslateText(ctx, text, "es")
            if err != nil {
                b.Fatal(err)
            }
        }
    })
}

Etapa 7: Mejoras y Optimizaciones

7.1 System Tray Integration

go
// internal/ui/tray.go
package ui

import (
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/driver/desktop"
)

type TrayManager struct {
    app        fyne.App
    systray    desktop.SystemTrayIcon
    onSettings func()
    onExit     func()
}

func NewTrayManager(app fyne.App, onSettings, onExit func()) *TrayManager {
    if desk, ok := app.(desktop.App); ok {
        return &TrayManager{
            app:        app,
            systray:    desk.NewSystemTrayIcon(),
            onSettings: onSettings,
            onExit:     onExit,
        }
    }
    return nil
}

func (t *TrayManager) Setup() {
    if t.systray == nil {
        return
    }

    menu := fyne.NewMenu("QuickTranslate",
        fyne.NewMenuItem("Configuración", t.onSettings),
        fyne.NewMenuItemSeparator(),
        fyne.NewMenuItem("Salir", t.onExit),
    )

    t.systray.SetMenu(menu)
    t.systray.SetIcon(resourceIconPng)
}

7.2 Settings Dialog

go
// internal/ui/settings.go
package ui

import (
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/container"
    "fyne.io/fyne/v2/widget"
)

type SettingsDialog struct {
    window    fyne.Window
    onSave    func(*config.Config)
    config    *config.Config
}

func NewSettingsDialog(app fyne.App, cfg *config.Config, onSave func(*config.Config)) *SettingsDialog {
    return &SettingsDialog{
        window: app.NewWindow("Configuración"),
        config: cfg,
        onSave: onSave,
    }
}

func (d *SettingsDialog) Show() {
    targetLang := widget.NewSelect([]string{
        "Español (es)",
        "Inglés (en)",
        "Francés (fr)",
        "Alemán (de)",
    }, nil)
    targetLang.SetSelected(getLanguageLabel(d.config.DefaultTargetLang))

    hotkeyEntry := widget.NewEntry()
    hotkeyEntry.SetText(d.config.HotKeys["translate"])

    form := &widget.Form{
        Items: []*widget.FormItem{
            {Text: "Idioma destino", Widget: targetLang},
            {Text: "Atajo de teclado", Widget: hotkeyEntry},
        },
        OnSubmit: func() {
            d.config.DefaultTargetLang = getLanguageCode(targetLang.Selected)
            d.config.HotKeys["translate"] = hotkeyEntry.Text
            d.onSave(d.config)
            d.window.Hide()
        },
        OnCancel: func() {
            d.window.Hide()
        },
    }

    d.window.SetContent(container.NewPadded(form))
    d.window.Resize(fyne.NewSize(300, 200))
    d.window.CenterOnScreen()
    d.window.Show()
}

7.3 Language Detection

go
// internal/translate/detector.go
package translate

import (
    "context"
    "github.com/aws/aws-sdk-go-v2/service/comprehend"
)

type LanguageDetector struct {
    client *comprehend.Client
}

func NewLanguageDetector(client *comprehend.Client) *LanguageDetector {
    return &LanguageDetector{client: client}
}

func (d *LanguageDetector) DetectLanguage(ctx context.Context, text string) (string, error) {
    input := &comprehend.DetectDominantLanguageInput{
        Text: &text,
    }

    result, err := d.client.DetectDominantLanguage(ctx, input)
    if err != nil {
        return "", err
    }

    if len(result.Languages) == 0 {
        return "en", nil // default to English
    }

    return *result.Languages[0].LanguageCode, nil
}

7.4 Error Handling y Recovery

go
// internal/errors/handler.go
package errors

import (
    "fmt"
    "runtime/debug"
)

type ErrorHandler struct {
    onError func(error)
}

func NewErrorHandler(onError func(error)) *ErrorHandler {
    return &ErrorHandler{onError: onError}
}

func (h *ErrorHandler) Handle(err error) {
    if h.onError != nil {
        h.onError(err)
    }
}

func (h *ErrorHandler) Recover() {
    if r := recover(); r != nil {
        err := fmt.Errorf("panic recovered: %v\nstack: %s", r, debug.Stack())
        h.Handle(err)
    }
}

// internal/ui/error_dialog.go
package ui

import (
    "fyne.io/fyne/v2"
    "fyne.io/fyne/v2/dialog"
)

type ErrorDialog struct {
    window fyne.Window
}

func NewErrorDialog(window fyne.Window) *ErrorDialog {
    return &ErrorDialog{window: window}
}

func (d *ErrorDialog) ShowError(err error) {
    dialog.ShowError(err, d.window)
}

Etapa 8: Documentación

8.1 Documentación de Usuario

markdown
# QuickTranslate - Manual de Usuario

## Instalación
1. Descarga el binario para tu sistema operativo
2. Ejecuta el instalador o extrae el archivo
3. Configura las credenciales de AWS (ver sección Configuración)

## Uso
1. Selecciona cualquier texto en tu pantalla
2. Presiona Ctrl+T (o el atajo configurado)
3. Aparecerá una ventana con la traducción

## Configuración
### AWS Credentials
```bash
aws configure
# Ingresa tus credenciales cuando se te solicite

Atajos de Teclado

Los atajos se pueden configurar desde:

  1. Click derecho en el ícono de la bandeja del sistema
  2. Selecciona "Configuración"
  3. Modifica el atajo según tus preferencias

Solución de Problemas

La traducción no aparece

  • Verifica que el texto esté seleccionado
  • Comprueba que las credenciales de AWS sean correctas
  • Revisa la conexión a internet

El atajo no funciona

  • Verifica que no haya conflictos con otros atajos
  • Reinicia la aplicación
  • Reconfigura el atajo desde las opciones
### 8.2 Documentación Técnica
```markdown
# QuickTranslate - Documentación Técnica

## Arquitectura

### Componentes Principales
1. **Clipboard Monitor**
   - Monitorea el portapapeles del sistema
   - Detecta cambios en el texto seleccionado
   - Utiliza goroutines para evitar bloqueos

2. **Hotkey Listener**
   - Registra atajos de teclado globales
   - Maneja eventos del sistema
   - Configurable por usuario

3. **Translation Service**
   - Integración con AWS Translate
   - Sistema de caché para optimizar rendimiento
   - Detección automática de idioma

4. **UI Manager**
   - Ventanas emergentes con Fyne
   - Integración con bandeja del sistema
   - Diálogos de configuración

### Flujo de Datos
1. Usuario selecciona texto
2. Clipboard Monitor detecta cambio
3. Usuario activa atajo
4. Se realiza traducción
5. Se muestra resultado

## Desarrollo

### Requisitos
- Go 1.19 o superior
- AWS CLI configurado
- Credenciales AWS con permisos para Translate

### Build
```bash
make build

Tests

bash
make test
make test-integration
make bench

Release

bash
make release VERSION=v1.0.0
## Verificación Final

### 1. Funcionalidad
- [ ] System tray funcional
- [ ] Configuración persistente
- [ ] Detección de idioma
- [ ] Manejo de errores robusto

### 2. Performance
- [ ] Caché optimizada
- [ ] Detección rápida
- [ ] UI responsiva
- [ ] Uso de recursos moderado

### 3. Documentación
- [ ] Manual de usuario
- [ ] Documentación técnica
- [ ] Ejemplos de uso
- [ ] Guía de troubleshooting

## Uso Final

```bash
# Instalar
go install github.com/yourusername/quick-translate@latest

# Ejecutar
quick-translate

# Uso
1. Seleccionar texto
2. Presionar Ctrl+T
3. Ver traducción

# Configurar
1. Click derecho en ícono
2. Seleccionar "Configuración"
3. Ajustar preferencias