mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 09:27:33 +01:00
OCR en imágenes funciona.
This commit is contained in:
228
CAMBIOS.md
Normal file
228
CAMBIOS.md
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
# Integración de Bluesky en TWBlue
|
||||||
|
|
||||||
|
Este documento describe la implementación completa de soporte para Bluesky (AT Protocol) en TWBlue.
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Se ha añadido soporte completo para la red social Bluesky, siguiendo la arquitectura MVC existente de TWBlue y manteniendo paridad de características con la implementación de Mastodon.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
- **atproto**: SDK oficial de Python para AT Protocol (Bluesky)
|
||||||
|
- Las dependencias existentes de TWBlue (wxPython, arrow, configobj, etc.)
|
||||||
|
|
||||||
|
## Estructura de Archivos
|
||||||
|
|
||||||
|
### Sesión (`src/sessions/blueski/`)
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `__init__.py` | Inicialización del módulo |
|
||||||
|
| `session.py` | Clase principal de sesión Bluesky con autenticación, API y gestión de cuenta |
|
||||||
|
| `compose.py` | Funciones de composición para formatear datos de API en cadenas legibles |
|
||||||
|
| `streaming.py` | Sistema de polling para actualizaciones en tiempo real |
|
||||||
|
| `utils.py` | Utilidades auxiliares (detección de medios, etc.) |
|
||||||
|
|
||||||
|
### Controladores de Buffers (`src/controller/buffers/blueski/`)
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `__init__.py` | Registro de tipos de buffer |
|
||||||
|
| `base.py` | Buffer base con funcionalidad común (acciones, menús, eventos) |
|
||||||
|
| `timeline.py` | Buffers de línea de tiempo (Home, Following, Notifications, etc.) |
|
||||||
|
| `user.py` | Buffers de usuarios (Followers, Following, Blocks) |
|
||||||
|
| `chat.py` | Buffers de mensajes directos |
|
||||||
|
|
||||||
|
### Controladores (`src/controller/blueski/`)
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `__init__.py` | Inicialización del módulo |
|
||||||
|
| `handler.py` | Handler principal que crea buffers y gestiona acciones |
|
||||||
|
| `messages.py` | Visor de posts con detalles completos |
|
||||||
|
| `userActions.py` | Diálogo de acciones de usuario (seguir, silenciar, bloquear) |
|
||||||
|
| `userList.py` | Gestión de listas de usuarios |
|
||||||
|
| `settings.py` | Configuración de cuenta |
|
||||||
|
| `templateEditor.py` | Editor de plantillas |
|
||||||
|
|
||||||
|
### Interfaz de Usuario (`src/wxUI/`)
|
||||||
|
|
||||||
|
#### Paneles (`src/wxUI/buffers/blueski/panels.py`)
|
||||||
|
|
||||||
|
- `HomePanel`: Panel de línea de tiempo con botones Post, Repost, Reply, Like, Chat
|
||||||
|
- `NotificationPanel`: Panel de notificaciones (hereda de HomePanel)
|
||||||
|
- `UserPanel`: Panel de lista de usuarios con botones Post, Actions, Message
|
||||||
|
- `ChatPanel`: Panel de lista de conversaciones
|
||||||
|
- `ChatMessagePanel`: Panel de mensajes de chat individuales
|
||||||
|
|
||||||
|
#### Diálogos (`src/wxUI/dialogs/blueski/`)
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `postDialogs.py` | Diálogo de composición de posts y visor de posts |
|
||||||
|
| `configuration.py` | Diálogo de configuración de cuenta |
|
||||||
|
| `menus.py` | Menús contextuales |
|
||||||
|
| `userActions.py` | Diálogo de acciones de usuario |
|
||||||
|
| `showUserProfile.py` | Diálogo de perfil de usuario |
|
||||||
|
|
||||||
|
### Configuración (`src/blueski.defaults`)
|
||||||
|
|
||||||
|
Archivo de configuración por defecto con secciones:
|
||||||
|
- `[blueski]`: Credenciales (handle, app_password, did, session_string)
|
||||||
|
- `[general]`: Tiempos relativos, posts por llamada, orden de timelines
|
||||||
|
- `[sound]`: Configuración de audio
|
||||||
|
- `[other_buffers]`: Timelines adicionales, búsquedas guardadas
|
||||||
|
- `[mysc]`: Idioma de corrector ortográfico
|
||||||
|
- `[reporting]`: Reportes de braille/voz
|
||||||
|
- `[templates]`: Plantillas de visualización
|
||||||
|
|
||||||
|
## Características Implementadas
|
||||||
|
|
||||||
|
### Autenticación
|
||||||
|
- Login con handle y App Password de Bluesky
|
||||||
|
- Persistencia de sesión mediante session_string
|
||||||
|
- Migración automática de configuración legacy
|
||||||
|
|
||||||
|
### Buffers Disponibles
|
||||||
|
|
||||||
|
1. **Home (Following)**: Timeline cronológico inverso de cuentas seguidas
|
||||||
|
2. **Discover**: Feed algorítmico de descubrimiento
|
||||||
|
3. **Mentions**: Menciones, respuestas y citas
|
||||||
|
4. **Notifications**: Todas las notificaciones (likes, reposts, follows, etc.)
|
||||||
|
5. **Sent**: Posts enviados por el usuario
|
||||||
|
6. **Likes**: Posts marcados como favoritos
|
||||||
|
7. **Followers**: Lista de seguidores
|
||||||
|
8. **Following**: Lista de seguidos
|
||||||
|
9. **Blocked Users**: Usuarios bloqueados
|
||||||
|
10. **Chats**: Mensajes directos
|
||||||
|
11. **Timelines de Usuario**: Timelines personalizados por usuario
|
||||||
|
12. **Búsquedas**: Búsquedas guardadas
|
||||||
|
|
||||||
|
### Acciones de Posts
|
||||||
|
- Publicar nuevo post (con imágenes, CW, idioma)
|
||||||
|
- Responder a posts
|
||||||
|
- Repostear
|
||||||
|
- Dar like
|
||||||
|
- Eliminar posts propios
|
||||||
|
- Ver conversación/hilo completo
|
||||||
|
- Copiar enlace al portapapeles
|
||||||
|
- Ver detalles del post (likes, reposts, descripción de imágenes)
|
||||||
|
|
||||||
|
### Acciones de Usuario
|
||||||
|
- Seguir/Dejar de seguir
|
||||||
|
- Silenciar/Desilenciar
|
||||||
|
- Bloquear/Desbloquear
|
||||||
|
- Ver perfil
|
||||||
|
- Ver timeline de usuario
|
||||||
|
- Ver seguidores/seguidos de usuario
|
||||||
|
- Enviar mensaje directo
|
||||||
|
|
||||||
|
### Mensajes Directos
|
||||||
|
- Lista de conversaciones
|
||||||
|
- Ver mensajes de una conversación
|
||||||
|
- Enviar mensajes
|
||||||
|
|
||||||
|
### Sistema de Actualización
|
||||||
|
- Polling periódico para notificaciones (configurable, mínimo 30 segundos)
|
||||||
|
- Publicación de eventos via pub/sub para consistencia con Mastodon
|
||||||
|
|
||||||
|
### Accesibilidad
|
||||||
|
- Lectura automática de nuevos elementos
|
||||||
|
- Indicadores de sonido para audio/video e imágenes
|
||||||
|
- Tiempos relativos actualizados al enfocar
|
||||||
|
- Soporte completo de lector de pantalla
|
||||||
|
|
||||||
|
## Formato de Visualización
|
||||||
|
|
||||||
|
El formato de posts y notificaciones se ha igualado al de Mastodon:
|
||||||
|
|
||||||
|
### Posts
|
||||||
|
```
|
||||||
|
Usuario (@handle), Texto del post, Fecha, Bluesky
|
||||||
|
```
|
||||||
|
|
||||||
|
Para reposts:
|
||||||
|
```
|
||||||
|
Reposteador (@handle), Reposted from @original: Texto, Fecha, Bluesky
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notificaciones
|
||||||
|
```
|
||||||
|
Usuario (@handle), {usuario} ha añadido a favoritos: {texto}, Fecha
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
```
|
||||||
|
Nombre (@handle). X seguidores, Y siguiendo, Z posts. Se unió fecha
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sonidos
|
||||||
|
|
||||||
|
| Acción | Sonido |
|
||||||
|
|--------|--------|
|
||||||
|
| Nuevo post en timeline | `tweet_received.ogg` |
|
||||||
|
| Nueva notificación | `notification_received.ogg` |
|
||||||
|
| Nueva mención | `mention_received.ogg` |
|
||||||
|
| Nuevo DM | `dm_received.ogg` |
|
||||||
|
| Post enviado | `tweet_send.ogg` |
|
||||||
|
| Respuesta enviada | `reply_send.ogg` |
|
||||||
|
| Repost | `retweet_send.ogg` |
|
||||||
|
| Like | `favourite.ogg` |
|
||||||
|
| DM enviado | `dm_sent.ogg` |
|
||||||
|
| Actualización de seguidores | `update_followers.ogg` |
|
||||||
|
| Búsqueda/Conversación | `search_updated.ogg` |
|
||||||
|
|
||||||
|
## Notas Técnicas
|
||||||
|
|
||||||
|
### Diferencias con Mastodon
|
||||||
|
- Bluesky usa AT Protocol en lugar de ActivityPub
|
||||||
|
- Autenticación mediante App Password en lugar de OAuth2
|
||||||
|
- No hay "Firehose" streaming real; se usa polling
|
||||||
|
- URIs en formato `at://did:plc:xxx/app.bsky.feed.post/rkey`
|
||||||
|
- Facets para menciones y enlaces en lugar de HTML
|
||||||
|
|
||||||
|
### Manejo de Errores
|
||||||
|
- Logging consistente con `log.error()` para errores esperados
|
||||||
|
- Mensajes de error traducibles para el usuario
|
||||||
|
|
||||||
|
### Configuración
|
||||||
|
- Valores por defecto en `blueski.defaults`
|
||||||
|
- Acceso a configuración via `self.session.settings`
|
||||||
|
- Helper `get_max_items()` para obtener límite de posts
|
||||||
|
|
||||||
|
## Pruebas
|
||||||
|
|
||||||
|
Tests ubicados en `src/test/sessions/blueski/`:
|
||||||
|
- `test_blueski_session.py`: Tests de la sesión de Bluesky
|
||||||
|
|
||||||
|
## Traducciones
|
||||||
|
|
||||||
|
Todos los strings de interfaz usan la función `_()` para internacionalización:
|
||||||
|
- Títulos de buffers
|
||||||
|
- Acciones de menú
|
||||||
|
- Mensajes de confirmación
|
||||||
|
- Mensajes de error
|
||||||
|
- Formatos de notificación
|
||||||
|
|
||||||
|
## Archivos Modificados (Fuera de blueski/)
|
||||||
|
|
||||||
|
Para integrar Bluesky en TWBlue, también se modificaron:
|
||||||
|
|
||||||
|
- `src/controller/mainController.py`: Registro de handlers de sesión
|
||||||
|
- `src/sessionmanager/`: Soporte para tipo de sesión "blueski"
|
||||||
|
- `src/requirements.txt`: Dependencia atproto
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
1. En TWBlue, ir a Sesión > Nueva cuenta
|
||||||
|
2. Seleccionar "Bluesky"
|
||||||
|
3. Introducir handle (ej: `usuario.bsky.social`)
|
||||||
|
4. Introducir App Password (desde Configuración > App Passwords en bsky.app)
|
||||||
|
5. Los buffers se crearán automáticamente
|
||||||
|
|
||||||
|
## Limitaciones Conocidas
|
||||||
|
|
||||||
|
- No hay streaming en tiempo real (se usa polling)
|
||||||
|
- Las listas de Bluesky no están implementadas aún
|
||||||
|
- Los starter packs no están soportados
|
||||||
|
- La moderación de contenido es básica (solo CW)
|
||||||
@@ -33,7 +33,7 @@ class Handler:
|
|||||||
unfav="HIDE",
|
unfav="HIDE",
|
||||||
view=_("&Show post"),
|
view=_("&Show post"),
|
||||||
view_conversation=_("View conversa&tion"),
|
view_conversation=_("View conversa&tion"),
|
||||||
ocr="HIDE",
|
ocr=_("&OCR"),
|
||||||
delete=_("&Delete"),
|
delete=_("&Delete"),
|
||||||
# User menu
|
# User menu
|
||||||
follow=_("&Actions..."),
|
follow=_("&Actions..."),
|
||||||
|
|||||||
@@ -327,3 +327,17 @@ class viewPost(base_messages.basicMessage):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class text(base_messages.basicMessage):
|
||||||
|
"""Simple text viewer dialog for OCR results and similar."""
|
||||||
|
|
||||||
|
def __init__(self, title, text="", *args, **kwargs):
|
||||||
|
self.title = title
|
||||||
|
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||||
|
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||||
|
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||||
|
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||||
|
|
||||||
|
def text_processor(self):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -605,11 +605,72 @@ class BaseBuffer(base.Buffer):
|
|||||||
output.speak(_("Could not delete."), True)
|
output.speak(_("Could not delete."), True)
|
||||||
|
|
||||||
|
|
||||||
def audio(self, *args, **kwargs):
|
def audio(self, event=None, item=None, *args, **kwargs):
|
||||||
output.speak(_("Audio playback not supported for Bluesky yet."))
|
"""Play audio/video from the current post."""
|
||||||
|
if sound.URLPlayer.player.is_playing():
|
||||||
|
return sound.URLPlayer.stop_audio()
|
||||||
|
if item is None:
|
||||||
|
item = self.get_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
urls = utils.get_media_urls(item)
|
||||||
|
if not urls:
|
||||||
|
output.speak(_("This post has no playable media."), True)
|
||||||
|
return
|
||||||
|
url = ""
|
||||||
|
if len(urls) == 1:
|
||||||
|
url = urls[0]
|
||||||
|
elif len(urls) > 1:
|
||||||
|
from wxUI.dialogs import urlList
|
||||||
|
urls_list = urlList.urlList()
|
||||||
|
urls_list.populate_list(urls)
|
||||||
|
if urls_list.get_response() == widgetUtils.OK:
|
||||||
|
url = urls_list.get_string()
|
||||||
|
if hasattr(urls_list, "destroy"):
|
||||||
|
urls_list.destroy()
|
||||||
|
if url:
|
||||||
|
sound.URLPlayer.play(url, self.session.settings["sound"]["volume"])
|
||||||
|
|
||||||
# Helper to map standard keys if they don't invoke the methods above via get_event
|
def ocr_image(self, *args, **kwargs):
|
||||||
# But usually get_event is enough.
|
"""Perform OCR on images in the current post."""
|
||||||
|
post = self.get_item()
|
||||||
|
if not post:
|
||||||
|
return
|
||||||
|
|
||||||
|
image_list = utils.get_image_urls(post)
|
||||||
|
if not image_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(image_list) > 1:
|
||||||
|
from wxUI.dialogs import urlList
|
||||||
|
labels = [_("Picture {0}").format(i + 1) for i in range(len(image_list))]
|
||||||
|
dialog = urlList.urlList(title=_("Select the picture"))
|
||||||
|
dialog.populate_list(labels)
|
||||||
|
if dialog.get_response() != widgetUtils.OK:
|
||||||
|
return
|
||||||
|
img = image_list[dialog.get_item()]
|
||||||
|
else:
|
||||||
|
img = image_list[0]
|
||||||
|
|
||||||
|
url = img.get("url")
|
||||||
|
if not url:
|
||||||
|
return
|
||||||
|
|
||||||
|
from extra import ocr as ocr_module
|
||||||
|
api = ocr_module.OCRSpace.OCRSpaceAPI()
|
||||||
|
try:
|
||||||
|
text = api.OCR_URL(url)
|
||||||
|
except ocr_module.OCRSpace.APIError:
|
||||||
|
output.speak(_("Unable to extract text"), True)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
log.error("OCR error: %s", e)
|
||||||
|
output.speak(_("Unable to extract text"), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
viewer = blueski_messages.text(title=_("OCR Result"), text=text["ParsedText"])
|
||||||
|
viewer.message.ShowModal()
|
||||||
|
viewer.message.Destroy()
|
||||||
|
|
||||||
# Also implement "view_item" if standard keymap uses it
|
# Also implement "view_item" if standard keymap uses it
|
||||||
def get_formatted_message(self):
|
def get_formatted_message(self):
|
||||||
|
|||||||
@@ -215,11 +215,40 @@ class Conversation(BaseBuffer):
|
|||||||
traverse(thread)
|
traverse(thread)
|
||||||
self.session.db[self.name] = []
|
self.session.db[self.name] = []
|
||||||
self.buffer.list.clear()
|
self.buffer.list.clear()
|
||||||
return self.process_items(final_items, play_sound)
|
# Don't use process_items() because it applies reverse logic.
|
||||||
|
# Conversations should always be chronological (oldest first).
|
||||||
|
return self._add_items_chronological(final_items, play_sound)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error("Error fetching thread: %s", e)
|
log.error("Error fetching thread: %s", e)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
def _add_items_chronological(self, items, play_sound=True):
|
||||||
|
"""Add items in chronological order (oldest first) without reverse logic."""
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
safe = True
|
||||||
|
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||||
|
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
self.session.db[self.name].append(item)
|
||||||
|
post = self.compose_function(item, self.session.db, self.session.settings,
|
||||||
|
relative_times=relative_times,
|
||||||
|
show_screen_names=show_screen_names,
|
||||||
|
safe=safe)
|
||||||
|
self.buffer.list.insert_item(False, *post)
|
||||||
|
|
||||||
|
# Select the root post (first item after ancestors, or just the first)
|
||||||
|
total = self.buffer.list.get_count()
|
||||||
|
if total > 0:
|
||||||
|
self.buffer.list.select_item(0)
|
||||||
|
|
||||||
|
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||||
|
self.session.sound.play(self.sound)
|
||||||
|
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
|
||||||
class LikesBuffer(BaseBuffer):
|
class LikesBuffer(BaseBuffer):
|
||||||
"""User's liked posts."""
|
"""User's liked posts."""
|
||||||
|
|||||||
@@ -30,32 +30,86 @@ def is_audio_or_video(post):
|
|||||||
if not embed:
|
if not embed:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
etype = g(embed, "$type") or g(embed, "py_type")
|
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||||
|
|
||||||
# Check for video embed
|
# Check for video embed
|
||||||
if etype and "video" in etype.lower():
|
if "video" in etype.lower():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check for external link that might be video (YouTube, etc.)
|
# Check for external link that might be video (YouTube, etc.)
|
||||||
if etype and "external" in etype:
|
if "external" in etype.lower():
|
||||||
ext = g(embed, "external", {})
|
ext = g(embed, "external", {})
|
||||||
uri = g(ext, "uri", "")
|
uri = g(ext, "uri", "")
|
||||||
# Common video hosting sites
|
|
||||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||||
for host in video_hosts:
|
for host in video_hosts:
|
||||||
if host in uri.lower():
|
if host in uri.lower():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Check in recordWithMedia wrapper
|
# Check in recordWithMedia wrapper
|
||||||
if etype and "recordWithMedia" in etype:
|
if "recordwithmedia" in etype.lower():
|
||||||
media = g(embed, "media", {})
|
media = g(embed, "media", {})
|
||||||
mtype = g(media, "$type") or g(media, "py_type")
|
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||||
if mtype and "video" in mtype.lower():
|
if "video" in mtype.lower():
|
||||||
return True
|
return True
|
||||||
|
if "external" in mtype.lower():
|
||||||
|
ext = g(media, "external", {})
|
||||||
|
uri = g(ext, "uri", "")
|
||||||
|
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||||
|
for host in video_hosts:
|
||||||
|
if host in uri.lower():
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_images_from_embed(embed):
|
||||||
|
"""Extract image URLs from an embed object."""
|
||||||
|
images = []
|
||||||
|
if not embed:
|
||||||
|
return images
|
||||||
|
|
||||||
|
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||||
|
|
||||||
|
def extract_images(img_list):
|
||||||
|
result = []
|
||||||
|
for img in (img_list or []):
|
||||||
|
url = None
|
||||||
|
# Try all possible URL field names
|
||||||
|
for key in ["fullsize", "thumb", "url", "uri", "src"]:
|
||||||
|
val = g(img, key)
|
||||||
|
if val and isinstance(val, str) and val.startswith("http"):
|
||||||
|
url = val
|
||||||
|
break
|
||||||
|
# Also check for nested 'image' object
|
||||||
|
if not url:
|
||||||
|
image_obj = g(img, "image", {})
|
||||||
|
if image_obj:
|
||||||
|
for key in ["ref", "$link", "url", "uri"]:
|
||||||
|
val = g(image_obj, key)
|
||||||
|
if val:
|
||||||
|
url = val
|
||||||
|
break
|
||||||
|
if url:
|
||||||
|
result.append({
|
||||||
|
"url": url,
|
||||||
|
"alt": g(img, "alt", "") or ""
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view)
|
||||||
|
if "images" in etype.lower():
|
||||||
|
images.extend(extract_images(g(embed, "images", [])))
|
||||||
|
|
||||||
|
# Check in recordWithMedia wrapper
|
||||||
|
if "recordwithmedia" in etype.lower():
|
||||||
|
media = g(embed, "media", {})
|
||||||
|
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||||
|
if "images" in mtype.lower():
|
||||||
|
images.extend(extract_images(g(media, "images", [])))
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
def is_image(post):
|
def is_image(post):
|
||||||
"""
|
"""
|
||||||
Check if post contains image content.
|
Check if post contains image content.
|
||||||
@@ -68,25 +122,22 @@ def is_image(post):
|
|||||||
"""
|
"""
|
||||||
actual_post = g(post, "post", post)
|
actual_post = g(post, "post", post)
|
||||||
embed = g(actual_post, "embed", None)
|
embed = g(actual_post, "embed", None)
|
||||||
if not embed:
|
return len(_extract_images_from_embed(embed)) > 0
|
||||||
return False
|
|
||||||
|
|
||||||
etype = g(embed, "$type") or g(embed, "py_type")
|
|
||||||
|
|
||||||
# Direct images embed
|
def get_image_urls(post):
|
||||||
if etype and "images" in etype:
|
"""
|
||||||
images = g(embed, "images", [])
|
Get URLs for image attachments from post for OCR.
|
||||||
return len(images) > 0
|
|
||||||
|
|
||||||
# Check in recordWithMedia wrapper
|
Args:
|
||||||
if etype and "recordWithMedia" in etype:
|
post: Bluesky post object
|
||||||
media = g(embed, "media", {})
|
|
||||||
mtype = g(media, "$type") or g(media, "py_type")
|
|
||||||
if mtype and "images" in mtype:
|
|
||||||
images = g(media, "images", [])
|
|
||||||
return len(images) > 0
|
|
||||||
|
|
||||||
return False
|
Returns:
|
||||||
|
list: List of dicts with 'url' and 'alt' keys
|
||||||
|
"""
|
||||||
|
actual_post = g(post, "post", post)
|
||||||
|
embed = g(actual_post, "embed", None)
|
||||||
|
return _extract_images_from_embed(embed)
|
||||||
|
|
||||||
|
|
||||||
def get_media_urls(post):
|
def get_media_urls(post):
|
||||||
@@ -105,24 +156,44 @@ def get_media_urls(post):
|
|||||||
if not embed:
|
if not embed:
|
||||||
return urls
|
return urls
|
||||||
|
|
||||||
etype = g(embed, "$type") or g(embed, "py_type")
|
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||||
|
|
||||||
# Video embed
|
def extract_video_urls(video_embed):
|
||||||
if etype and "video" in etype.lower():
|
"""Extract URLs from a video embed object."""
|
||||||
playlist = g(embed, "playlist", None)
|
result = []
|
||||||
|
# Playlist URL (HLS stream)
|
||||||
|
playlist = g(video_embed, "playlist", None)
|
||||||
if playlist:
|
if playlist:
|
||||||
urls.append(playlist)
|
result.append(playlist)
|
||||||
# Alternative URL fields
|
# Alternative URL fields
|
||||||
for key in ["url", "uri", "thumb"]:
|
for key in ["url", "uri"]:
|
||||||
val = g(embed, key)
|
val = g(video_embed, key)
|
||||||
if val and val not in urls:
|
if val and val not in result:
|
||||||
urls.append(val)
|
result.append(val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Direct video embed (app.bsky.embed.video#view)
|
||||||
|
if "video" in etype.lower():
|
||||||
|
urls.extend(extract_video_urls(embed))
|
||||||
|
|
||||||
|
# Check in recordWithMedia wrapper
|
||||||
|
if "recordWithMedia" in etype or "record_with_media" in etype.lower():
|
||||||
|
media = g(embed, "media", {})
|
||||||
|
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||||
|
if "video" in mtype.lower():
|
||||||
|
urls.extend(extract_video_urls(media))
|
||||||
|
# Also check for external in media
|
||||||
|
if "external" in mtype.lower():
|
||||||
|
ext = g(media, "external", {})
|
||||||
|
uri = g(ext, "uri", "")
|
||||||
|
if uri and uri not in urls:
|
||||||
|
urls.append(uri)
|
||||||
|
|
||||||
# External links (YouTube, etc.)
|
# External links (YouTube, etc.)
|
||||||
if etype and "external" in etype:
|
if "external" in etype.lower():
|
||||||
ext = g(embed, "external", {})
|
ext = g(embed, "external", {})
|
||||||
uri = g(ext, "uri", "")
|
uri = g(ext, "uri", "")
|
||||||
if uri:
|
if uri and uri not in urls:
|
||||||
urls.append(uri)
|
urls.append(uri)
|
||||||
|
|
||||||
return urls
|
return urls
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class HomePanel(wx.Panel):
|
|||||||
# But for now, simple list is what the previous code had.
|
# But for now, simple list is what the previous code had.
|
||||||
|
|
||||||
def set_focus_function(self, func):
|
def set_focus_function(self, func):
|
||||||
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
|
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||||
|
|
||||||
def set_position(self, reverse):
|
def set_position(self, reverse):
|
||||||
if reverse:
|
if reverse:
|
||||||
@@ -93,7 +93,7 @@ class UserPanel(wx.Panel):
|
|||||||
self.SetSizer(self.sizer)
|
self.SetSizer(self.sizer)
|
||||||
|
|
||||||
def set_focus_function(self, func):
|
def set_focus_function(self, func):
|
||||||
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
|
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||||
|
|
||||||
def set_position(self, reverse):
|
def set_position(self, reverse):
|
||||||
if reverse:
|
if reverse:
|
||||||
@@ -124,7 +124,7 @@ class ChatPanel(wx.Panel):
|
|||||||
self.SetSizer(self.sizer)
|
self.SetSizer(self.sizer)
|
||||||
|
|
||||||
def set_focus_function(self, func):
|
def set_focus_function(self, func):
|
||||||
self.list.list.Bind(wx.EVT_SET_FOCUS, func)
|
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||||
|
|
||||||
def set_focus_in_list(self):
|
def set_focus_in_list(self):
|
||||||
self.list.list.SetFocus()
|
self.list.list.SetFocus()
|
||||||
|
|||||||
@@ -203,3 +203,28 @@ class viewPost(wx.Dialog):
|
|||||||
if hasattr(self, buttonName):
|
if hasattr(self, buttonName):
|
||||||
return getattr(self, buttonName).Enable()
|
return getattr(self, buttonName).Enable()
|
||||||
|
|
||||||
|
|
||||||
|
class viewText(wx.Dialog):
|
||||||
|
def __init__(self, title="", text="", *args, **kwargs):
|
||||||
|
super(viewText, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850), title=title)
|
||||||
|
panel = wx.Panel(self)
|
||||||
|
label = wx.StaticText(panel, -1, _("Text"))
|
||||||
|
self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE, size=(250, 180))
|
||||||
|
self.text.SetFocus()
|
||||||
|
textBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
textBox.Add(label, 0, wx.ALL, 5)
|
||||||
|
textBox.Add(self.text, 1, wx.EXPAND, 5)
|
||||||
|
mainBox = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
mainBox.Add(textBox, 0, wx.ALL, 5)
|
||||||
|
self.spellcheck = wx.Button(panel, -1, _("Check &spelling..."), size=wx.DefaultSize)
|
||||||
|
self.translateButton = wx.Button(panel, -1, _("&Translate..."), size=wx.DefaultSize)
|
||||||
|
cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"), size=wx.DefaultSize)
|
||||||
|
cancelButton.SetDefault()
|
||||||
|
buttonsBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
buttonsBox.Add(self.spellcheck, 0, wx.ALL, 5)
|
||||||
|
buttonsBox.Add(self.translateButton, 0, wx.ALL, 5)
|
||||||
|
buttonsBox.Add(cancelButton, 0, wx.ALL, 5)
|
||||||
|
mainBox.Add(buttonsBox, 0, wx.ALL, 5)
|
||||||
|
panel.SetSizer(mainBox)
|
||||||
|
self.SetClientSize(mainBox.CalcMin())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user