From 93d37ab3e8194771accc1c08179952de03d696d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Pav=C3=B3n=20Abi=C3=A1n?= Date: Sun, 1 Feb 2026 21:10:46 +0100 Subject: [PATCH] Plantillas --- src/controller/blueski/handler.py | 44 +++++ src/controller/blueski/templateEditor.py | 194 +++++-------------- src/controller/buffers/blueski/base.py | 32 +++- src/sessions/blueski/templates.py | 218 ++++++++++++++++++++++ src/wxUI/dialogs/blueski/configuration.py | 14 ++ 5 files changed, 345 insertions(+), 157 deletions(-) create mode 100644 src/sessions/blueski/templates.py diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py index f1bef1c2..fd41b344 100644 --- a/src/controller/blueski/handler.py +++ b/src/controller/blueski/handler.py @@ -350,7 +350,51 @@ class Handler: ask_default = True if current_mode in (None, "ask") else False from wxUI.dialogs.blueski.configuration import AccountSettingsDialog + from .templateEditor import EditTemplate dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default) + try: + if buffer.session.settings.get("templates") is None: + buffer.session.settings["templates"] = {} + templates_cfg = buffer.session.settings.get("templates", {}) + template_state = { + "post": templates_cfg.get("post", "$display_name, $safe_text $date."), + "person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts."), + "notification": templates_cfg.get("notification", "$display_name $text, $date"), + } + dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"]) + + def edit_post_template(*args, **kwargs): + control = EditTemplate(template=template_state["post"], type="post") + result = control.run_dialog() + if result: + buffer.session.settings["templates"]["post"] = result + buffer.session.settings.write() + template_state["post"] = result + dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"]) + + def edit_person_template(*args, **kwargs): + control = EditTemplate(template=template_state["person"], type="person") + result = control.run_dialog() + if result: + buffer.session.settings["templates"]["person"] = result + buffer.session.settings.write() + template_state["person"] = result + dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"]) + + def edit_notification_template(*args, **kwargs): + control = EditTemplate(template=template_state["notification"], type="notification") + result = control.run_dialog() + if result: + buffer.session.settings["templates"]["notification"] = result + buffer.session.settings.write() + template_state["notification"] = result + dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"]) + + widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template) + widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template) + widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template) + except Exception as e: + logger.error("Failed to init Bluesky templates editor: %s", e) resp = dlg.ShowModal() if resp == wx.ID_OK: vals = dlg.get_values() diff --git a/src/controller/blueski/templateEditor.py b/src/controller/blueski/templateEditor.py index 8af96f51..1cc7d395 100644 --- a/src/controller/blueski/templateEditor.py +++ b/src/controller/blueski/templateEditor.py @@ -1,153 +1,45 @@ -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -# fromapprove.controller.mastodon import templateEditor as mastodon_template_editor # If adapting -fromapprove.translation import translate as _ - -if TYPE_CHECKING: - fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted - -logger = logging.getLogger(__name__) - -# This file would handle the logic for a template editor specific to Blueski. -# A template editor allows users to customize how certain information or messages -# from Blueski are displayed in Approve. - -# For Blueski, this might be less relevant initially if its content structure -# is simpler than Mastodon's, or if user-customizable templates are not a primary feature. -# However, having the structure allows for future expansion. - -# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed. - -class BlueskiTemplateEditor: - def __init__(self, session: BlueskiSession) -> None: - self.session = session - # self.user_id = session.user_id - # self.config_prefix = f"sessions.blueski.{self.user_id}.templates." # Example config path - - def get_editable_templates(self) -> list[dict[str, Any]]: - """ - Returns a list of templates that the user can edit for Blueski. - Each entry should describe the template, its purpose, and current value. - """ - # This would typically fetch template definitions from a default set - # and override with any user-customized versions from config. - - # Example structure for an editable template: - # templates = [ - # { - # "id": "new_follower_notification", # Unique ID for this template - # "name": _("New Follower Notification Format"), - # "description": _("Customize how new follower notifications from Blueski are displayed."), - # "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!", - # "current_template": self._get_template_content("new_follower_notification"), - # "variables": [ # Available variables for this template - # {"name": "actor.displayName", "description": _("Display name of the new follower")}, - # {"name": "actor.handle", "description": _("Handle of the new follower")}, - # {"name": "actor.url", "description": _("URL to the new follower's profile")}, - # ], - # "category": "notifications", # For grouping in UI - # }, - # # Add more editable templates for Blueski here - # ] - # return templates - return [] # Placeholder - no editable templates defined yet for Blueski - - def _get_template_content(self, template_id: str) -> str: - """ - Retrieves the current content of a specific template, either user-customized or default. - """ - # config_key = self.config_prefix + template_id - # default_value = self._get_default_template_content(template_id) - # return approve.config.config.get_value(config_key, default_value) # Example config access - return self._get_default_template_content(template_id) # Placeholder - - def _get_default_template_content(self, template_id: str) -> str: - """ - Returns the default content for a given template ID. - """ - # This could be hardcoded or loaded from a defaults file. - # if template_id == "new_follower_notification": - # return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!" - # # ... other default templates - return "" # Placeholder - - async def save_template_content(self, template_id: str, content: str) -> bool: - """ - Saves the user-customized content for a specific template. - """ - # config_key = self.config_prefix + template_id - # try: - # await approve.config.config.set_value(config_key, content) # Example config access - # logger.info(f"Blueski template '{template_id}' saved for user {self.user_id}.") - # return True - # except Exception as e: - # logger.error(f"Error saving Blueski template '{template_id}' for user {self.user_id}: {e}") - # return False - return False # Placeholder - - def get_template_preview(self, template_id: str, custom_content: str | None = None) -> str: - """ - Generates a preview of a template using sample data. - If custom_content is provided, it's used instead of the saved template. - """ - # content_to_render = custom_content if custom_content is not None else self._get_template_content(template_id) - # sample_data = self._get_sample_data_for_template(template_id) - - # try: - # # Use a templating engine (like Jinja2) to render the preview - # # from jinja2 import Template - # # template = Template(content_to_render) - # # preview = template.render(**sample_data) - # # return preview - # return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder - # except Exception as e: - # logger.error(f"Error generating preview for Blueski template '{template_id}': {e}") - # return _("Error generating preview.") - return _("Template previews not yet implemented for Blueski.") # Placeholder - - def _get_sample_data_for_template(self, template_id: str) -> dict[str, Any]: - """ - Returns sample data appropriate for previewing a specific template. - """ - # if template_id == "new_follower_notification": - # return { - # "actor": { - # "displayName": "Test User", - # "handle": "testuser.bsky.social", - # "url": "https://bsky.app/profile/testuser.bsky.social" - # } - # } - # # ... other sample data - return {} # Placeholder - -# Functions to be called by the main controller/handler for template editor actions. - -async def get_editor_config(session: BlueskiSession) -> dict[str, Any]: - """ - Get the configuration needed to display the template editor for Blueski. - """ - editor = BlueskiTemplateEditor(session) - return { - "editable_templates": editor.get_editable_templates(), - "help_text": _("Customize Blueski message formats. Use variables shown for each template."), - } - -async def save_template(session: BlueskiSession, template_id: str, content: str) -> bool: - """ - Save a modified template for Blueski. - """ - editor = BlueskiTemplateEditor(session) - return await editor.save_template_content(template_id, content) - -async def get_template_preview_html(session: BlueskiSession, template_id: str, content: str) -> str: - """ - Get an HTML preview for a template with given content. - """ - editor = BlueskiTemplateEditor(session) - return editor.get_template_preview(template_id, custom_content=content) +# -*- coding: utf-8 -*- +import re +import wx +from typing import List +from sessions.blueski.templates import post_variables, person_variables, notification_variables +from wxUI.dialogs import templateDialogs -logger.info("Blueski template editor module loaded (placeholders).") +class EditTemplate(object): + def __init__(self, template: str, type: str) -> None: + super(EditTemplate, self).__init__() + self.default_template = template + if type == "post": + self.variables = post_variables + elif type == "notification": + self.variables = notification_variables + else: + self.variables = person_variables + self.template: str = template + + def validate_template(self, template: str) -> bool: + used_variables: List[str] = re.findall(r"\$\w+", template) + validated: bool = True + for var in used_variables: + if var[1:] not in self.variables: + validated = False + return validated + + def run_dialog(self) -> str: + dialog = templateDialogs.EditTemplateDialog( + template=self.template, + variables=self.variables, + default_template=self.default_template, + ) + response = dialog.ShowModal() + if response == wx.ID_SAVE: + validated: bool = self.validate_template(dialog.template.GetValue()) + if validated == False: + templateDialogs.invalid_template() + self.template = dialog.template.GetValue() + return self.run_dialog() + else: + return dialog.template.GetValue() + else: + return "" diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py index 92b25b95..8ffa0303 100644 --- a/src/controller/buffers/blueski/base.py +++ b/src/controller/buffers/blueski/base.py @@ -10,7 +10,7 @@ import languageHandler from pubsub import pub from controller.buffers.base import base from controller.blueski import messages as blueski_messages -from sessions.blueski import compose, utils +from sessions.blueski import compose, utils, templates from mysc.thread_utils import call_threaded from wxUI.buffers.blueski import panels as BlueskiPanels from wxUI import commonMessageDialogs @@ -739,11 +739,31 @@ class BaseBuffer(base.Buffer): item = self.get_item() if item is None: return - # Use the compose function to get the full formatted text - # Bluesky compose returns [user, text, date, source] - composed = self.compose_function(item, self.session.db, self.session.settings, self.session.settings["general"].get("relative_times", False), self.session.settings["general"].get("show_screen_names", False)) - # Join them for a full readout similar to Mastodon's template render - return " ".join(composed) + relative_times = self.session.settings["general"].get("relative_times", False) + offset_hours = 0 + if isinstance(self.session.db, dict): + offset_hours = self.session.db.get("utc_offset", 0) or 0 + template_settings = self.session.settings.get("templates", {}) + try: + if self.type == "notifications": + template = template_settings.get("notification", "$display_name $text, $date") + post_template = template_settings.get("post", "$display_name, $safe_text $date.") + return templates.render_notification(item, template, post_template, self.session.settings, relative_times, offset_hours) + if self.type in ("user", "post_user_list"): + template = template_settings.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts.") + return templates.render_user(item, template, self.session.settings, relative_times, offset_hours) + template = template_settings.get("post", "$display_name, $safe_text $date.") + return templates.render_post(item, template, self.session.settings, relative_times, offset_hours) + except Exception: + # Fallback to compose if any template render fails. + composed = self.compose_function( + item, + self.session.db, + self.session.settings, + relative_times, + self.session.settings["general"].get("show_screen_names", False), + ) + return " ".join(composed) def view_conversation(self, *args, **kwargs): item = self.get_item() diff --git a/src/sessions/blueski/templates.py b/src/sessions/blueski/templates.py new file mode 100644 index 00000000..d6e66dd5 --- /dev/null +++ b/src/sessions/blueski/templates.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +import arrow +import languageHandler +from string import Template + + +post_variables = [ + "date", + "display_name", + "screen_name", + "source", + "lang", + "safe_text", + "text", + "image_descriptions", + "visibility", + "pinned", +] +person_variables = [ + "display_name", + "screen_name", + "description", + "followers", + "following", + "favorites", + "posts", + "created_at", +] +notification_variables = ["display_name", "screen_name", "text", "date"] + + +def _g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _extract_labels(obj): + labels = _g(obj, "labels", None) + if labels is None: + return [] + if isinstance(labels, dict): + return labels.get("values", []) or [] + if isinstance(labels, list): + return labels + return [] + + +def _extract_cw_text(post, record): + labels = _extract_labels(post) + _extract_labels(record) + for label in labels: + val = _g(label, "val", "") + if val == "warn": + return _("Sensitive Content") + if isinstance(val, str) and val.startswith("warn:"): + return val.split("warn:", 1)[-1].strip() + return "" + + +def _extract_image_descriptions(post, record): + def collect_images(embed): + if not embed: + return [] + etype = _g(embed, "$type") or _g(embed, "py_type") or "" + if "recordWithMedia" in etype: + media = _g(embed, "media") + mtype = _g(media, "$type") or _g(media, "py_type") or "" + if "images" in mtype: + return list(_g(media, "images", []) or []) + return [] + if "images" in etype: + return list(_g(embed, "images", []) or []) + return [] + + images = [] + images.extend(collect_images(_g(post, "embed"))) + if not images: + images.extend(collect_images(_g(record, "embed"))) + + descriptions = [] + for idx, img in enumerate(images, start=1): + alt = _g(img, "alt", "") or "" + if alt: + descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt)) + return "\n".join(descriptions) + + +def process_date(field, relative_times=True, offset_hours=0): + original_date = arrow.get(field) + if relative_times: + return original_date.humanize(locale=languageHandler.curLang[:2]) + return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2]) + + +def render_post(post, template, settings, relative_times=False, offset_hours=0): + actual_post = _g(post, "post", post) + record = _g(actual_post, "record") or _g(post, "record") or {} + author = _g(actual_post, "author") or _g(post, "author") or {} + + reason = _g(post, "reason") + is_repost = False + reposter = None + if reason: + rtype = _g(reason, "$type") or _g(reason, "py_type") or "" + if "reasonRepost" in rtype: + is_repost = True + reposter = _g(reason, "by") + + if is_repost and reposter: + display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "") + screen_name = _g(reposter, "handle", "") + else: + display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "") + screen_name = _g(author, "handle", "") + + text = _g(record, "text", "") or "" + if is_repost: + original_handle = _g(author, "handle", "") + text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text) + + cw_text = _extract_cw_text(actual_post, record) + safe_text = text + if cw_text: + safe_text = _("Content warning: {cw}").format(cw=cw_text) + + created_at = _g(record, "createdAt") or _g(record, "created_at") + indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at") + date_field = created_at or indexed_at + date = process_date(date_field, relative_times, offset_hours) if date_field else "" + + langs = _g(record, "langs") or _g(record, "languages") or [] + lang = langs[0] if isinstance(langs, list) and langs else "" + + image_descriptions = _extract_image_descriptions(actual_post, record) + + available_data = dict( + date=date, + display_name=display_name, + screen_name=screen_name, + source="Bluesky", + lang=lang, + safe_text=safe_text, + text=text, + image_descriptions=image_descriptions, + visibility=_("Public"), + pinned="", + ) + return Template(_(template)).safe_substitute(**available_data) + + +def render_user(user, template, settings, relative_times=True, offset_hours=0): + display_name = _g(user, "displayName") or _g(user, "display_name") or _g(user, "handle", "") + screen_name = _g(user, "handle", "") + description = _g(user, "description", "") or "" + followers = _g(user, "followersCount", 0) or 0 + following = _g(user, "followsCount", 0) or 0 + posts = _g(user, "postsCount", 0) or 0 + created_at = _g(user, "createdAt") + created = "" + if created_at: + created = process_date(created_at, relative_times, offset_hours) + + available_data = dict( + display_name=display_name, + screen_name=screen_name, + description=description, + followers=followers, + following=following, + favorites="", + posts=posts, + created_at=created, + ) + return Template(_(template)).safe_substitute(**available_data) + + +def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0): + author = _g(notification, "author") or {} + display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "") + screen_name = _g(author, "handle", "") + reason = _g(notification, "reason", "unknown") + record = _g(notification, "record") or {} + post_text = _g(record, "text", "") or "" + + if reason == "like": + text = _("{username} has added to favorites: {status}").format( + username=display_name, status=post_text + ) if post_text else _("{username} has added to favorites").format(username=display_name) + elif reason == "repost": + text = _("{username} has reposted: {status}").format( + username=display_name, status=post_text + ) if post_text else _("{username} has reposted").format(username=display_name) + elif reason == "follow": + text = _("{username} has followed you.").format(username=display_name) + elif reason == "mention": + text = _("{username} has mentioned you: {status}").format( + username=display_name, status=post_text + ) if post_text else _("{username} has mentioned you").format(username=display_name) + elif reason == "reply": + text = _("{username} has replied: {status}").format( + username=display_name, status=post_text + ) if post_text else _("{username} has replied").format(username=display_name) + elif reason == "quote": + text = _("{username} has quoted your post: {status}").format( + username=display_name, status=post_text + ) if post_text else _("{username} has quoted your post").format(username=display_name) + else: + text = "{user}: {reason}".format(user=display_name or screen_name, reason=reason) + + indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at") + date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else "" + + available_data = dict( + display_name=display_name, + screen_name=screen_name, + text=text, + date=date, + ) + return Template(_(template)).safe_substitute(**available_data) diff --git a/src/wxUI/dialogs/blueski/configuration.py b/src/wxUI/dialogs/blueski/configuration.py index 0fff820a..0f6fb3aa 100644 --- a/src/wxUI/dialogs/blueski/configuration.py +++ b/src/wxUI/dialogs/blueski/configuration.py @@ -15,6 +15,15 @@ class AccountSettingsDialog(wx.Dialog): self.ask_before_boost.SetValue(bool(ask_before_boost)) sizer.Add(self.ask_before_boost, 0, wx.ALL, 8) + templates_box = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Templates")), wx.VERTICAL) + self.template_post = wx.Button(panel, wx.ID_ANY, _("Edit template for posts")) + self.template_person = wx.Button(panel, wx.ID_ANY, _("Edit template for persons")) + self.template_notification = wx.Button(panel, wx.ID_ANY, _("Edit template for notifications")) + templates_box.Add(self.template_post, 0, wx.ALL, 4) + templates_box.Add(self.template_person, 0, wx.ALL, 4) + templates_box.Add(self.template_notification, 0, wx.ALL, 4) + sizer.Add(templates_box, 0, wx.EXPAND | wx.ALL, 8) + # Buttons btn_sizer = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL) @@ -31,3 +40,8 @@ class AccountSettingsDialog(wx.Dialog): "ask_before_boost": self.ask_before_boost.GetValue(), } + def set_template_labels(self, post_template, person_template, notification_template): + self.template_post.SetLabel(_("Edit template for posts. Current template: {}").format(post_template)) + self.template_person.SetLabel(_("Edit template for persons. Current template: {}").format(person_template)) + self.template_notification.SetLabel(_("Edit template for notifications. Current template: {}").format(notification_template)) +