AI Image Studio, AI podcast Maker, AI product Marketing
This commit is contained in:
@@ -51,7 +51,7 @@ def save_asset_to_library(
|
||||
description: Optional[str] = None,
|
||||
prompt: Optional[str] = None,
|
||||
tags: Optional[list] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
asset_metadata: Optional[Dict[str, Any]] = None,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
cost: Optional[float] = None,
|
||||
@@ -77,7 +77,7 @@ def save_asset_to_library(
|
||||
description: Asset description (optional)
|
||||
prompt: Generation prompt (optional)
|
||||
tags: List of tags (optional)
|
||||
metadata: Additional metadata (optional)
|
||||
asset_metadata: Additional metadata (optional)
|
||||
provider: AI provider used (optional)
|
||||
model: Model used (optional)
|
||||
cost: Generation cost (optional)
|
||||
@@ -143,7 +143,7 @@ def save_asset_to_library(
|
||||
description=description,
|
||||
prompt=prompt,
|
||||
tags=tags or [],
|
||||
metadata=metadata or {},
|
||||
asset_metadata=asset_metadata or {},
|
||||
provider=provider,
|
||||
model=model,
|
||||
cost=cost,
|
||||
|
||||
246
backend/utils/file_storage.py
Normal file
246
backend/utils/file_storage.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
File Storage Utility
|
||||
Robust file storage helper for saving generated content assets.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum filename length
|
||||
MAX_FILENAME_LENGTH = 255
|
||||
|
||||
# Allowed characters in filenames (alphanumeric, dash, underscore, dot)
|
||||
ALLOWED_FILENAME_CHARS = set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.')
|
||||
|
||||
|
||||
def sanitize_filename(filename: str, max_length: int = 100) -> str:
|
||||
"""
|
||||
Sanitize filename to be filesystem-safe.
|
||||
|
||||
Args:
|
||||
filename: Original filename
|
||||
max_length: Maximum length for filename
|
||||
|
||||
Returns:
|
||||
Sanitized filename
|
||||
"""
|
||||
if not filename:
|
||||
return f"file_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Remove path separators and other dangerous characters
|
||||
sanitized = "".join(c if c in ALLOWED_FILENAME_CHARS else '_' for c in filename)
|
||||
|
||||
# Remove leading/trailing dots and spaces
|
||||
sanitized = sanitized.strip('. ')
|
||||
|
||||
# Ensure it's not empty
|
||||
if not sanitized:
|
||||
sanitized = f"file_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Truncate if too long
|
||||
if len(sanitized) > max_length:
|
||||
name, ext = os.path.splitext(sanitized)
|
||||
max_name_length = max_length - len(ext) - 1
|
||||
sanitized = name[:max_name_length] + ext
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def ensure_directory_exists(directory: Path) -> bool:
|
||||
"""
|
||||
Ensure directory exists, creating it if necessary.
|
||||
|
||||
Args:
|
||||
directory: Path to directory
|
||||
|
||||
Returns:
|
||||
True if directory exists or was created, False otherwise
|
||||
"""
|
||||
try:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory {directory}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def save_file_safely(
|
||||
content: bytes,
|
||||
directory: Path,
|
||||
filename: str,
|
||||
max_file_size: int = 100 * 1024 * 1024 # 100MB default
|
||||
) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
Safely save file content to disk.
|
||||
|
||||
Args:
|
||||
content: File content as bytes
|
||||
directory: Directory to save file in
|
||||
filename: Filename (will be sanitized)
|
||||
max_file_size: Maximum allowed file size in bytes
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, error_message). file_path is None on error.
|
||||
"""
|
||||
try:
|
||||
# Validate file size
|
||||
if len(content) > max_file_size:
|
||||
return None, f"File size {len(content)} exceeds maximum {max_file_size}"
|
||||
|
||||
if len(content) == 0:
|
||||
return None, "File content is empty"
|
||||
|
||||
# Ensure directory exists
|
||||
if not ensure_directory_exists(directory):
|
||||
return None, f"Failed to create directory: {directory}"
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = sanitize_filename(filename)
|
||||
|
||||
# Construct full path
|
||||
file_path = directory / safe_filename
|
||||
|
||||
# Check if file already exists (unlikely with UUID, but check anyway)
|
||||
if file_path.exists():
|
||||
# Add UUID to make it unique
|
||||
name, ext = os.path.splitext(safe_filename)
|
||||
safe_filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
file_path = directory / safe_filename
|
||||
|
||||
# Write file atomically (write to temp file first, then rename)
|
||||
temp_path = file_path.with_suffix(file_path.suffix + '.tmp')
|
||||
try:
|
||||
with open(temp_path, 'wb') as f:
|
||||
f.write(content)
|
||||
|
||||
# Atomic rename
|
||||
temp_path.replace(file_path)
|
||||
|
||||
logger.info(f"Successfully saved file: {file_path} ({len(content)} bytes)")
|
||||
return file_path, None
|
||||
|
||||
except Exception as write_error:
|
||||
# Clean up temp file if it exists
|
||||
if temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except:
|
||||
pass
|
||||
raise write_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving file: {e}", exc_info=True)
|
||||
return None, str(e)
|
||||
|
||||
|
||||
def generate_unique_filename(
|
||||
prefix: str,
|
||||
extension: str = ".png",
|
||||
include_uuid: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Generate a unique filename.
|
||||
|
||||
Args:
|
||||
prefix: Filename prefix
|
||||
extension: File extension (with or without dot)
|
||||
include_uuid: Whether to include UUID in filename
|
||||
|
||||
Returns:
|
||||
Unique filename
|
||||
"""
|
||||
if not extension.startswith('.'):
|
||||
extension = '.' + extension
|
||||
|
||||
prefix = sanitize_filename(prefix, max_length=50)
|
||||
|
||||
if include_uuid:
|
||||
unique_id = uuid.uuid4().hex[:8]
|
||||
return f"{prefix}_{unique_id}{extension}"
|
||||
else:
|
||||
return f"{prefix}{extension}"
|
||||
|
||||
|
||||
def save_text_file_safely(
|
||||
content: str,
|
||||
directory: Path,
|
||||
filename: str,
|
||||
encoding: str = 'utf-8',
|
||||
max_file_size: int = 10 * 1024 * 1024 # 10MB default for text
|
||||
) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""
|
||||
Safely save text content to disk.
|
||||
|
||||
Args:
|
||||
content: Text content as string
|
||||
directory: Directory to save file in
|
||||
filename: Filename (will be sanitized)
|
||||
encoding: Text encoding (default: utf-8)
|
||||
max_file_size: Maximum allowed file size in bytes
|
||||
|
||||
Returns:
|
||||
Tuple of (file_path, error_message). file_path is None on error.
|
||||
"""
|
||||
try:
|
||||
# Validate content
|
||||
if not content or not isinstance(content, str):
|
||||
return None, "Content must be a non-empty string"
|
||||
|
||||
# Convert to bytes for size check
|
||||
content_bytes = content.encode(encoding)
|
||||
|
||||
# Validate file size
|
||||
if len(content_bytes) > max_file_size:
|
||||
return None, f"File size {len(content_bytes)} exceeds maximum {max_file_size}"
|
||||
|
||||
# Ensure directory exists
|
||||
if not ensure_directory_exists(directory):
|
||||
return None, f"Failed to create directory: {directory}"
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = sanitize_filename(filename)
|
||||
|
||||
# Ensure .txt extension if not present
|
||||
if not safe_filename.endswith(('.txt', '.md', '.json')):
|
||||
safe_filename = os.path.splitext(safe_filename)[0] + '.txt'
|
||||
|
||||
# Construct full path
|
||||
file_path = directory / safe_filename
|
||||
|
||||
# Check if file already exists
|
||||
if file_path.exists():
|
||||
# Add UUID to make it unique
|
||||
name, ext = os.path.splitext(safe_filename)
|
||||
safe_filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
|
||||
file_path = directory / safe_filename
|
||||
|
||||
# Write file atomically (write to temp file first, then rename)
|
||||
temp_path = file_path.with_suffix(file_path.suffix + '.tmp')
|
||||
try:
|
||||
with open(temp_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
|
||||
# Atomic rename
|
||||
temp_path.replace(file_path)
|
||||
|
||||
logger.info(f"Successfully saved text file: {file_path} ({len(content_bytes)} bytes, {len(content)} chars)")
|
||||
return file_path, None
|
||||
|
||||
except Exception as write_error:
|
||||
# Clean up temp file if it exists
|
||||
if temp_path.exists():
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except:
|
||||
pass
|
||||
raise write_error
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving text file: {e}", exc_info=True)
|
||||
return None, str(e)
|
||||
|
||||
133
backend/utils/text_asset_tracker.py
Normal file
133
backend/utils/text_asset_tracker.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Text Asset Tracker Utility
|
||||
Helper utility for saving and tracking text content as files in the asset library.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from sqlalchemy.orm import Session
|
||||
from utils.asset_tracker import save_asset_to_library
|
||||
from utils.file_storage import save_text_file_safely, generate_unique_filename, sanitize_filename
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def save_and_track_text_content(
|
||||
db: Session,
|
||||
user_id: str,
|
||||
content: str,
|
||||
source_module: str,
|
||||
title: str,
|
||||
description: Optional[str] = None,
|
||||
prompt: Optional[str] = None,
|
||||
tags: Optional[list] = None,
|
||||
asset_metadata: Optional[Dict[str, Any]] = None,
|
||||
base_dir: Optional[Path] = None,
|
||||
subdirectory: Optional[str] = None,
|
||||
file_extension: str = ".txt"
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Save text content to disk and track it in the asset library.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: Clerk user ID
|
||||
content: Text content to save
|
||||
source_module: Source module name (e.g., "linkedin_writer", "facebook_writer")
|
||||
title: Title for the asset
|
||||
description: Description of the content
|
||||
prompt: Original prompt used for generation
|
||||
tags: List of tags for search/filtering
|
||||
asset_metadata: Additional metadata
|
||||
base_dir: Base directory for file storage (defaults to backend/{module}_text)
|
||||
subdirectory: Optional subdirectory (e.g., "posts", "articles")
|
||||
file_extension: File extension (.txt, .md, etc.)
|
||||
|
||||
Returns:
|
||||
Asset ID if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
if not content or not isinstance(content, str) or len(content.strip()) == 0:
|
||||
logger.warning("Empty or invalid content provided")
|
||||
return None
|
||||
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
logger.error("Invalid user_id provided")
|
||||
return None
|
||||
|
||||
# Determine output directory
|
||||
if base_dir is None:
|
||||
# Default to backend/{module}_text
|
||||
base_dir = Path(__file__).parent.parent
|
||||
module_name = source_module.replace('_', '')
|
||||
output_dir = base_dir / f"{module_name}_text"
|
||||
else:
|
||||
output_dir = base_dir
|
||||
|
||||
# Add subdirectory if specified
|
||||
if subdirectory:
|
||||
output_dir = output_dir / subdirectory
|
||||
|
||||
# Generate safe filename from title
|
||||
safe_title = sanitize_filename(title, max_length=80)
|
||||
filename = generate_unique_filename(
|
||||
prefix=safe_title,
|
||||
extension=file_extension,
|
||||
include_uuid=True
|
||||
)
|
||||
|
||||
# Save text file
|
||||
file_path, save_error = save_text_file_safely(
|
||||
content=content,
|
||||
directory=output_dir,
|
||||
filename=filename,
|
||||
encoding='utf-8',
|
||||
max_file_size=10 * 1024 * 1024 # 10MB for text
|
||||
)
|
||||
|
||||
if not file_path or save_error:
|
||||
logger.error(f"Failed to save text file: {save_error}")
|
||||
return None
|
||||
|
||||
# Generate file URL
|
||||
relative_path = file_path.relative_to(base_dir)
|
||||
file_url = f"/api/text-assets/{relative_path.as_posix()}"
|
||||
|
||||
# Prepare metadata
|
||||
final_metadata = asset_metadata or {}
|
||||
final_metadata.update({
|
||||
"status": "completed",
|
||||
"character_count": len(content),
|
||||
"word_count": len(content.split())
|
||||
})
|
||||
|
||||
# Save to asset library
|
||||
asset_id = save_asset_to_library(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
asset_type="text",
|
||||
source_module=source_module,
|
||||
filename=filename,
|
||||
file_url=file_url,
|
||||
file_path=str(file_path),
|
||||
file_size=len(content.encode('utf-8')),
|
||||
mime_type="text/plain" if file_extension == ".txt" else "text/markdown",
|
||||
title=title,
|
||||
description=description or f"Generated {source_module.replace('_', ' ')} content",
|
||||
prompt=prompt,
|
||||
tags=tags or [source_module, "text"],
|
||||
asset_metadata=final_metadata
|
||||
)
|
||||
|
||||
if asset_id:
|
||||
logger.info(f"✅ Text asset saved to library: ID={asset_id}, filename={filename}")
|
||||
else:
|
||||
logger.warning(f"Asset tracking returned None for {filename}")
|
||||
|
||||
return asset_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error saving and tracking text content: {str(e)}", exc_info=True)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user