#!/usr/bin/env bash # ABOUTME: Smart file renaming using AI to generate descriptive filenames # ABOUTME: Simple, focused implementation that actually works set -euo pipefail VERSION="5.20.1" SCRIPT_NAME="smart-rename" CONFIG_DIR="$HOME/.config/smart-rename" CONFIG_FILE="$CONFIG_DIR/config.yaml" # Configuration variables with defaults PROMPT_TEMPLATE="" BASE_CURRENCY="EUR" OLLAMA_MODEL="qwen2.5:3b" OPENAI_MODEL="gpt-4o-mini" CLAUDE_MODEL="claude-3-haiku-20240307" MAX_CONTENT_LENGTH=5000 API_TIMEOUT=30 # Load configuration load_config() { # Create config directory if it doesn't exist if [[ ! -d "$CONFIG_DIR" ]]; then mkdir -p "$CONFIG_DIR" echo "Created config directory: $CONFIG_DIR" >&2 fi # Create default config if it doesn't exist if [[ ! -f "$CONFIG_FILE" ]]; then echo "Creating default config at: $CONFIG_FILE" >&2 cat > "$CONFIG_FILE" <<'EOF' # smart-rename configuration # Edit this file to customize behavior # Base currency for receipts/invoices currency: base: EUR # AI Provider settings api: ollama: model: qwen2.5:3b timeout: 30 openai: model: gpt-4o-mini # Uncomment and add your API key: # key: sk-... claude: model: claude-3-haiku-20240307 # Uncomment and add your API key: # key: sk-ant-... # Prompt template (customize as needed) prompt: template: | Generate a concise, descriptive filename for the following content (no extension, lowercase, use hyphens). For receipts/invoices use YYYY-MM-DD-amount.cc-description format where amount always includes exactly two decimal places using a period as decimal separator (e.g., 123.45 or 100.00). Content: {{CONTENT}} EOF fi if [[ -f "$CONFIG_FILE" ]] && command -v yq >/dev/null 2>&1; then # Load prompt template local prompt=$(yq eval '.prompt.template // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$prompt" ]] && PROMPT_TEMPLATE="$prompt" # Load currency local currency=$(yq eval '.currency.base // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$currency" ]] && BASE_CURRENCY="$currency" # Load API settings local ollama_model=$(yq eval '.api.ollama.model // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$ollama_model" ]] && OLLAMA_MODEL="$ollama_model" local openai_model=$(yq eval '.api.openai.model // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$openai_model" ]] && OPENAI_MODEL="$openai_model" local claude_model=$(yq eval '.api.claude.model // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$claude_model" ]] && CLAUDE_MODEL="$claude_model" # Load API keys if present local openai_key=$(yq eval '.api.openai.key // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$openai_key" ]] && export OPENAI_API_KEY="$openai_key" local claude_key=$(yq eval '.api.claude.key // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$claude_key" ]] && export CLAUDE_API_KEY="$claude_key" # Load processing settings local max_len=$(yq eval '.processing.max_content_length // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$max_len" ]] && MAX_CONTENT_LENGTH="$max_len" local timeout=$(yq eval '.api.ollama.timeout // ""' "$CONFIG_FILE" 2>/dev/null) [[ -n "$timeout" ]] && API_TIMEOUT="$timeout" fi # Set default prompt if not configured if [[ -z "$PROMPT_TEMPLATE" ]]; then PROMPT_TEMPLATE="Generate a concise, descriptive filename for the following content (no extension, lowercase, use hyphens). For receipts/invoices use YYYY-MM-DD-amount.cc-description format where amount always includes exactly two decimal places using a period as decimal separator (e.g., 123.45 or 100.00). Use $BASE_CURRENCY as default currency. Content: {{CONTENT}}" fi } # Simple function to process a file process_file() { local file="$1" local content="" # Check file exists if [[ ! -f "$file" ]]; then echo "Error: File not found: $file" >&2 return 1 fi # Read file content based on type local extension="${file##*.}" extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') case "$extension" in pdf) # Try to extract text from PDF if ! command -v pdftotext >/dev/null 2>&1; then echo "Error: pdftotext not found. Install poppler for PDF support:" >&2 echo " brew install poppler" >&2 return 1 fi local pdf_error pdf_error=$(pdftotext "$file" - 2>&1 >/dev/null) || true if [[ -n "$pdf_error" ]]; then echo "Error extracting text from PDF: $file" >&2 echo " $pdf_error" >&2 return 1 fi content=$(pdftotext "$file" - 2>/dev/null | head -c "$MAX_CONTENT_LENGTH" || true) ;; jpg|jpeg|png|gif|bmp|tiff|webp) # For images, we'd need vision API support echo "Image files require AI with vision support (not yet implemented)" >&2 return 1 ;; *) # Plain text files content=$(head -c "$MAX_CONTENT_LENGTH" "$file" 2>/dev/null) || { echo "Error reading file: $file" >&2 return 1 } ;; esac if [[ -z "$content" ]]; then echo "File is empty: $file" >&2 return 1 fi # Build prompt from template local prompt="$PROMPT_TEMPLATE" prompt="${prompt//\{\{CONTENT\}\}/$content}" prompt="${prompt//\{\{FILENAME\}\}/$file}" prompt="${prompt//\{\{BASE_CURRENCY\}\}/$BASE_CURRENCY}" # Try to get AI response local new_name="" # Try OpenAI first if we have a key if [[ -n "${OPENAI_API_KEY:-}" ]]; then echo "Using OpenAI ($OPENAI_MODEL)..." >&2 local json_payload=$(jq -n --arg p "$prompt" --arg m "$OPENAI_MODEL" '{ model: $m, messages: [{role: "user", content: $p}], max_tokens: 100 }') new_name=$(curl -s --max-time "$API_TIMEOUT" https://api.openai.com/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d "$json_payload" | jq -r '.choices[0].message.content // empty' | head -1) || true if [[ -n "$new_name" ]]; then echo "✓ Got response from OpenAI" >&2 else echo "✗ OpenAI failed" >&2 fi fi # Try Claude if we have a key and no response yet if [[ -z "$new_name" && -n "${CLAUDE_API_KEY:-}" ]]; then echo "Using Claude ($CLAUDE_MODEL)..." >&2 local json_payload=$(jq -n --arg p "$prompt" --arg m "$CLAUDE_MODEL" '{ model: $m, messages: [{role: "user", content: $p}], max_tokens: 100 }') new_name=$(curl -s --max-time "$API_TIMEOUT" https://api.anthropic.com/v1/messages \ -H "x-api-key: $CLAUDE_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d "$json_payload" | jq -r '.content[0].text // empty' | head -1) || true if [[ -n "$new_name" ]]; then echo "✓ Got response from Claude" >&2 else echo "✗ Claude failed" >&2 fi fi # Try Ollama as fallback if no API keys or if APIs failed if [[ -z "$new_name" ]] && command -v ollama >/dev/null 2>&1; then # Check if Ollama service is running if ! curl -s http://localhost:11434/api/version >/dev/null 2>&1; then echo "⚠ Ollama service not running. Start with: brew services start ollama" >&2 else # Check if model is available, pull if not if ! ollama list 2>/dev/null | grep -q "^${OLLAMA_MODEL}"; then echo "Pulling Ollama model '$OLLAMA_MODEL' (first run only)..." >&2 if ! ollama pull "$OLLAMA_MODEL" >&2; then echo "✗ Failed to pull Ollama model '$OLLAMA_MODEL'" >&2 fi fi echo "Trying Ollama ($OLLAMA_MODEL)..." >&2 # Create temp file for prompt to avoid shell escaping issues local temp_prompt=$(mktemp) echo "$prompt" > "$temp_prompt" # Use Ollama CLI with temp file input, clean up ANSI codes # Capture full output first to avoid SIGPIPE from early pipe closure local temp_output temp_output=$(mktemp) timeout "$API_TIMEOUT" ollama run "$OLLAMA_MODEL" < "$temp_prompt" > "$temp_output" 2>&1 || true local ollama_response ollama_response=$(sed 's/\x1b\[[0-9;]*[mGKHJ]//g' "$temp_output" | \ sed 's/\[?[0-9]*[hl]//g' | \ tr -d '\r' | \ sed '/^$/d' | \ grep -v '^\[' | \ head -1) || true rm -f "$temp_output" rm -f "$temp_prompt" if [[ -n "$ollama_response" ]] && [[ ! "$ollama_response" =~ "Error" ]]; then new_name="$ollama_response" echo "✓ Got response from Ollama" >&2 else echo "✗ Ollama failed" >&2 if [[ -n "$ollama_response" ]]; then echo " Response: $ollama_response" >&2 else echo " Note: Ollama may need restart: brew services restart ollama" >&2 fi fi fi fi if [[ -z "$new_name" ]]; then echo "Error: Could not generate filename (no AI service available)" >&2 return 1 fi # Clean the name (keep periods for decimal amounts) new_name=$(echo "$new_name" | tr -d '"' | tr ' ' '-' | tr -cd '[:alnum:].-' | tr '[:upper:]' '[:lower:]') # Normalise receipt/invoice amounts to always have two decimal places # Pattern: YYYY-MM-DD-amount-description where amount may be malformed if [[ "$new_name" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([0-9]+)-([0-9]{2})-(.+)$ ]]; then # Case: hyphen used as decimal separator (e.g., 198-75 instead of 198.75) local date_part="${BASH_REMATCH[1]}" local amount_whole="${BASH_REMATCH[2]}" local amount_decimal="${BASH_REMATCH[3]}" local rest="${BASH_REMATCH[4]}" new_name="${date_part}-${amount_whole}.${amount_decimal}-${rest}" elif [[ "$new_name" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([0-9]+)-([^0-9].*)$ ]]; then # Case: whole number without decimals (e.g., 100 instead of 100.00) local date_part="${BASH_REMATCH[1]}" local amount="${BASH_REMATCH[2]}" local rest="${BASH_REMATCH[3]}" new_name="${date_part}-${amount}.00-${rest}" elif [[ "$new_name" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([0-9]+\.[0-9])-([^0-9].*)$ ]]; then # Case: single decimal place (e.g., 100.5 instead of 100.50) local date_part="${BASH_REMATCH[1]}" local amount="${BASH_REMATCH[2]}" local rest="${BASH_REMATCH[3]}" new_name="${date_part}-${amount}0-${rest}" fi # Add extension local ext="${file##*.}" if [[ "$ext" != "$file" ]]; then new_name="${new_name}.${ext}" fi echo "Generated name: $new_name" >&2 # Check if target file exists and create backup if needed if [[ -e "$new_name" ]]; then # Find next available backup number local backup_base="${new_name}" local backup_num=0 local backup_name="${backup_base}.bak" # Check for existing backups (.bak, .1.bak, .2.bak, etc.) if [[ -e "$backup_name" ]]; then backup_num=1 while [[ -e "${backup_base}.${backup_num}.bak" ]]; do ((backup_num++)) done backup_name="${backup_base}.${backup_num}.bak" fi echo "Target file exists, creating backup: $new_name -> $backup_name" >&2 mv "$new_name" "$backup_name" || { echo "Failed to create backup: $backup_name" >&2 return 1 } fi # Confirm rename unless auto mode if [[ "$AUTO_RENAME" != "true" ]]; then read -p "Rename '$file' to '$new_name'? (y/N): " -n 1 -r echo # Add newline after single character input if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then echo "Skipped" return 0 fi fi # Rename the file mv "$file" "$new_name" && echo "Renamed: $file -> $new_name" } # Show help show_help() { cat <&2 # Build fd arguments FD_ARGS=() # Add depth control if [[ "$RECURSIVE" == "false" ]]; then FD_ARGS+=(--max-depth 1) fi # Add pattern based on mode if [[ "$USE_GLOB" == "true" ]]; then FD_ARGS+=(--glob "$PATTERN") else # Default is regex FD_ARGS+=("$PATTERN") fi FD_ARGS+=(--type f .) # Use fd to find files mapfile -t files < <(fd "${FD_ARGS[@]}" 2>/dev/null || true) if [[ ${#files[@]} -eq 0 ]]; then echo "No files found matching pattern: $PATTERN" >&2 continue fi echo "Found ${#files[@]} files:" >&2 for file in "${files[@]}"; do echo " - $file" >&2 done echo "" >&2 # Process each file for file in "${files[@]}"; do process_file "$file" || true echo "" >&2 done fi done