diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a88cbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.aider* +.env +*.png diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26afade --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gonamer + +go 1.23.4 + +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/gonamer.go b/gonamer.go new file mode 100644 index 0000000..399b99c --- /dev/null +++ b/gonamer.go @@ -0,0 +1,375 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "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() { + // Get the config file path + configFilePath, err := getConfigFilePath() + 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(os.Args) < 2 { + fmt.Println("Usage: go run main.go ") + return + } + imageFilename := os.Args[1] + + // 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() (string, error) { + 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++ + } +}