OCR en imágenes funciona.

This commit is contained in:
Jesús Pavón Abián
2026-02-01 19:49:49 +01:00
parent c275ed9cf8
commit 8402bc6d82
8 changed files with 473 additions and 45 deletions

View File

@@ -33,7 +33,7 @@ class Handler:
unfav="HIDE",
view=_("&Show post"),
view_conversation=_("View conversa&tion"),
ocr="HIDE",
ocr=_("&OCR"),
delete=_("&Delete"),
# User menu
follow=_("&Actions..."),

View File

@@ -327,3 +327,17 @@ class viewPost(base_messages.basicMessage):
)
except Exception:
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

View File

@@ -605,11 +605,72 @@ class BaseBuffer(base.Buffer):
output.speak(_("Could not delete."), True)
def audio(self, *args, **kwargs):
output.speak(_("Audio playback not supported for Bluesky yet."))
# Helper to map standard keys if they don't invoke the methods above via get_event
# But usually get_event is enough.
def audio(self, event=None, item=None, *args, **kwargs):
"""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"])
def ocr_image(self, *args, **kwargs):
"""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
def get_formatted_message(self):

View File

@@ -215,11 +215,40 @@ class Conversation(BaseBuffer):
traverse(thread)
self.session.db[self.name] = []
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:
log.error("Error fetching thread: %s", e)
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):
"""User's liked posts."""

View File

@@ -30,32 +30,86 @@ def is_audio_or_video(post):
if not embed:
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
if etype and "video" in etype.lower():
if "video" in etype.lower():
return True
# 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", {})
uri = g(ext, "uri", "")
# Common video hosting sites
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
for host in video_hosts:
if host in uri.lower():
return True
# Check in recordWithMedia wrapper
if etype and "recordWithMedia" in etype:
if "recordwithmedia" in etype.lower():
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type")
if mtype and "video" in mtype.lower():
mtype = g(media, "$type") or g(media, "py_type") or ""
if "video" in mtype.lower():
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
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):
"""
Check if post contains image content.
@@ -68,25 +122,22 @@ def is_image(post):
"""
actual_post = g(post, "post", post)
embed = g(actual_post, "embed", None)
if not embed:
return False
return len(_extract_images_from_embed(embed)) > 0
etype = g(embed, "$type") or g(embed, "py_type")
# Direct images embed
if etype and "images" in etype:
images = g(embed, "images", [])
return len(images) > 0
def get_image_urls(post):
"""
Get URLs for image attachments from post for OCR.
# Check in recordWithMedia wrapper
if etype and "recordWithMedia" in etype:
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
Args:
post: Bluesky post object
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):
@@ -105,24 +156,44 @@ def get_media_urls(post):
if not embed:
return urls
etype = g(embed, "$type") or g(embed, "py_type")
etype = g(embed, "$type") or g(embed, "py_type") or ""
# Video embed
if etype and "video" in etype.lower():
playlist = g(embed, "playlist", None)
def extract_video_urls(video_embed):
"""Extract URLs from a video embed object."""
result = []
# Playlist URL (HLS stream)
playlist = g(video_embed, "playlist", None)
if playlist:
urls.append(playlist)
result.append(playlist)
# Alternative URL fields
for key in ["url", "uri", "thumb"]:
val = g(embed, key)
if val and val not in urls:
urls.append(val)
for key in ["url", "uri"]:
val = g(video_embed, key)
if val and val not in result:
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.)
if etype and "external" in etype:
if "external" in etype.lower():
ext = g(embed, "external", {})
uri = g(ext, "uri", "")
if uri:
if uri and uri not in urls:
urls.append(uri)
return urls

View File

@@ -49,8 +49,8 @@ class HomePanel(wx.Panel):
# But for now, simple list is what the previous code had.
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):
if reverse:
self.list.select_item(0)
@@ -93,8 +93,8 @@ class UserPanel(wx.Panel):
self.SetSizer(self.sizer)
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):
if reverse:
self.list.select_item(0)
@@ -124,7 +124,7 @@ class ChatPanel(wx.Panel):
self.SetSizer(self.sizer)
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):
self.list.list.SetFocus()

View File

@@ -203,3 +203,28 @@ class viewPost(wx.Dialog):
if hasattr(self, buttonName):
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())