commit 0277157919021df3e10d95002bd836134d988a37 Author: cdn0x12 Date: Sun Mar 30 23:20:47 2025 +0800 init diff --git a/README.md b/README.md new file mode 100644 index 0000000..3294c00 --- /dev/null +++ b/README.md @@ -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 [options] +``` + +### Options + +- `-target `: Target path for translated content (default: `_l10n[.extension]`) +- `-source-lang `: Source language code (required) +- `-target-lang `: Target language code (required) +- `-model `: Model to use for translation (overrides config) +- `-api-key `: API key (overrides config) +- `-api-base `: API base URL (overrides config) +- `-system-prompt `: System prompt (overrides config) +- `-format `: 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. diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4b5d09f --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f38e0d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/wholetrans/d10n + +go 1.24.1 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..b4a0dcb --- /dev/null +++ b/main.go @@ -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 [options]") + fmt.Println("Options:") + fmt.Println(" -target Target path for translated content") + fmt.Println(" -source-lang Source language code (required)") + fmt.Println(" -target-lang Target language code (required)") + fmt.Println(" -model Model to use for translation") + fmt.Println(" -api-key API key for OpenAI compatible service") + fmt.Println(" -api-base API base URL for OpenAI compatible service") + fmt.Println(" -system-prompt System prompt for the model") + fmt.Println(" -format 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 +} diff --git a/sample_config.yaml b/sample_config.yaml new file mode 100644 index 0000000..948681e --- /dev/null +++ b/sample_config.yaml @@ -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 diff --git a/translator/translator.go b/translator/translator.go new file mode 100644 index 0000000..948abde --- /dev/null +++ b/translator/translator.go @@ -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 +}