package main import ( "bytes" "encoding/base64" "encoding/json" "flag" "fmt" "io" "io/ioutil" "log" "mime/multipart" "net/http" "os" "path/filepath" "regexp" "strings" "unicode" "gopkg.in/yaml.v3" ) // ImageExtensionRegex is a regular expression to validate image file extensions. var ImageExtensionRegex = regexp.MustCompile(`(?i)\.(jpg|jpeg|png|gif)$`) // OpenAIRequest represents the structure of the request to the OpenAI API. type OpenAIRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` Temperature float64 `json:"temperature,omitempty"` } // Message represents a message in the OpenAI API request. type Message struct { Role string `json:"role"` Content []Content `json:"content"` } type Content struct { Type string `json:"type"` Text string `json:"text,omitempty"` ImageURL *ImageURL `json:"image_url,omitempty"` } type ImageURL struct { URL string `json:"url"` } // OpenAIResponse represents the structure of the response from the OpenAI API. type OpenAIResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } // Config represents the structure of the configuration file. type Config struct { APIKey string `yaml:"apikey"` Model string `yaml:"model"` Endpoint string `yaml:"endpoint"` Temperature float64 `yaml:"temperature"` } func main() { var configFile string flag.StringVar(&configFile, "c", "", "Path to config file") flag.Parse() // Get the config file path configFilePath, err := getConfigFilePath(configFile) if err != nil { log.Fatalf("Error getting config file path: %v", err) } // Load the configuration from the file config, err := loadConfig(configFilePath) if err != nil { log.Fatalf("Error loading config: %v", err) } // Get the image filename from the command-line arguments if len(flag.Args()) < 1 { fmt.Println("Usage: gonamer [-c config.yaml] ") flag.PrintDefaults() return } imageFilename := flag.Arg(0) // Validate the image filename extension if !ImageExtensionRegex.MatchString(imageFilename) { fmt.Println("Error: Invalid image file extension. Only .jpg, .jpeg, .png, and .gif are supported.") return } // Get the suggested filename from the LLM newFilename, err := getSuggestedFilename(imageFilename, config) if err != nil { fmt.Println("Error getting suggested filename:", err) return } // Rename the file err = renameFile(imageFilename, newFilename) if err != nil { fmt.Println("Error renaming file:", err) return } fmt.Printf("Successfully renamed '%s' to '%s'\n", imageFilename, newFilename) } // getConfigFilePath returns the path to the config file. func getConfigFilePath(configFile string) (string, error) { if configFile != "" { return configFile, nil } homeDir, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(homeDir, ".config", "gonamer.yaml"), nil } // loadConfig loads the configuration from the specified file. func loadConfig(configFilePath string) (*Config, error) { data, err := ioutil.ReadFile(configFilePath) if err != nil { return nil, err } var config Config err = yaml.Unmarshal(data, &config) if err != nil { return nil, err } return &config, nil } // getSuggestedFilename sends the image to the LLM and returns the suggested filename. func getSuggestedFilename(imageFilename string, config *Config) (string, error) { // 1. Encode the image to base64 imageData, err := os.ReadFile(imageFilename) if err != nil { return "", fmt.Errorf("error reading image file: %w", err) } base64Image := base64.StdEncoding.EncodeToString(imageData) // 2. Create the request payload requestData := OpenAIRequest{ Model: config.Model, Messages: []Message{ { Role: "user", Content: []Content{ { Type: "text", Text: "Suggest a useful, descriptive, and unique filename for this image, considering its content and context. Only provide the filename, no other text or explanation. Do not include the file extension.", }, { Type: "image_url", ImageURL: &ImageURL{ URL: fmt.Sprintf("data:image/%s;base64,%s", strings.TrimPrefix(filepath.Ext(imageFilename), "."), base64Image), }, }, }, }, }, Temperature: config.Temperature, } requestBody, err := json.Marshal(requestData) if err != nil { return "", fmt.Errorf("error marshalling request data: %w", err) } // 3. Send the request to the LLM (using JSON request as default) originalExtension := filepath.Ext(imageFilename) newFilename, err := sendJSONRequest(requestBody, originalExtension, config) if err != nil { return "", err } return newFilename, nil } // sendJSONRequest sends the request as JSON to the LLM endpoint func sendJSONRequest(requestBody []byte, originalExtension string, config *Config) (string, error) { req, err := http.NewRequest("POST", config.Endpoint, bytes.NewBuffer(requestBody)) if err != nil { return "", fmt.Errorf("error creating HTTP request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+config.APIKey) client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("error sending HTTP request: %w", err) } defer resp.Body.Close() // 4. Process the response responseBody, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("error reading response body: %w", err) } // Print the raw response body //fmt.Println("Raw Response Body:") //fmt.Println(string(responseBody)) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, responseBody) } var apiResponse OpenAIResponse err = json.Unmarshal(responseBody, &apiResponse) if err != nil { return "", fmt.Errorf("error unmarshalling response: %w", err) } if len(apiResponse.Choices) == 0 || apiResponse.Choices[0].Message.Content == "" { return "", fmt.Errorf("empty response from API") } suggestedFilename := strings.TrimSpace(apiResponse.Choices[0].Message.Content) // Remove any existing extension from the suggested filename suggestedFilename = strings.TrimSuffix(suggestedFilename, filepath.Ext(suggestedFilename)) // Ensure the suggested filename has the correct extension if !strings.HasSuffix(strings.ToLower(suggestedFilename), strings.ToLower(originalExtension)) { suggestedFilename += originalExtension } return suggestedFilename, nil } // sendMultipartRequest sends the request as multipart/form-data to the LLM endpoint (alternative) func sendMultipartRequest(requestBody []byte, originalExtension string, config *Config) (string, error) { // Prepare a form body := &bytes.Buffer{} writer := multipart.NewWriter(body) // Add the JSON part jsonPart, err := writer.CreateFormField("json") if err != nil { return "", fmt.Errorf("error creating form field: %w", err) } _, err = jsonPart.Write(requestBody) if err != nil { return "", fmt.Errorf("error writing to form field: %w", err) } // Close the multipart writer err = writer.Close() if err != nil { return "", fmt.Errorf("error closing multipart writer: %w", err) } // Create a new request req, err := http.NewRequest("POST", config.Endpoint, body) if err != nil { return "", fmt.Errorf("error creating HTTP request: %w", err) } // Set headers req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+config.APIKey) // Send the request client := &http.Client{} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("error sending HTTP request: %w", err) } defer resp.Body.Close() // Process the response (same as in sendJSONRequest) responseBody, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("error reading response body: %w", err) } // Print the raw response body //fmt.Println("Raw Response Body:") //fmt.Println(string(responseBody)) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, responseBody) } var apiResponse OpenAIResponse err = json.Unmarshal(responseBody, &apiResponse) if err != nil { return "", fmt.Errorf("error unmarshalling response: %w", err) } if len(apiResponse.Choices) == 0 || apiResponse.Choices[0].Message.Content == "" { return "", fmt.Errorf("empty response from API") } suggestedFilename := strings.TrimSpace(apiResponse.Choices[0].Message.Content) // Remove any existing extension from the suggested filename suggestedFilename = strings.TrimSuffix(suggestedFilename, filepath.Ext(suggestedFilename)) // Ensure the suggested filename has the correct extension if !strings.HasSuffix(strings.ToLower(suggestedFilename), strings.ToLower(originalExtension)) { suggestedFilename += originalExtension } return suggestedFilename, nil } // renameFile renames the file, handling potential issues like existing files. func renameFile(oldFilename, newFilename string) error { // Sanitize the new filename (remove invalid characters, etc.) newFilename = sanitizeFilename(newFilename) // Check if a file with the new name already exists if _, err := os.Stat(newFilename); err == nil { // If it exists, generate a unique name (e.g., by adding a number) newFilename = makeUniqueFilename(newFilename) } // Perform the rename operation return os.Rename(oldFilename, newFilename) } // sanitizeFilename removes invalid characters from a filename and ensures the first character is a letter. func sanitizeFilename(filename string) string { // Extract the extension ext := filepath.Ext(filename) filenameWithoutExt := filename[:len(filename)-len(ext)] // Define a regular expression to match invalid filename characters invalidChars := regexp.MustCompile(`[\\/:*?"<>|]`) // Replace invalid characters with underscores sanitizedFilename := invalidChars.ReplaceAllString(filenameWithoutExt, "_") // Remove non-letter characters from the beginning of the filename for len(sanitizedFilename) > 0 && !unicode.IsLetter(rune(sanitizedFilename[0])) { sanitizedFilename = sanitizedFilename[1:] } // Ensure the first character is a letter if len(sanitizedFilename) == 0 || !unicode.IsLetter(rune(sanitizedFilename[0])) { sanitizedFilename = "A_" + sanitizedFilename } // Remove leading dots and underscores for len(sanitizedFilename) > 0 && (sanitizedFilename[0] == '.' || sanitizedFilename[0] == '_') { sanitizedFilename = sanitizedFilename[1:] } // If the filename is now empty (e.g., it was only dots or underscores), set it to "A" if len(sanitizedFilename) == 0 { sanitizedFilename = "A" } return sanitizedFilename + ext } // makeUniqueFilename adds a number to the filename to make it unique. func makeUniqueFilename(filename string) string { ext := filepath.Ext(filename) name := filename[:len(filename)-len(ext)] counter := 1 newFilename := filename for { if _, err := os.Stat(newFilename); os.IsNotExist(err) { return newFilename } newFilename = fmt.Sprintf("%s_%d%s", name, counter, ext) counter++ } }