Compare commits
No commits in common. "ba855d1622e395b1f9e9ea5a7e43d0caf8ddc325" and "d71c767b06a7f87fa8896aa6dca68ce523309e8f" have entirely different histories.
ba855d1622
...
d71c767b06
|
@ -1,3 +0,0 @@
|
|||
.aider*
|
||||
.env
|
||||
*.png
|
69
README.md
69
README.md
|
@ -1,69 +1,2 @@
|
|||
# Gonamer
|
||||
# gonamer
|
||||
|
||||
Gonamer is a command-line tool written in Go that uses any openai compatible vision model to intelligently rename image files based on their content. It analyzes images and suggests descriptive, meaningful filenames that reflect what's in the image.
|
||||
|
||||
## Features
|
||||
|
||||
- Uses OpenAI's vision model to analyze image content
|
||||
- Supports JPG, JPEG, PNG, and GIF formats
|
||||
- Generates unique, descriptive filenames
|
||||
- Handles filename conflicts automatically
|
||||
- Sanitizes filenames for cross-platform compatibility
|
||||
- Configurable via YAML configuration file
|
||||
|
||||
## Installation
|
||||
|
||||
1. Ensure you have Go installed on your system
|
||||
2. Clone this repository
|
||||
3. Install dependencies:
|
||||
```go
|
||||
go get gopkg.in/yaml.v3
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a configuration file at `~/.config/gonamer.yaml` with the following structure:
|
||||
|
||||
```yaml
|
||||
apikey: "your-api-key"
|
||||
model: "gpt-4-vision-preview"
|
||||
endpoint: "https://api.openai.com/v1/chat/completions"
|
||||
temperature: 0.7
|
||||
```
|
||||
|
||||
Make sure to replace:
|
||||
- `your-api-key` with your API key
|
||||
- `endpoint` with your preferred OpenAI-compatible API endpoint (supports OpenAI, Azure OpenAI, or any compatible API provider)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
gonamer <image_filename>
|
||||
```
|
||||
|
||||
For example:
|
||||
```bash
|
||||
gonamer vacation_photo.jpg
|
||||
```
|
||||
|
||||
The tool will:
|
||||
1. Analyze the image using OpenAI's vision model
|
||||
2. Generate a descriptive filename based on the image content
|
||||
3. Sanitize the filename for compatibility
|
||||
4. Rename the file while preserving the original extension
|
||||
5. Handle any filename conflicts by adding a numeric suffix
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Invalid file extensions will be rejected
|
||||
- Network errors will be reported clearly
|
||||
- Filename conflicts are resolved automatically
|
||||
- Invalid characters in suggested filenames are sanitized
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
|
|
4
go.sum
4
go.sum
|
@ -1,4 +0,0 @@
|
|||
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=
|
375
gonamer.go
375
gonamer.go
|
@ -1,375 +0,0 @@
|
|||
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 <image_filename>")
|
||||
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++
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
# GONAMER config example
|
||||
apikey: sk-or-v1-29374ajfga973kqae9f7a45hqraeea
|
||||
# model at openrouter
|
||||
model: qwen/qwen-2-vl-72b-instruct
|
||||
# openrouter endpoint
|
||||
endpoint: https://openrouter.ai/api/v1/chat/completions
|
||||
temperature: 0.7
|
Loading…
Reference in New Issue