init
This commit is contained in:
commit
0277157919
7 changed files with 477 additions and 0 deletions
60
README.md
Normal file
60
README.md
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
# d10n - Content Translation CLI Tool
|
||||||
|
|
||||||
|
`d10n` is a CLI tool for translating text content in files or directories using LLM models.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a configuration file at `~/.config/d10n.yaml` with the following format:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
api_base: "https://api.openai.com" # OpenAI-compatible API base URL
|
||||||
|
api_key: "your-api-key" # API key for the service
|
||||||
|
model: "gpt-4o" # Model to use for translation
|
||||||
|
system_prompt: "Custom prompt" # Optional: Custom system prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
You can use the following variables in your system prompt:
|
||||||
|
- `$SOURCE_LANG`: Will be replaced with the source language code
|
||||||
|
- `$TARGET_LANG`: Will be replaced with the target language code
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
d10n <source_path> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
- `-target <path>`: Target path for translated content (default: `<source_path>_l10n[.extension]`)
|
||||||
|
- `-source-lang <code>`: Source language code (required)
|
||||||
|
- `-target-lang <code>`: Target language code (required)
|
||||||
|
- `-model <model>`: Model to use for translation (overrides config)
|
||||||
|
- `-api-key <key>`: API key (overrides config)
|
||||||
|
- `-api-base <url>`: API base URL (overrides config)
|
||||||
|
- `-system-prompt <prompt>`: System prompt (overrides config)
|
||||||
|
- `-format <ext>`: File format to process (e.g., `md`, `txt`)
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
Translate a single file from English to Spanish:
|
||||||
|
```
|
||||||
|
d10n document.md -target-lang es
|
||||||
|
```
|
||||||
|
|
||||||
|
Translate a directory of Markdown files to Chinese:
|
||||||
|
```
|
||||||
|
d10n ./documents -target-lang zh -format md
|
||||||
|
```
|
||||||
|
|
||||||
|
Specify source language explicitly:
|
||||||
|
```
|
||||||
|
d10n article.txt -source-lang fr -target-lang en
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building from Source
|
||||||
|
|
||||||
|
```
|
||||||
|
go build -o d10n
|
||||||
|
```
|
||||||
|
|
||||||
|
Then move the binary to a location in your PATH.
|
58
config/config.go
Normal file
58
config/config.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config stores the application configuration
|
||||||
|
type Config struct {
|
||||||
|
APIBase string `yaml:"api_base"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
SystemPrompt string `yaml:"system_prompt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default system prompt as a placeholder
|
||||||
|
const DefaultSystemPrompt = "Placeholder"
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from ~/.config/d10n.yaml
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
// Get user's home directory
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not get home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default config path
|
||||||
|
configPath := filepath.Join(homeDir, ".config", "d10n.yaml")
|
||||||
|
|
||||||
|
// Check if config file exists
|
||||||
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
|
return &Config{
|
||||||
|
SystemPrompt: DefaultSystemPrompt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read config file
|
||||||
|
data, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not read config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse config
|
||||||
|
var config Config
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse config file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default system prompt if not specified
|
||||||
|
if config.SystemPrompt == "" {
|
||||||
|
config.SystemPrompt = DefaultSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
5
go.mod
Normal file
5
go.mod
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
module github.com/wholetrans/d10n
|
||||||
|
|
||||||
|
go 1.24.1
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
4
go.sum
Normal file
4
go.sum
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
216
main.go
Normal file
216
main.go
Normal file
|
@ -0,0 +1,216 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wholetrans/d10n/config"
|
||||||
|
"github.com/wholetrans/d10n/translator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Parse command line arguments
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcePath := os.Args[1]
|
||||||
|
|
||||||
|
// Set up flags
|
||||||
|
flagSet := flag.NewFlagSet("d10n", flag.ExitOnError)
|
||||||
|
|
||||||
|
targetPathPtr := flagSet.String("target", "", "Target path for translated content")
|
||||||
|
sourceLanguagePtr := flagSet.String("source-lang", "", "Source language code")
|
||||||
|
targetLanguagePtr := flagSet.String("target-lang", "", "Target language code (required)")
|
||||||
|
modelPtr := flagSet.String("model", "", "Model to use for translation")
|
||||||
|
apiKeyPtr := flagSet.String("api-key", "", "API key for OpenAI compatible service")
|
||||||
|
apiBasePtr := flagSet.String("api-base", "", "API base URL for OpenAI compatible service")
|
||||||
|
systemPromptPtr := flagSet.String("system-prompt", "", "System prompt for the model")
|
||||||
|
formatPtr := flagSet.String("format", "", "File format to process (e.g., md, txt)")
|
||||||
|
|
||||||
|
// Parse flags
|
||||||
|
if err := flagSet.Parse(os.Args[2:]); err != nil {
|
||||||
|
fmt.Println("Error parsing arguments:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error loading configuration:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and set defaults
|
||||||
|
if *targetLanguagePtr == "" {
|
||||||
|
fmt.Println("Error: Target language is required")
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *sourceLanguagePtr == "" {
|
||||||
|
fmt.Println("Error: Source language is required")
|
||||||
|
printUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override config with command line arguments if provided
|
||||||
|
if *modelPtr != "" {
|
||||||
|
cfg.Model = *modelPtr
|
||||||
|
}
|
||||||
|
|
||||||
|
if *apiKeyPtr != "" {
|
||||||
|
cfg.APIKey = *apiKeyPtr
|
||||||
|
}
|
||||||
|
|
||||||
|
if *apiBasePtr != "" {
|
||||||
|
cfg.APIBase = *apiBasePtr
|
||||||
|
}
|
||||||
|
|
||||||
|
if *systemPromptPtr != "" {
|
||||||
|
cfg.SystemPrompt = *systemPromptPtr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set target path if not provided
|
||||||
|
targetPath := *targetPathPtr
|
||||||
|
if targetPath == "" {
|
||||||
|
ext := filepath.Ext(sourcePath)
|
||||||
|
base := strings.TrimSuffix(sourcePath, ext)
|
||||||
|
targetPath = base + "_l10n" + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create translator
|
||||||
|
trans := translator.NewTranslator(cfg)
|
||||||
|
|
||||||
|
// Process the source path
|
||||||
|
fileInfo, err := os.Stat(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error accessing source path %s: %v\n", sourcePath, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fileInfo.IsDir() {
|
||||||
|
err = processDirectory(sourcePath, targetPath, *sourceLanguagePtr, *targetLanguagePtr, *formatPtr, trans)
|
||||||
|
} else {
|
||||||
|
err = processFile(sourcePath, targetPath, *sourceLanguagePtr, *targetLanguagePtr, trans)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error during processing:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Translation completed successfully!")
|
||||||
|
}
|
||||||
|
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Println("Usage: d10n <source_path> [options]")
|
||||||
|
fmt.Println("Options:")
|
||||||
|
fmt.Println(" -target <path> Target path for translated content")
|
||||||
|
fmt.Println(" -source-lang <code> Source language code (required)")
|
||||||
|
fmt.Println(" -target-lang <code> Target language code (required)")
|
||||||
|
fmt.Println(" -model <model> Model to use for translation")
|
||||||
|
fmt.Println(" -api-key <key> API key for OpenAI compatible service")
|
||||||
|
fmt.Println(" -api-base <url> API base URL for OpenAI compatible service")
|
||||||
|
fmt.Println(" -system-prompt <prompt> System prompt for the model")
|
||||||
|
fmt.Println(" -format <ext> File format to process (e.g., md, txt)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func processFile(sourcePath, targetPath, sourceLanguage, targetLanguage string, trans *translator.Translator) error {
|
||||||
|
content, err := os.ReadFile(sourcePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading file %s: %w", sourcePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
translatedContent, err := trans.Translate(string(content), sourceLanguage, targetLanguage)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error translating file %s: %w", sourcePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create target directory if it doesn't exist
|
||||||
|
targetDir := filepath.Dir(targetPath)
|
||||||
|
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("error creating target directory %s: %w", targetDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write translated content to target path
|
||||||
|
if err := os.WriteFile(targetPath, []byte(translatedContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("error writing to file %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Translated %s to %s\n", sourcePath, targetPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDirectory(sourcePath, targetPath, sourceLanguage, targetLanguage, format string, trans *translator.Translator) error {
|
||||||
|
// Create target directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||||
|
return fmt.Errorf("error creating target directory %s: %w", targetPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First scan to find all matching files
|
||||||
|
var matchedFiles []string
|
||||||
|
err := filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file matches the format
|
||||||
|
if format != "" && !strings.HasSuffix(info.Name(), "."+format) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedFiles = append(matchedFiles, path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display all matched files
|
||||||
|
fmt.Printf("Found %d files to translate:\n", len(matchedFiles))
|
||||||
|
for _, file := range matchedFiles {
|
||||||
|
relPath, _ := filepath.Rel(sourcePath, file)
|
||||||
|
fmt.Printf("- %s\n", relPath)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Process each matched file with progress updates
|
||||||
|
for i, path := range matchedFiles {
|
||||||
|
// Compute relative path from source
|
||||||
|
relPath, err := filepath.Rel(sourcePath, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error computing relative path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute target file path
|
||||||
|
targetFilePath := filepath.Join(targetPath, relPath)
|
||||||
|
|
||||||
|
// Create target directory structure if needed
|
||||||
|
targetFileDir := filepath.Dir(targetFilePath)
|
||||||
|
if err := os.MkdirAll(targetFileDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("error creating target directory %s: %w", targetFileDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display progress
|
||||||
|
fmt.Printf("[%d/%d] Translating: %s\n", i+1, len(matchedFiles), relPath)
|
||||||
|
|
||||||
|
// Process individual file
|
||||||
|
err = processFile(path, targetFilePath, sourceLanguage, targetLanguage, trans)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
7
sample_config.yaml
Normal file
7
sample_config.yaml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Sample configuration file for d10n
|
||||||
|
# Copy this to ~/.config/d10n.yaml and update with your actual credentials
|
||||||
|
|
||||||
|
api_base: "https://api.openai.com" # OpenAI-compatible API base URL
|
||||||
|
api_key: "your-api-key-here" # API key for the service
|
||||||
|
model: "gpt-4o" # Model to use for translation
|
||||||
|
system_prompt: "You are a professional translator. You are translating from $SOURCE_LANG to $TARGET_LANG. Maintain the original formatting and structure of the text while translating it accurately." # Custom system prompt with variables
|
127
translator/translator.go
Normal file
127
translator/translator.go
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
package translator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wholetrans/d10n/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Translator handles communication with the OpenAI API
|
||||||
|
type Translator struct {
|
||||||
|
config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message represents a message in the OpenAI chat format
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatRequest represents a request to the OpenAI chat completion API
|
||||||
|
type ChatRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatResponse represents a response from the OpenAI chat completion API
|
||||||
|
type ChatResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTranslator creates a new translator
|
||||||
|
func NewTranslator(cfg *config.Config) *Translator {
|
||||||
|
return &Translator{
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Translate translates content from sourceLanguage to targetLanguage
|
||||||
|
func (t *Translator) Translate(content, sourceLanguage, targetLanguage string) (string, error) {
|
||||||
|
messages := []Message{
|
||||||
|
{
|
||||||
|
Role: "system",
|
||||||
|
Content: t.getSystemPrompt(sourceLanguage, targetLanguage),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the API request
|
||||||
|
requestBody := ChatRequest{
|
||||||
|
Model: t.config.Model,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error marshaling request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP request
|
||||||
|
req, err := http.NewRequest("POST", t.config.APIBase+"/v1/chat/completions", bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error creating HTTP request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+t.config.APIKey)
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("error sending request to API: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Parse the response
|
||||||
|
var responseBody ChatResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil {
|
||||||
|
return "", fmt.Errorf("error parsing API response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for API errors
|
||||||
|
if responseBody.Error != nil {
|
||||||
|
return "", fmt.Errorf("API error: %s", responseBody.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have any choices
|
||||||
|
if len(responseBody.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("API returned no translation")
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseBody.Choices[0].Message.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSystemPrompt constructs the system prompt for translation
|
||||||
|
func (t *Translator) getSystemPrompt(sourceLanguage, targetLanguage string) string {
|
||||||
|
basePrompt := t.config.SystemPrompt
|
||||||
|
|
||||||
|
// Replace variables in the system prompt
|
||||||
|
basePrompt = strings.ReplaceAll(basePrompt, "$SOURCE_LANG", sourceLanguage)
|
||||||
|
basePrompt = strings.ReplaceAll(basePrompt, "$TARGET_LANG", targetLanguage)
|
||||||
|
|
||||||
|
// If the source language is specified, include it in the prompt
|
||||||
|
sourceLangStr := ""
|
||||||
|
if sourceLanguage != "" {
|
||||||
|
sourceLangStr = fmt.Sprintf(" from %s", sourceLanguage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the translation instruction to the base prompt
|
||||||
|
translationInstruction := fmt.Sprintf("\nTranslate the following text%s to %s. Only output the translated text, without any explanations or additional content.", sourceLangStr, targetLanguage)
|
||||||
|
|
||||||
|
return basePrompt + translationInstruction
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue