Added artifact_postgres_export.sh to automate exporting code artifacts from Open WebUI chat conversations stored in PostgreSQL. Features: - Direct PostgreSQL database access via Docker exec - Remote server support via SSH (--remote flag) - Automatic filename detection from markdown headers - Directory structure preservation (src/main.rs, src/parser/mod.rs, etc.) - Safety checks: UUID validation, empty directory check (--force override) - Colored terminal output with verbose mode - Smart code block extraction with language detection - Support for 20+ file extensions (Rust, Python, JS, YAML, etc.) - No metadata file pollution in output directory Usage: artifact_postgres_export.sh [OPTIONS] <chat_id> <output_dir> Example: artifact_postgres_export.sh --remote vps e135d74e-5b43-4b24-a651-e999f103942b ~/Projects/rust/piglet Updated README.md with comprehensive documentation including: - Full option reference - Usage examples - Feature list 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
8.3 KiB
Bash
Executable File
295 lines
8.3 KiB
Bash
Executable File
#!/bin/bash
|
||
#
|
||
# artifact_postgres_export.sh - Export code artifacts from Open WebUI PostgreSQL database
|
||
#
|
||
# Usage: artifact_postgres_export.sh [OPTIONS] <chat_id> <output_dir>
|
||
#
|
||
# Arguments:
|
||
# chat_id Chat ID from Open WebUI URL (e.g., e135d74e-5b43-4b24-a651-e999f103942b)
|
||
# output_dir Directory to save extracted code files
|
||
#
|
||
# Options:
|
||
# -h, --help Show this help message
|
||
# -H, --host HOST PostgreSQL host (default: ai_postgres via Docker)
|
||
# -u, --user USER PostgreSQL user (default: ai)
|
||
# -d, --db DATABASE PostgreSQL database (default: openwebui)
|
||
# -v, --verbose Verbose output
|
||
# --remote HOST SSH remote host for Docker access (default: vps)
|
||
# -f, --force Force export even if output directory is not empty
|
||
#
|
||
# Examples:
|
||
# artifact_postgres_export.sh e135d74e-5b43-4b24-a651-e999f103942b ~/Projects/rust/piglet
|
||
# artifact_postgres_export.sh --remote vps abc123def456 ./output
|
||
#
|
||
|
||
set -euo pipefail
|
||
|
||
# Colors
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[1;33m'
|
||
BLUE='\033[0;34m'
|
||
MAGENTA='\033[0;35m'
|
||
CYAN='\033[0;36m'
|
||
RESET='\033[0m'
|
||
|
||
# Default values
|
||
PG_CONTAINER="ai_postgres"
|
||
PG_USER="ai"
|
||
PG_DATABASE="openwebui"
|
||
REMOTE_HOST="vps"
|
||
VERBOSE=false
|
||
FORCE=false
|
||
|
||
# Functions
|
||
print_error() {
|
||
echo -e "${RED}✗ Error:${RESET} $1" >&2
|
||
}
|
||
|
||
print_success() {
|
||
echo -e "${GREEN}✓${RESET} $1"
|
||
}
|
||
|
||
print_info() {
|
||
echo -e "${BLUE}ℹ${RESET} $1"
|
||
}
|
||
|
||
print_warning() {
|
||
echo -e "${YELLOW}⚠${RESET} $1"
|
||
}
|
||
|
||
print_verbose() {
|
||
if [[ "$VERBOSE" == "true" ]]; then
|
||
echo -e "${CYAN}→${RESET} $1"
|
||
fi
|
||
}
|
||
|
||
show_help() {
|
||
sed -n '2,/^$/p' "$0" | sed 's/^# \?//' | head -n -1
|
||
exit 0
|
||
}
|
||
|
||
# Parse options
|
||
POSITIONAL_ARGS=()
|
||
while [[ $# -gt 0 ]]; do
|
||
case $1 in
|
||
-h|--help)
|
||
show_help
|
||
;;
|
||
-H|--host)
|
||
PG_CONTAINER="$2"
|
||
shift 2
|
||
;;
|
||
-u|--user)
|
||
PG_USER="$2"
|
||
shift 2
|
||
;;
|
||
-d|--db)
|
||
PG_DATABASE="$2"
|
||
shift 2
|
||
;;
|
||
-v|--verbose)
|
||
VERBOSE=true
|
||
shift
|
||
;;
|
||
-f|--force)
|
||
FORCE=true
|
||
shift
|
||
;;
|
||
--remote)
|
||
REMOTE_HOST="$2"
|
||
shift 2
|
||
;;
|
||
-*)
|
||
print_error "Unknown option: $1"
|
||
echo "Use --help for usage information"
|
||
exit 1
|
||
;;
|
||
*)
|
||
POSITIONAL_ARGS+=("$1")
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Restore positional parameters
|
||
set -- "${POSITIONAL_ARGS[@]}"
|
||
|
||
# Validate arguments
|
||
if [[ $# -lt 1 ]]; then
|
||
print_error "Missing required argument: chat_id"
|
||
echo "Usage: $(basename "$0") [OPTIONS] <chat_id> [output_dir]"
|
||
echo "Use --help for more information"
|
||
exit 1
|
||
fi
|
||
|
||
CHAT_ID="$1"
|
||
OUTPUT_DIR="${2:-.}" # Default to current directory if not specified
|
||
|
||
# Validate chat ID format (UUID)
|
||
if [[ ! "$CHAT_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]; then
|
||
print_error "Invalid chat ID format. Expected UUID format."
|
||
exit 1
|
||
fi
|
||
|
||
# Create output directory
|
||
mkdir -p "$OUTPUT_DIR"
|
||
|
||
# Check if output directory is empty (unless --force)
|
||
if [[ "$FORCE" == "false" ]]; then
|
||
if [[ -n "$(ls -A "$OUTPUT_DIR" 2>/dev/null)" ]]; then
|
||
print_error "Output directory is not empty: $OUTPUT_DIR"
|
||
echo "Use --force to export anyway, or choose an empty directory"
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
print_verbose "Output directory: $OUTPUT_DIR"
|
||
|
||
# Function to run PostgreSQL queries
|
||
run_psql() {
|
||
local query="$1"
|
||
if [[ -n "$REMOTE_HOST" ]]; then
|
||
ssh -A "$REMOTE_HOST" "docker exec $PG_CONTAINER psql -U $PG_USER -d $PG_DATABASE -t -c \"$query\""
|
||
else
|
||
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DATABASE" -t -c "$query"
|
||
fi
|
||
}
|
||
|
||
print_info "Fetching chat from database..."
|
||
|
||
# Check if chat exists
|
||
CHAT_EXISTS=$(run_psql "SELECT COUNT(*) FROM chat WHERE id = '$CHAT_ID';" | tr -d '[:space:]')
|
||
if [[ "$CHAT_EXISTS" != "1" ]]; then
|
||
print_error "Chat ID not found in database"
|
||
exit 1
|
||
fi
|
||
|
||
# Get chat title
|
||
CHAT_TITLE=$(run_psql "SELECT title FROM chat WHERE id = '$CHAT_ID';" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||
print_success "Found chat: ${MAGENTA}${CHAT_TITLE}${RESET}"
|
||
|
||
# Export chat data to temporary file
|
||
TEMP_FILE=$(mktemp)
|
||
trap 'rm -f "$TEMP_FILE"' EXIT
|
||
|
||
print_verbose "Exporting chat data to temporary file..."
|
||
run_psql "SELECT chat FROM chat WHERE id = '$CHAT_ID';" > "$TEMP_FILE"
|
||
|
||
# Parse JSON and extract code blocks using Python
|
||
print_info "Extracting code blocks..."
|
||
|
||
python3 - "$TEMP_FILE" "$OUTPUT_DIR" "$CHAT_ID" "$CHAT_TITLE" "$GREEN" "$RED" "$YELLOW" "$BLUE" "$MAGENTA" "$CYAN" "$RESET" <<'PYTHON_SCRIPT'
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# Get arguments
|
||
temp_file = sys.argv[1]
|
||
output_dir = Path(sys.argv[2])
|
||
chat_id = sys.argv[3]
|
||
chat_title = sys.argv[4]
|
||
GREEN, RED, YELLOW, BLUE, MAGENTA, CYAN, RESET = sys.argv[5:12]
|
||
|
||
# Read JSON
|
||
with open(temp_file, 'r') as f:
|
||
content = f.read().strip()
|
||
|
||
try:
|
||
chat_data = json.loads(content)
|
||
except json.JSONDecodeError as e:
|
||
print(f"{RED}✗ Error:{RESET} Failed to parse JSON: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
messages = chat_data.get('messages', [])
|
||
if isinstance(messages, dict):
|
||
messages = list(messages.values())
|
||
if not messages:
|
||
print(f"{YELLOW}⚠ Warning:{RESET} No messages found in chat")
|
||
sys.exit(0)
|
||
|
||
code_blocks_found = 0
|
||
|
||
# Extension mapping
|
||
extensions = {
|
||
'rust': '.rs', 'python': '.py', 'javascript': '.js', 'typescript': '.ts',
|
||
'bash': '.sh', 'shell': '.sh', 'sh': '.sh', 'toml': '.toml',
|
||
'yaml': '.yaml', 'yml': '.yml', 'json': '.json', 'md': '.md',
|
||
'markdown': '.md', 'c': '.c', 'cpp': '.cpp', 'java': '.java',
|
||
'go': '.go', 'html': '.html', 'css': '.css',
|
||
}
|
||
|
||
for idx, message in enumerate(messages):
|
||
role = message.get('role', 'unknown')
|
||
content = message.get('content', '')
|
||
|
||
# Split content by code blocks
|
||
parts = re.split(r'```', content)
|
||
|
||
block_idx = 0
|
||
for i in range(1, len(parts), 2):
|
||
if i >= len(parts):
|
||
break
|
||
|
||
# Get the code block
|
||
code_block = parts[i]
|
||
lines = code_block.split('\n', 1)
|
||
language = lines[0].strip().lower() if lines else 'txt'
|
||
code = lines[1] if len(lines) > 1 else code_block
|
||
|
||
# Look for filename in the text before this code block
|
||
before_block = parts[i - 1]
|
||
before_lines = before_block.strip().split('\n')
|
||
|
||
filename = None
|
||
original_filename = None
|
||
|
||
# Check last few lines before code block for filenames
|
||
for line in reversed(before_lines[-3:]):
|
||
line = line.strip()
|
||
# Pattern 1: ### `src/main.rs` or ### src/main.rs
|
||
match = re.search(r'###?\s*`?([^\s`]+\.(rs|toml|py|js|sh|md|json|yaml|yml|html|css|c|cpp|java|go))`?', line, re.IGNORECASE)
|
||
if match:
|
||
original_filename = match.group(1)
|
||
filename = original_filename # Keep original path structure
|
||
break
|
||
# Pattern 2: Look for step indicators with filenames
|
||
match = re.search(r'[:#]\s*`?([^\s`]+\.(rs|toml|py|js|sh|md|json|yaml|yml|html|css|c|cpp|java|go))`?', line, re.IGNORECASE)
|
||
if match:
|
||
original_filename = match.group(1)
|
||
filename = original_filename # Keep original path structure
|
||
break
|
||
|
||
# If no filename found, generate one
|
||
if not filename:
|
||
ext = extensions.get(language, '.txt')
|
||
filename = f"{idx:03d}-{block_idx:02d}{ext}"
|
||
|
||
# Strip leading slashes/dots to prevent absolute path issues
|
||
filename = filename.lstrip('/')
|
||
|
||
filepath = output_dir / filename
|
||
|
||
# Create parent directories if needed
|
||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Save code
|
||
with open(filepath, 'w') as f:
|
||
f.write(code.strip())
|
||
|
||
if original_filename:
|
||
print(f"{GREEN}✓{RESET} Saved: {filename} ({CYAN}{language}{RESET})")
|
||
else:
|
||
print(f"{GREEN}✓{RESET} Saved: {filename} ({CYAN}{language}{RESET})")
|
||
code_blocks_found += 1
|
||
block_idx += 1
|
||
|
||
if code_blocks_found == 0:
|
||
print(f"{YELLOW}⚠ Warning:{RESET} No code blocks found in chat")
|
||
else:
|
||
print(f"\n{GREEN}✓{RESET} Exported {MAGENTA}{code_blocks_found}{RESET} code blocks to {BLUE}{output_dir}{RESET}")
|
||
PYTHON_SCRIPT
|
||
|
||
exit 0
|