diff --git a/gotify_tray/database/cache.py b/gotify_tray/database/cache.py index e7ebcd5..ac62dac 100644 --- a/gotify_tray/database/cache.py +++ b/gotify_tray/database/cache.py @@ -32,6 +32,9 @@ class Cache(object): self.cache_dir = os.path.join(path, "cache") os.makedirs(self.cache_dir, exist_ok=True) + def directory(self) -> str: + return self.cache_dir + def clear(self): self.cursor.execute("DELETE FROM cache") self.database.commit() diff --git a/gotify_tray/database/default_settings.py b/gotify_tray/database/default_settings.py index fa03269..500ffd8 100644 --- a/gotify_tray/database/default_settings.py +++ b/gotify_tray/database/default_settings.py @@ -19,6 +19,8 @@ DEFAULT_SETTINGS = { "tray/icon/unread": False, "watchdog/interval/s": 60, "MessageWidget/image/size": 33, + "MessageWidget/content_image/W_percentage": 1.0, + "MessageWidget/content_image/H_percentage": 0.5, "MainWindow/label/size": 25, "MainWindow/button/size": 33, "MainWindow/application/icon/size": 40, diff --git a/gotify_tray/gui/MainApplication.py b/gotify_tray/gui/MainApplication.py index 0bf2d91..19eba73 100644 --- a/gotify_tray/gui/MainApplication.py +++ b/gotify_tray/gui/MainApplication.py @@ -16,6 +16,8 @@ from gotify_tray.tasks import ( GetApplicationsTask, GetApplicationMessagesTask, GetMessagesTask, + ProcessMessageTask, + ProcessMessagesTask, ServerConnectionWatchdogTask, ) from gotify_tray.gui.themes import set_theme @@ -74,6 +76,8 @@ class MainApplication(QtWidgets.QApplication): self.application_model = ApplicationModel() self.main_window = MainWindow(self.application_model, self.messages_model) + self.main_window.show() # The initial .show() is necessary to get the correct sizes when adding MessageWigets + QtCore.QTimer.singleShot(0, self.main_window.hide) self.refresh_applications() @@ -195,43 +199,39 @@ class MainApplication(QtWidgets.QApplication): ), ) + def get_messages_finished_callback(self, page: gotify.GotifyPagedMessagesModel): + """Process messages before inserting them into the main window + """ + + def insert_helper(row: int, message: gotify.GotifyMessageModel): + if item := self.application_model.itemFromId(message.appid): + self.insert_message( + row, message, item.data(ApplicationItemDataRole.ApplicationRole), + ) + self.processEvents() + + self.process_messages_task = ProcessMessagesTask(page) + self.process_messages_task.message_processed.connect(insert_helper) + self.process_messages_task.start() + def application_selection_changed_callback( self, item: Union[ApplicationModelItem, ApplicationAllMessagesItem] ): self.messages_model.clear() if isinstance(item, ApplicationModelItem): - - def get_application_messages_callback( - page: gotify.GotifyPagedMessagesModel, - ): - for i, message in enumerate(page.messages): - self.insert_message( - i, message, item.data(ApplicationItemDataRole.ApplicationRole), - ) - self.get_application_messages_task = GetApplicationMessagesTask( item.data(ApplicationItemDataRole.ApplicationRole).id, self.gotify_client, ) self.get_application_messages_task.success.connect( - get_application_messages_callback + self.get_messages_finished_callback ) self.get_application_messages_task.start() elif isinstance(item, ApplicationAllMessagesItem): - - def get_messages_callback(page: gotify.GotifyPagedMessagesModel): - for i, message in enumerate(page.messages): - if item := self.application_model.itemFromId(message.appid): - self.insert_message( - i, - message, - item.data(ApplicationItemDataRole.ApplicationRole), - ) - self.get_messages_task = GetMessagesTask(self.gotify_client) - self.get_messages_task.success.connect(get_messages_callback) + self.get_messages_task.success.connect(self.get_messages_finished_callback) self.get_messages_task.start() def add_message_to_model(self, message: gotify.GotifyMessageModel): @@ -240,14 +240,27 @@ class MainApplication(QtWidgets.QApplication): if selected_application_item := self.application_model.itemFromIndex( application_index ): - if isinstance(selected_application_item, ApplicationModelItem): - # A single application is selected - if ( - message.appid - == selected_application_item.data( - ApplicationItemDataRole.ApplicationRole - ).id + + def insert_message_helper(): + if isinstance(selected_application_item, ApplicationModelItem): + # A single application is selected + if ( + message.appid + == selected_application_item.data( + ApplicationItemDataRole.ApplicationRole + ).id + ): + self.insert_message( + 0, + message, + application_item.data( + ApplicationItemDataRole.ApplicationRole + ), + ) + elif isinstance( + selected_application_item, ApplicationAllMessagesItem ): + # "All messages' is selected self.insert_message( 0, message, @@ -255,13 +268,15 @@ class MainApplication(QtWidgets.QApplication): ApplicationItemDataRole.ApplicationRole ), ) - elif isinstance(selected_application_item, ApplicationAllMessagesItem): - # "All messages' is selected - self.insert_message( - 0, - message, - application_item.data(ApplicationItemDataRole.ApplicationRole), - ) + + self.process_message_task = ProcessMessageTask(message) + self.process_message_task.finished.connect(insert_message_helper) + self.process_message_task.start() + else: + logger.error( + f"App id {message.appid} could not be found. Refreshing applications." + ) + self.refresh_applications() def new_message_callback(self, message: gotify.GotifyMessageModel): self.add_message_to_model(message) @@ -335,18 +350,13 @@ class MainApplication(QtWidgets.QApplication): if image_popup := getattr(self, "image_popup", None): image_popup.close() - def refresh_callback(self): - # Manual refresh -> also reset the image cache - Cache().clear() - self.refresh_applications() - def theme_change_requested_callback(self, theme: str): # Set the theme set_theme(self, theme) # Update the main window icons self.main_window.set_icons() - + # Update the message widget icons for r in range(self.messages_model.rowCount()): message_widget: MessageWidget = self.main_window.listView_messages.indexWidget( @@ -400,7 +410,7 @@ class MainApplication(QtWidgets.QApplication): self.tray.messageClicked.connect(self.tray_notification_clicked_callback) self.tray.activated.connect(self.tray_activated_callback) - self.main_window.refresh.connect(self.refresh_callback) + self.main_window.refresh.connect(self.refresh_applications) self.main_window.delete_all.connect(self.delete_all_messages_callback) self.main_window.application_selection_changed.connect( self.application_selection_changed_callback diff --git a/gotify_tray/gui/designs/widget_settings.py b/gotify_tray/gui/designs/widget_settings.py index 31df766..97e7ac8 100644 --- a/gotify_tray/gui/designs/widget_settings.py +++ b/gotify_tray/gui/designs/widget_settings.py @@ -171,6 +171,22 @@ class Ui_Dialog(object): self.horizontalLayout_4.addItem(spacerItem5) self.gridLayout_2.addLayout(self.horizontalLayout_4, 0, 0, 1, 1) self.verticalLayout.addWidget(self.groupbox_image_popup) + self.groupBox_cache = QtWidgets.QGroupBox(self.tab_advanced) + self.groupBox_cache.setObjectName("groupBox_cache") + self.horizontalLayout_6 = QtWidgets.QHBoxLayout(self.groupBox_cache) + self.horizontalLayout_6.setObjectName("horizontalLayout_6") + self.pb_clear_cache = QtWidgets.QPushButton(self.groupBox_cache) + self.pb_clear_cache.setObjectName("pb_clear_cache") + self.horizontalLayout_6.addWidget(self.pb_clear_cache) + self.pb_open_cache_dir = QtWidgets.QPushButton(self.groupBox_cache) + self.pb_open_cache_dir.setObjectName("pb_open_cache_dir") + self.horizontalLayout_6.addWidget(self.pb_open_cache_dir) + self.label_cache = QtWidgets.QLabel(self.groupBox_cache) + self.label_cache.setObjectName("label_cache") + self.horizontalLayout_6.addWidget(self.label_cache) + spacerItem6 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.horizontalLayout_6.addItem(spacerItem6) + self.verticalLayout.addWidget(self.groupBox_cache) self.groupBox_logging = QtWidgets.QGroupBox(self.tab_advanced) self.groupBox_logging.setObjectName("groupBox_logging") self.gridLayout_6 = QtWidgets.QGridLayout(self.groupBox_logging) @@ -178,8 +194,8 @@ class Ui_Dialog(object): self.combo_logging = QtWidgets.QComboBox(self.groupBox_logging) self.combo_logging.setObjectName("combo_logging") self.gridLayout_6.addWidget(self.combo_logging, 0, 1, 1, 1) - spacerItem6 = QtWidgets.QSpacerItem(190, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) - self.gridLayout_6.addItem(spacerItem6, 0, 3, 1, 1) + spacerItem7 = QtWidgets.QSpacerItem(190, 20, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum) + self.gridLayout_6.addItem(spacerItem7, 0, 3, 1, 1) self.pb_open_log = QtWidgets.QPushButton(self.groupBox_logging) self.pb_open_log.setMaximumSize(QtCore.QSize(30, 16777215)) self.pb_open_log.setObjectName("pb_open_log") @@ -188,8 +204,8 @@ class Ui_Dialog(object): self.label_logging.setObjectName("label_logging") self.gridLayout_6.addWidget(self.label_logging, 0, 0, 1, 1) self.verticalLayout.addWidget(self.groupBox_logging) - spacerItem7 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) - self.verticalLayout.addItem(spacerItem7) + spacerItem8 = QtWidgets.QSpacerItem(20, 40, QtWidgets.QSizePolicy.Policy.Minimum, QtWidgets.QSizePolicy.Policy.Expanding) + self.verticalLayout.addItem(spacerItem8) self.tabWidget.addTab(self.tab_advanced, "") self.gridLayout.addWidget(self.tabWidget, 0, 0, 1, 1) @@ -238,6 +254,10 @@ class Ui_Dialog(object): self.label_2.setToolTip(_translate("Dialog", "Maximum pop-up height")) self.label_2.setText(_translate("Dialog", "Height")) self.spin_popup_h.setToolTip(_translate("Dialog", "Maximum pop-up height")) + self.groupBox_cache.setTitle(_translate("Dialog", "Cache")) + self.pb_clear_cache.setText(_translate("Dialog", "Clear")) + self.pb_open_cache_dir.setText(_translate("Dialog", "Open")) + self.label_cache.setText(_translate("Dialog", "TextLabel")) self.groupBox_logging.setTitle(_translate("Dialog", "Logging")) self.pb_open_log.setToolTip(_translate("Dialog", "Open logfile")) self.pb_open_log.setText(_translate("Dialog", "...")) diff --git a/gotify_tray/gui/designs/widget_settings.ui b/gotify_tray/gui/designs/widget_settings.ui index 5660f81..fc75ed8 100644 --- a/gotify_tray/gui/designs/widget_settings.ui +++ b/gotify_tray/gui/designs/widget_settings.ui @@ -400,6 +400,49 @@ + + + + Cache + + + + + + Clear + + + + + + + Open + + + + + + + TextLabel + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + diff --git a/gotify_tray/gui/widgets/MainWindow.py b/gotify_tray/gui/widgets/MainWindow.py index 78c7c92..a4c095b 100644 --- a/gotify_tray/gui/widgets/MainWindow.py +++ b/gotify_tray/gui/widgets/MainWindow.py @@ -30,7 +30,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): ): super(MainWindow, self).__init__() self.setupUi(self) - + self.installEventFilter(self) self.setWindowTitle(__title__) @@ -85,7 +85,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): size = settings.value("MainWindow/application/icon/size", type=int) self.listView_applications.setIconSize(QtCore.QSize(size, size)) - + # Refresh the status widget self.status_widget.refresh() @@ -104,7 +104,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): def insert_message_widget( self, message_item: MessagesModelItem, image_path: str = "" ): - message_widget = MessageWidget(message_item, image_path=image_path) + message_widget = MessageWidget( + self.listView_messages, message_item, image_path=image_path + ) self.listView_messages.setIndexWidget( self.messages_model.indexFromItem(message_item), message_widget ) diff --git a/gotify_tray/gui/widgets/MessageWidget.py b/gotify_tray/gui/widgets/MessageWidget.py index 339963d..8916f28 100644 --- a/gotify_tray/gui/widgets/MessageWidget.py +++ b/gotify_tray/gui/widgets/MessageWidget.py @@ -4,9 +4,11 @@ from PyQt6 import QtCore, QtGui, QtWidgets from ..models.MessagesModel import MessageItemDataRole, MessagesModelItem from ..designs.widget_message import Ui_Form +from gotify_tray.database import Downloader from gotify_tray.database import Settings -from gotify_tray.utils import convert_links +from gotify_tray.utils import convert_links, get_image from gotify_tray.gui.themes import get_theme_file +from gotify_tray.gotify.models import GotifyMessageModel settings = Settings("gotify-tray") @@ -16,13 +18,18 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): deletion_requested = QtCore.pyqtSignal(MessagesModelItem) image_popup = QtCore.pyqtSignal(str, QtCore.QPoint) - def __init__(self, message_item: MessagesModelItem, image_path: str = ""): + def __init__( + self, + parent: QtWidgets.QWidget, + message_item: MessagesModelItem, + image_path: str = "", + ): super(MessageWidget, self).__init__() + self.parent = parent self.setupUi(self) self.setAutoFillBackground(True) - self.message_item = message_item - message = message_item.data(MessageItemDataRole.MessageRole) + message: GotifyMessageModel = message_item.data(MessageItemDataRole.MessageRole) # Fonts self.set_fonts() @@ -31,14 +38,20 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.label_title.setText(message.title) self.label_date.setText(message.date.strftime("%Y-%m-%d, %H:%M")) - if ( - markdown := message.get("extras", {}) - .get("client::display", {}) - .get("contentType") - ) == "text/markdown": + if markdown := ( + message.get("extras", {}).get("client::display", {}).get("contentType") + == "text/markdown" + ): self.label_message.setTextFormat(QtCore.Qt.TextFormat.MarkdownText) - self.label_message.setText(convert_links(message.message)) + # If the message is only an image URL, then instead of showing the message, + # download the image and show it in the message label + if image_url := get_image(message.message): + downloader = Downloader() + filename = downloader.get_filename(image_url) + self.set_message_image(filename) + else: + self.label_message.setText(convert_links(message.message)) # Show the application icon if image_path: @@ -91,6 +104,27 @@ class MessageWidget(QtWidgets.QWidget, Ui_Form): self.pb_delete.setIcon(QtGui.QIcon(get_theme_file("trashcan.svg"))) self.pb_delete.setIconSize(QtCore.QSize(24, 24)) + def set_message_image(self, filename: str): + pixmap = QtGui.QPixmap(filename) + + # Make sure the image fits within the listView + W = settings.value("MessageWidget/content_image/W_percentage", type=float) * ( + self.parent.width() - self.label_image.width() + ) + H = ( + settings.value("MessageWidget/content_image/H_percentage", type=float) + * self.parent.height() + ) + + if pixmap.width() > W or pixmap.height() > H: + pixmap = pixmap.scaled( + QtCore.QSize(int(W), int(H)), + aspectRatioMode=QtCore.Qt.AspectRatioMode.KeepAspectRatio, + transformMode=QtCore.Qt.TransformationMode.SmoothTransformation, + ) + + self.label_message.setPixmap(pixmap) + def link_hovered_callback(self, link: str): if not settings.value("ImagePopup/enabled", type=bool): return diff --git a/gotify_tray/gui/widgets/SettingsDialog.py b/gotify_tray/gui/widgets/SettingsDialog.py index ab66dab..c37171f 100644 --- a/gotify_tray/gui/widgets/SettingsDialog.py +++ b/gotify_tray/gui/widgets/SettingsDialog.py @@ -2,12 +2,12 @@ import logging import platform import os -from gotify_tray.database import Settings +from gotify_tray.database import Cache, Settings from gotify_tray.gotify import GotifyMessageModel from gotify_tray.gui.models import MessagesModelItem from . import MessageWidget from gotify_tray.utils import get_icon, verify_server, open_file -from gotify_tray.tasks import ExportSettingsTask, ImportSettingsTask +from gotify_tray.tasks import ExportSettingsTask, ImportSettingsTask, CacheSizeTask, ClearCacheTask from gotify_tray.gui.themes import get_themes from PyQt6 import QtCore, QtGui, QtWidgets @@ -91,9 +91,12 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): ) self.spin_popup_w.setValue(settings.value("ImagePopup/w", type=int)) self.spin_popup_h.setValue(settings.value("ImagePopup/h", type=int)) + self.label_cache.setText("0 MB") + self.compute_cache_size() def add_message_widget(self): self.message_widget = MessageWidget( + self, MessagesModelItem( GotifyMessageModel( { @@ -107,6 +110,11 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): ) self.layout_fonts_message.addWidget(self.message_widget) + def compute_cache_size(self): + self.cache_size_task = CacheSizeTask() + self.cache_size_task.size.connect(lambda size: self.label_cache.setText(f"{round(size/1e6, 1)} MB")) + self.cache_size_task.start() + def change_server_info_callback(self): self.server_changed = verify_server(force_new=True, enable_import=False) @@ -179,6 +187,11 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): settings.clear() self.quit_requested.emit() + def clear_cache_callback(self): + self.clear_cache_task = ClearCacheTask() + self.clear_cache_task.start() + self.label_cache.setText("0 MB") + def link_callbacks(self): self.buttonBox.button( QtWidgets.QDialogButtonBox.StandardButton.Apply @@ -223,6 +236,8 @@ class SettingsDialog(QtWidgets.QDialog, Ui_Dialog): self.groupbox_image_popup.toggled.connect(self.settings_changed_callback) self.spin_popup_w.valueChanged.connect(self.settings_changed_callback) self.spin_popup_h.valueChanged.connect(self.settings_changed_callback) + self.pb_clear_cache.clicked.connect(self.clear_cache_callback) + self.pb_open_cache_dir.clicked.connect(lambda: open_file(Cache().directory())) def apply_settings(self): # Priority diff --git a/gotify_tray/tasks.py b/gotify_tray/tasks.py index d8a821d..d9e0233 100644 --- a/gotify_tray/tasks.py +++ b/gotify_tray/tasks.py @@ -1,13 +1,17 @@ import abc +import glob import logging import time +import os +from functools import reduce from PyQt6 import QtCore from PyQt6.QtCore import pyqtSignal -from gotify_tray.database import Settings +from gotify_tray.database import Cache, Downloader, Settings from gotify_tray.gotify.api import GotifyClient from gotify_tray.gotify.models import GotifyVersionModel +from gotify_tray.utils import get_image from . import gotify @@ -199,3 +203,48 @@ class ImportSettingsTask(BaseTask): def task(self): settings.load(self.path) self.success.emit() + + +class ProcessMessageTask(BaseTask): + def __init__(self, message: gotify.GotifyMessageModel): + super(ProcessMessageTask, self).__init__() + self.message = message + + def task(self): + if image_url := get_image(self.message.message): + downloader = Downloader() + downloader.get_filename(image_url) + + +class ProcessMessagesTask(BaseTask): + message_processed = pyqtSignal(int, gotify.GotifyMessageModel) + + def __init__(self, page: gotify.GotifyPagedMessagesModel): + super(ProcessMessagesTask, self).__init__() + self.page = page + + def task(self): + downloader = Downloader() + for i, message in enumerate(self.page.messages): + if image_url := get_image(message.message): + downloader.get_filename(image_url) + self.message_processed.emit(i, message) + + # Prevent locking up the UI when there are a lot of messages with images ready at the same time + time.sleep(0.001) + + +class CacheSizeTask(BaseTask): + size = pyqtSignal(int) + + def task(self): + cache_dir = Cache().directory() + if os.path.exists(cache_dir): + cache_size_bytes = reduce(lambda x, f: x + os.path.getsize(f), glob.glob(os.path.join(cache_dir, "*")), 0) + self.size.emit(cache_size_bytes) + +class ClearCacheTask(BaseTask): + def task(self): + cache = Cache() + cache.clear() + \ No newline at end of file diff --git a/gotify_tray/utils.py b/gotify_tray/utils.py index d36c3cf..b087872 100644 --- a/gotify_tray/utils.py +++ b/gotify_tray/utils.py @@ -4,6 +4,7 @@ import re import subprocess from pathlib import Path +from typing import Optional def verify_server(force_new: bool = False, enable_import: bool = True) -> bool: @@ -44,6 +45,25 @@ def convert_links(text): return _link.sub(replace, text) +def get_image(s: str) -> Optional[str]: + """If `s` contains only an image URL, this function returns that URL. + This function also extracts a URL in the `![]()` markdown image format. + """ + s = s.strip() + + # Return True if 's' is a url and has an image extension + RE = r'(?:(https://|http://)|(www\.))(\S+\b/?)([!"#$%&\'()*+,\-./:;<=>?@[\\\]^_`{|}~]*).(jpg|jpeg|png|bmp|gif)(\s|$)' + if re.compile(RE, re.I).fullmatch(s) is not None: + return s + + # Return True if 's' has the markdown image format + RE = r'!\[[^\]]*\]\((.*?)\s*("(?:.*[^"])")?\s*\)' + if re.compile(RE, re.I).fullmatch(s) is not None: + return re.compile(RE, re.I).findall(s)[0][0] + + return None + + def get_abs_path(s) -> str: h = Path(__file__).parent.parent p = Path(s)