feat(preview-generation): implement thumbnail generation for documents; add preview upload to EspoCRM
This commit is contained in:
@@ -362,96 +362,187 @@ class DocumentSync:
|
||||
self._log(f"❌ Fehler beim Laden von Download-Info: {e}", level='error')
|
||||
return None
|
||||
|
||||
async def generate_thumbnail(self, file_path: str, mime_type: str) -> Optional[bytes]:
|
||||
async def generate_thumbnail(self, file_path: str, mime_type: str, max_width: int = 600, max_height: int = 800) -> Optional[bytes]:
|
||||
"""
|
||||
Generiert Vorschaubild (Thumbnail) für ein Document
|
||||
Generiert Vorschaubild (Preview) für ein Document im WebP-Format
|
||||
|
||||
Unterstützt:
|
||||
- PDF: Erste Seite als Bild
|
||||
- DOCX/DOC: Konvertierung zu PDF, dann erste Seite
|
||||
- Images: Resize auf Thumbnail-Größe
|
||||
- Images: Resize auf Preview-Größe
|
||||
- Andere: Platzhalter-Icon basierend auf MIME-Type
|
||||
|
||||
Args:
|
||||
file_path: Pfad zur Datei (lokal oder Download-URL)
|
||||
file_path: Pfad zur Datei (lokal)
|
||||
mime_type: MIME-Type des Documents
|
||||
max_width: Maximale Breite (default: 600px)
|
||||
max_height: Maximale Höhe (default: 800px)
|
||||
|
||||
Returns:
|
||||
Thumbnail als bytes (PNG/JPEG) oder None bei Fehler
|
||||
Preview als WebP bytes oder None bei Fehler
|
||||
"""
|
||||
self._log(f"🖼️ Thumbnail-Generierung für {mime_type}")
|
||||
self._log(f"🖼️ Preview-Generierung für {mime_type} (max: {max_width}x{max_height})")
|
||||
|
||||
# TODO: Implementierung
|
||||
#
|
||||
# Benötigte Libraries:
|
||||
# - pdf2image (für PDF → Image)
|
||||
# - python-docx + docx2pdf (für DOCX → PDF → Image)
|
||||
# - Pillow (PIL) für Image-Processing
|
||||
# - poppler-utils (System-Dependency für pdf2image)
|
||||
#
|
||||
# Implementierungs-Schritte:
|
||||
#
|
||||
# 1. PDF-Handling:
|
||||
# from pdf2image import convert_from_path
|
||||
# images = convert_from_path(file_path, first_page=1, last_page=1)
|
||||
# thumbnail = images[0].resize((200, 280))
|
||||
# return thumbnail_to_bytes(thumbnail)
|
||||
#
|
||||
# 2. DOCX-Handling:
|
||||
# - Konvertiere zu temporärem PDF
|
||||
# - Dann wie PDF behandeln
|
||||
#
|
||||
# 3. Image-Handling:
|
||||
# from PIL import Image
|
||||
# img = Image.open(file_path)
|
||||
# img.thumbnail((200, 280))
|
||||
# return image_to_bytes(img)
|
||||
#
|
||||
# 4. Fallback:
|
||||
# - Generic file-type icon basierend auf MIME-Type
|
||||
|
||||
self._log(f"⚠️ Thumbnail-Generierung noch nicht implementiert", level='warn')
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
thumbnail = None
|
||||
|
||||
# PDF-Handling
|
||||
if mime_type == 'application/pdf':
|
||||
try:
|
||||
from pdf2image import convert_from_path
|
||||
self._log(" Converting PDF page 1 to image...")
|
||||
images = convert_from_path(file_path, first_page=1, last_page=1, dpi=150)
|
||||
if images:
|
||||
thumbnail = images[0]
|
||||
except ImportError:
|
||||
self._log("⚠️ pdf2image nicht installiert - überspringe PDF-Preview", level='warn')
|
||||
return None
|
||||
except Exception as e:
|
||||
self._log(f"⚠️ PDF-Konvertierung fehlgeschlagen: {e}", level='warn')
|
||||
return None
|
||||
|
||||
# DOCX/DOC-Handling
|
||||
elif mime_type in ['application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/msword']:
|
||||
try:
|
||||
import tempfile
|
||||
import os
|
||||
from docx2pdf import convert
|
||||
from pdf2image import convert_from_path
|
||||
|
||||
self._log(" Converting DOCX → PDF → Image...")
|
||||
|
||||
# Temporäres PDF erstellen
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
||||
pdf_path = tmp.name
|
||||
|
||||
# DOCX → PDF (benötigt LibreOffice)
|
||||
convert(file_path, pdf_path)
|
||||
|
||||
# PDF → Image
|
||||
images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150)
|
||||
if images:
|
||||
thumbnail = images[0]
|
||||
|
||||
# Cleanup
|
||||
os.remove(pdf_path)
|
||||
|
||||
except ImportError:
|
||||
self._log("⚠️ docx2pdf nicht installiert - überspringe DOCX-Preview", level='warn')
|
||||
return None
|
||||
except Exception as e:
|
||||
self._log(f"⚠️ DOCX-Konvertierung fehlgeschlagen: {e}", level='warn')
|
||||
return None
|
||||
|
||||
# Image-Handling
|
||||
elif mime_type.startswith('image/'):
|
||||
try:
|
||||
self._log(" Processing image file...")
|
||||
thumbnail = Image.open(file_path)
|
||||
except Exception as e:
|
||||
self._log(f"⚠️ Image-Laden fehlgeschlagen: {e}", level='warn')
|
||||
return None
|
||||
|
||||
else:
|
||||
self._log(f"⚠️ Keine Preview-Generierung für MIME-Type: {mime_type}", level='warn')
|
||||
return None
|
||||
|
||||
if not thumbnail:
|
||||
return None
|
||||
|
||||
# Resize auf max dimensions (behält Aspect Ratio)
|
||||
thumbnail.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert zu WebP bytes
|
||||
buffer = io.BytesIO()
|
||||
thumbnail.save(buffer, format='WEBP', quality=85)
|
||||
webp_bytes = buffer.getvalue()
|
||||
|
||||
self._log(f"✅ Preview generiert: {len(webp_bytes)} bytes WebP")
|
||||
return webp_bytes
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ Fehler bei Preview-Generierung: {e}", level='error')
|
||||
import traceback
|
||||
self._log(traceback.format_exc(), level='debug')
|
||||
return None
|
||||
|
||||
async def update_sync_metadata(
|
||||
self,
|
||||
document_id: str,
|
||||
xai_file_id: str,
|
||||
collection_ids: List[str],
|
||||
xai_file_id: Optional[str] = None,
|
||||
collection_ids: Optional[List[str]] = None,
|
||||
file_hash: Optional[str] = None,
|
||||
thumbnail_data: Optional[bytes] = None
|
||||
preview_data: Optional[bytes] = None
|
||||
) -> None:
|
||||
"""
|
||||
Updated Document-Metadaten nach erfolgreichem xAI-Sync
|
||||
|
||||
Args:
|
||||
document_id: EspoCRM Document ID
|
||||
xai_file_id: xAI File ID
|
||||
collection_ids: Liste der xAI Collection IDs
|
||||
xai_file_id: xAI File ID (optional - setzt nur wenn vorhanden)
|
||||
collection_ids: Liste der xAI Collection IDs (optional)
|
||||
file_hash: MD5/SHA Hash des gesyncten Files
|
||||
thumbnail_data: Vorschaubild als bytes
|
||||
preview_data: Vorschaubild (WebP) als bytes
|
||||
"""
|
||||
try:
|
||||
update_data = {
|
||||
'xaiFileId': xai_file_id,
|
||||
'xaiCollections': collection_ids,
|
||||
'dateiStatus': 'Gesynct', # Status zurücksetzen
|
||||
}
|
||||
update_data = {}
|
||||
|
||||
# Nur xAI-Felder updaten wenn vorhanden
|
||||
if xai_file_id:
|
||||
update_data['xaiFileId'] = xai_file_id
|
||||
|
||||
if collection_ids is not None:
|
||||
update_data['xaiCollections'] = collection_ids
|
||||
|
||||
# Nur Status auf "Gesynct" setzen wenn xAI-File-ID vorhanden
|
||||
if xai_file_id:
|
||||
update_data['dateiStatus'] = 'Gesynct'
|
||||
|
||||
# Hash speichern für zukünftige Change Detection
|
||||
if file_hash:
|
||||
update_data['xaiSyncedHash'] = file_hash
|
||||
|
||||
# Thumbnail als Attachment hochladen (falls vorhanden)
|
||||
if thumbnail_data:
|
||||
# TODO: Implementiere Thumbnail-Upload zu EspoCRM
|
||||
# EspoCRM unterstützt Preview-Images für Documents
|
||||
# Muss als separates Attachment hochgeladen werden
|
||||
self._log(f"⚠️ Thumbnail-Upload noch nicht implementiert", level='warn')
|
||||
# Preview als Attachment hochladen (falls vorhanden)
|
||||
if preview_data:
|
||||
await self._upload_preview_to_espocrm(document_id, preview_data)
|
||||
|
||||
await self.espocrm.update_entity('Document', document_id, update_data)
|
||||
self._log(f"✅ Sync-Metadaten aktualisiert für Document {document_id}")
|
||||
# Nur updaten wenn es etwas zu updaten gibt
|
||||
if update_data:
|
||||
await self.espocrm.update_entity('Document', document_id, update_data)
|
||||
self._log(f"✅ Sync-Metadaten aktualisiert für Document {document_id}: {list(update_data.keys())}")
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ Fehler beim Update von Sync-Metadaten: {e}", level='error')
|
||||
raise
|
||||
|
||||
async def _upload_preview_to_espocrm(self, document_id: str, preview_data: bytes) -> None:
|
||||
"""
|
||||
Lädt Preview-Image als Attachment zu EspoCRM hoch
|
||||
|
||||
Args:
|
||||
document_id: Document ID
|
||||
preview_data: WebP Preview als bytes
|
||||
"""
|
||||
try:
|
||||
self._log(f"📤 Uploading preview image ({len(preview_data)} bytes)...")
|
||||
|
||||
# Upload via EspoCRM Attachment API
|
||||
await self.espocrm.upload_attachment(
|
||||
file_content=preview_data,
|
||||
filename='preview.webp',
|
||||
parent_type='Document',
|
||||
parent_id=document_id,
|
||||
field='preview',
|
||||
mime_type='image/webp',
|
||||
role='Attachment'
|
||||
)
|
||||
|
||||
self._log(f"✅ Preview erfolgreich hochgeladen")
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"❌ Fehler beim Preview-Upload: {e}", level='error')
|
||||
# Don't raise - Preview ist optional, Sync sollte trotzdem erfolgreich sein
|
||||
|
||||
@@ -298,3 +298,117 @@ class EspoCRMAPI:
|
||||
|
||||
result = await self.list_entities(entity_type, where=where)
|
||||
return result.get('list', [])
|
||||
|
||||
async def upload_attachment(
|
||||
self,
|
||||
file_content: bytes,
|
||||
filename: str,
|
||||
parent_type: str,
|
||||
parent_id: str,
|
||||
field: str,
|
||||
mime_type: str = 'application/octet-stream',
|
||||
role: str = 'Attachment'
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Upload an attachment to EspoCRM.
|
||||
|
||||
Args:
|
||||
file_content: File content as bytes
|
||||
filename: Name of the file
|
||||
parent_type: Parent entity type (e.g., 'Document')
|
||||
parent_id: Parent entity ID
|
||||
field: Field name for the attachment (e.g., 'preview')
|
||||
mime_type: MIME type of the file
|
||||
role: Attachment role (default: 'Attachment')
|
||||
|
||||
Returns:
|
||||
Attachment entity data
|
||||
"""
|
||||
self._log(f"Uploading attachment: {filename} ({len(file_content)} bytes) to {parent_type}/{parent_id}/{field}")
|
||||
|
||||
url = self.api_base_url.rstrip('/') + '/Attachment'
|
||||
headers = {
|
||||
'X-Api-Key': self.api_key,
|
||||
# Content-Type wird automatisch von aiohttp gesetzt für FormData
|
||||
}
|
||||
|
||||
# Erstelle FormData
|
||||
form_data = aiohttp.FormData()
|
||||
form_data.add_field('file', file_content, filename=filename, content_type=mime_type)
|
||||
form_data.add_field('parentType', parent_type)
|
||||
form_data.add_field('parentId', parent_id)
|
||||
form_data.add_field('field', field)
|
||||
form_data.add_field('role', role)
|
||||
form_data.add_field('name', filename)
|
||||
|
||||
effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
|
||||
try:
|
||||
async with session.post(url, headers=headers, data=form_data) as response:
|
||||
self._log(f"Upload response status: {response.status}", level='debug')
|
||||
|
||||
if response.status == 401:
|
||||
raise EspoCRMAuthError("Authentication failed - check API key")
|
||||
elif response.status == 403:
|
||||
raise EspoCRMError("Access forbidden")
|
||||
elif response.status == 404:
|
||||
raise EspoCRMError(f"Attachment endpoint not found")
|
||||
elif response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise EspoCRMError(f"Upload error {response.status}: {error_text}")
|
||||
|
||||
# Parse response
|
||||
if response.content_type == 'application/json':
|
||||
result = await response.json()
|
||||
attachment_id = result.get('id')
|
||||
self._log(f"✅ Attachment uploaded successfully: {attachment_id}")
|
||||
return result
|
||||
else:
|
||||
response_text = await response.text()
|
||||
self._log(f"⚠️ Non-JSON response: {response_text[:200]}", level='warn')
|
||||
return {'success': True, 'response': response_text}
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
self._log(f"Upload failed: {e}", level='error')
|
||||
raise EspoCRMError(f"Upload request failed: {e}") from e
|
||||
|
||||
async def download_attachment(self, attachment_id: str) -> bytes:
|
||||
"""
|
||||
Download an attachment from EspoCRM.
|
||||
|
||||
Args:
|
||||
attachment_id: Attachment ID
|
||||
|
||||
Returns:
|
||||
File content as bytes
|
||||
"""
|
||||
self._log(f"Downloading attachment: {attachment_id}")
|
||||
|
||||
url = self.api_base_url.rstrip('/') + f'/Attachment/file/{attachment_id}'
|
||||
headers = {
|
||||
'X-Api-Key': self.api_key,
|
||||
}
|
||||
|
||||
effective_timeout = aiohttp.ClientTimeout(total=self.api_timeout_seconds)
|
||||
|
||||
async with aiohttp.ClientSession(timeout=effective_timeout) as session:
|
||||
try:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status == 401:
|
||||
raise EspoCRMAuthError("Authentication failed - check API key")
|
||||
elif response.status == 403:
|
||||
raise EspoCRMError("Access forbidden")
|
||||
elif response.status == 404:
|
||||
raise EspoCRMError(f"Attachment not found: {attachment_id}")
|
||||
elif response.status >= 400:
|
||||
error_text = await response.text()
|
||||
raise EspoCRMError(f"Download error {response.status}: {error_text}")
|
||||
|
||||
content = await response.read()
|
||||
self._log(f"✅ Downloaded {len(content)} bytes")
|
||||
return content
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
self._log(f"Download failed: {e}", level='error')
|
||||
raise EspoCRMError(f"Download request failed: {e}") from e
|
||||
|
||||
Reference in New Issue
Block a user