mirror of
https://github.com/chidiwilliams/buzz.git
synced 2026-03-14 22:55:46 +01:00
Initial version by @greatdaveo
This commit is contained in:
parent
3869ac08db
commit
5bad412804
7 changed files with 514 additions and 0 deletions
1
buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg
Normal file
1
buzz/assets/update_FILL0_wght700_GRAD0_opsz48.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M160-200v-60h640v60H160Zm320-136L280-536l42-42 128 128v-310h60v310l128-128 42 42-200 200Z" transform="rotate(180 480 -480)"/></svg>
|
||||
|
After Width: | Height: | Size: 229 B |
|
|
@ -82,6 +82,9 @@ class Settings:
|
|||
FORCE_CPU = "force-cpu"
|
||||
REDUCE_GPU_MEMORY = "reduce-gpu-memory"
|
||||
|
||||
LAST_UPDATE_CHECK = "update/last-check"
|
||||
UPDATE_AVAILABLE_VERSION = "update/available-version"
|
||||
|
||||
def get_user_identifier(self) -> str:
|
||||
user_id = self.value(self.Key.USER_IDENTIFIER, "")
|
||||
if not user_id:
|
||||
|
|
|
|||
180
buzz/update_checker.py
Normal file
180
buzz/update_checker.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import json
|
||||
import logging
|
||||
import platform
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, QUrl
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
from sympy import true
|
||||
|
||||
from buzz.__version__ import VERSION
|
||||
from buzz.settings.settings import Settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateInfo:
|
||||
version: str
|
||||
release_notes: str
|
||||
download_url: str
|
||||
|
||||
class UpdateChecker(QObject):
|
||||
update_available = pyqtSignal(object)
|
||||
|
||||
no_update_available = pyqtSignal()
|
||||
|
||||
check_failed = pyqtSignal(str)
|
||||
|
||||
VERSION_JSON_URL = "https://raw.githubusercontent.com/chidiwilliams/buzz/refs/heads/main/version.json"
|
||||
|
||||
CHECK_INTERVAL_DAYS = 7
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
network_manager: Optional[QNetworkAccessManager] = None,
|
||||
parent: Optional[QObject] = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.settings = settings
|
||||
|
||||
if network_manager is None:
|
||||
network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager = network_manager
|
||||
self.network_manager.finished.connect(self._on_reply_finished)
|
||||
|
||||
self._force_check = False
|
||||
|
||||
|
||||
def should_check_for_updates(self) -> bool:
|
||||
""""Check if we are on Windows/macOS and if 7 days passed"""
|
||||
system = platform.system()
|
||||
if system not in ("Windows", "Darwin"):
|
||||
logging.debug("Skipping update check on linux")
|
||||
return False
|
||||
|
||||
last_check = self.settings.value(
|
||||
Settings.Key.LAST_UPDATE_CHECK,
|
||||
"",
|
||||
)
|
||||
|
||||
if last_check:
|
||||
try:
|
||||
last_check_date = datetime.fromisoformat(last_check)
|
||||
days_since_check = (datetime.now() - last_check_date).days
|
||||
if days_since_check < self.CHECK_INTERVAL_DAYS:
|
||||
logging.debug(
|
||||
f"Skipping update check, last checked {days_since_check} days ago"
|
||||
)
|
||||
return False
|
||||
except ValueError:
|
||||
#Invalid date format
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def check_for_updates(self, force: bool = False) -> None:
|
||||
"""Start the network request"""
|
||||
self._force_check = force
|
||||
|
||||
if not force and not self.should_check_for_updates():
|
||||
self.no_update_available.emit()
|
||||
return
|
||||
|
||||
logging.info("Checking for updates...")
|
||||
|
||||
url = QUrl(self.VERSION_JSON_URL)
|
||||
request = QNetworkRequest(url)
|
||||
self.network_manager.get(request)
|
||||
|
||||
def _on_reply_finished(self, reply: QNetworkReply) -> None:
|
||||
"""Handles the network reply for version.json fetch"""
|
||||
self.settings.set_value(
|
||||
Settings.Key.LAST_UPDATE_CHECK,
|
||||
datetime.now().isoformat()
|
||||
)
|
||||
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
error_msg = f"Failed to check for updates: {reply.errorString()}"
|
||||
logging.error(error_msg)
|
||||
self.check_failed.emit(error_msg)
|
||||
reply.deleteLater()
|
||||
return
|
||||
|
||||
try:
|
||||
data = json.loads(reply.readAll().data().decode("utf-8"))
|
||||
reply.deleteLater()
|
||||
|
||||
remote_version = data.get("version", "")
|
||||
release_notes = data.get("release_notes", "")
|
||||
download_urls = data.get("download_urls", {})
|
||||
|
||||
#Get the download url for current platform
|
||||
download_url = self._get_download_url(download_urls)
|
||||
|
||||
if self._is_newer_version(remote_version):
|
||||
logging.info(f"Update available: {remote_version}")
|
||||
|
||||
#Store the available version
|
||||
self.settings.set_value(
|
||||
Settings.Key.UPDATE_AVAILABLE_VERSION,
|
||||
remote_version
|
||||
)
|
||||
|
||||
update_info = UpdateInfo(
|
||||
version=remote_version,
|
||||
release_notes=release_notes,
|
||||
download_url=download_url
|
||||
)
|
||||
self.update_available.emit(update_info)
|
||||
|
||||
else:
|
||||
logging.info("No update available")
|
||||
self.settings.set_value(
|
||||
Settings.Key.UPDATE_AVAILABLE_VERSION,
|
||||
""
|
||||
)
|
||||
self.no_update_available.emit()
|
||||
|
||||
except (json.JSONDecodeError, KeyError) as e:
|
||||
error_msg = f"Failed to parse version info: {e}"
|
||||
logging.error(error_msg)
|
||||
self.check_failed.emit(error_msg)
|
||||
|
||||
def _get_download_url(self, download_urls: dict) -> str:
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "Windows":
|
||||
return download_urls.get("windows_x64", "")
|
||||
elif system == "Darwin":
|
||||
if machine in ("arm64", "aarch64"):
|
||||
return download_urls.get("macos_arm", "")
|
||||
else:
|
||||
return download_urls.get("macos_x86", "")
|
||||
|
||||
return ""
|
||||
|
||||
def _is_newer_version(self, remote_version: str) -> bool:
|
||||
"""Compare remote version with current version"""
|
||||
try:
|
||||
current_parts = [int(x) for x in VERSION.split(".")]
|
||||
remote_parts = [int(x) for x in remote_version.split(".")]
|
||||
|
||||
#pad with zeros if needed
|
||||
while len(current_parts) < len(remote_parts):
|
||||
current_parts.append(0)
|
||||
while len(remote_parts) < len(current_parts):
|
||||
remote_parts.append(0)
|
||||
|
||||
return remote_parts > current_parts
|
||||
|
||||
except ValueError:
|
||||
logging.error(f"Invalid version format: {VERSION} or {remote_version}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -129,3 +129,4 @@ ADD_ICON_PATH = get_path("assets/add_FILL0_wght700_GRAD0_opsz48.svg")
|
|||
URL_ICON_PATH = get_path("assets/url.svg")
|
||||
TRASH_ICON_PATH = get_path("assets/delete_FILL0_wght700_GRAD0_opsz48.svg")
|
||||
CANCEL_ICON_PATH = get_path("assets/cancel_FILL0_wght700_GRAD0_opsz48.svg")
|
||||
UPDATE_ICON_PATH = get_path("assets/update_FILL0_wght700_GRAD0_opsz48.svg")
|
||||
|
|
@ -24,6 +24,8 @@ from buzz.db.service.transcription_service import TranscriptionService
|
|||
from buzz.file_transcriber_queue_worker import FileTranscriberQueueWorker
|
||||
from buzz.locale import _
|
||||
from buzz.settings.settings import APP_NAME, Settings
|
||||
from buzz.update_checker import UpdateChecker, UpdateInfo
|
||||
from buzz.widgets.update_dialog import UpdateDialog
|
||||
from buzz.settings.shortcuts import Shortcuts
|
||||
from buzz.store.keyring_store import set_password, Key
|
||||
from buzz.transcriber.transcriber import (
|
||||
|
|
@ -70,6 +72,9 @@ class MainWindow(QMainWindow):
|
|||
self.quit_on_complete = False
|
||||
self.transcription_service = transcription_service
|
||||
|
||||
#update checker
|
||||
self._update_info: Optional[UpdateInfo] = None
|
||||
|
||||
self.toolbar = MainWindowToolbar(shortcuts=self.shortcuts, parent=self)
|
||||
self.toolbar.new_transcription_action_triggered.connect(
|
||||
self.on_new_transcription_action_triggered
|
||||
|
|
@ -87,6 +92,7 @@ class MainWindow(QMainWindow):
|
|||
self.on_stop_transcription_action_triggered
|
||||
)
|
||||
self.addToolBar(self.toolbar)
|
||||
self.toolbar.update_action_triggered.connect(self.on_update_action_triggered)
|
||||
self.setUnifiedTitleAndToolBarOnMac(True)
|
||||
|
||||
self.preferences = self.load_preferences(settings=self.settings)
|
||||
|
|
@ -156,6 +162,9 @@ class MainWindow(QMainWindow):
|
|||
|
||||
self.transcription_viewer_widget = None
|
||||
|
||||
#Initialize and run update checker
|
||||
self._init_update_checker()
|
||||
|
||||
def on_preferences_changed(self, preferences: Preferences):
|
||||
self.preferences = preferences
|
||||
self.save_preferences(preferences)
|
||||
|
|
@ -493,3 +502,33 @@ class MainWindow(QMainWindow):
|
|||
self.setBaseSize(1240, 600)
|
||||
self.resize(1240, 600)
|
||||
self.settings.end_group()
|
||||
|
||||
def _init_update_checker(self):
|
||||
"""Initializes and runs the update checker."""
|
||||
self.update_checker = UpdateChecker(settings=self.settings, parent=self)
|
||||
self.update_checker.update_available.connect(self._on_update_available)
|
||||
self.update_checker.check_failed.connect(self._on_update_check_failed)
|
||||
|
||||
# Check for updates on startup
|
||||
self.update_checker.check_for_updates()
|
||||
|
||||
def _on_update_available(self, update_info: UpdateInfo):
|
||||
"""Called when an update is available."""
|
||||
logging.info(f"Update available: {update_info.version}")
|
||||
self._update_info = update_info
|
||||
self.toolbar.set_update_available(True)
|
||||
|
||||
def _on_update_check_failed(self, error: str):
|
||||
"""Called when update check fails."""
|
||||
logging.warning(f"Update check failed: {error}")
|
||||
|
||||
def on_update_action_triggered(self):
|
||||
"""Called when user clicks the update action in toolbar."""
|
||||
if self._update_info is None:
|
||||
return
|
||||
|
||||
dialog = UpdateDialog(
|
||||
update_info=self._update_info,
|
||||
parent=self
|
||||
)
|
||||
dialog.exec()
|
||||
|
|
@ -16,6 +16,7 @@ from buzz.widgets.icon import (
|
|||
EXPAND_ICON_PATH,
|
||||
CANCEL_ICON_PATH,
|
||||
TRASH_ICON_PATH,
|
||||
UPDATE_ICON_PATH,
|
||||
)
|
||||
from buzz.widgets.recording_transcriber_widget import RecordingTranscriberWidget
|
||||
from buzz.widgets.toolbar import ToolBar
|
||||
|
|
@ -26,6 +27,7 @@ class MainWindowToolbar(ToolBar):
|
|||
new_url_transcription_action_triggered: pyqtSignal
|
||||
open_transcript_action_triggered: pyqtSignal
|
||||
clear_history_action_triggered: pyqtSignal
|
||||
update_action_triggered: pyqtSignal
|
||||
ICON_LIGHT_THEME_BACKGROUND = "#555"
|
||||
ICON_DARK_THEME_BACKGROUND = "#AAA"
|
||||
|
||||
|
|
@ -70,6 +72,13 @@ class MainWindowToolbar(ToolBar):
|
|||
self.clear_history_action = Action(
|
||||
Icon(TRASH_ICON_PATH, self), _("Clear History"), self
|
||||
)
|
||||
|
||||
self.update_action = Action(
|
||||
Icon(UPDATE_ICON_PATH, self), _("Update Available"), self
|
||||
)
|
||||
self.update_action_triggered = self.update_action.triggered
|
||||
self.update_action.setVisible(False)
|
||||
|
||||
self.clear_history_action_triggered = self.clear_history_action.triggered
|
||||
self.clear_history_action.setDisabled(True)
|
||||
|
||||
|
|
@ -86,6 +95,10 @@ class MainWindowToolbar(ToolBar):
|
|||
self.clear_history_action,
|
||||
]
|
||||
)
|
||||
|
||||
self.addSeparator()
|
||||
self.addAction(self.update_action)
|
||||
|
||||
self.setMovable(False)
|
||||
self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
|
||||
|
||||
|
|
@ -114,3 +127,7 @@ class MainWindowToolbar(ToolBar):
|
|||
|
||||
def set_clear_history_action_enabled(self, enabled: bool):
|
||||
self.clear_history_action.setEnabled(enabled)
|
||||
|
||||
def set_update_available(self, available: bool):
|
||||
"""Shows or hides the update action in the toolbar."""
|
||||
self.update_action.setVisible(available)
|
||||
|
|
|
|||
273
buzz/widgets/update_dialog.py
Normal file
273
buzz/widgets/update_dialog.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtCore import Qt, QUrl
|
||||
from PyQt6.QtGui import QIcon
|
||||
from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QProgressBar,
|
||||
QMessageBox,
|
||||
QWidget,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
from buzz.__version__ import VERSION
|
||||
from buzz.locale import _
|
||||
from buzz.update_checker import UpdateInfo
|
||||
from buzz.widgets.icon import BUZZ_ICON_PATH
|
||||
|
||||
class UpdateDialog(QDialog):
|
||||
"""Dialog shows when an update is available"""
|
||||
def __init__(
|
||||
self,
|
||||
update_info: UpdateInfo,
|
||||
network_manager: Optional[QNetworkAccessManager] = None,
|
||||
parent: Optional[QWidget] = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.update_info = update_info
|
||||
|
||||
if network_manager is None:
|
||||
network_manager = QNetworkAccessManager(self)
|
||||
self.network_manager = network_manager
|
||||
|
||||
self._download_reply: Optional[QNetworkReply] = None
|
||||
self._temp_file_path: Optional[str] = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
|
||||
def _setup_ui(self):
|
||||
self.setWindowTitle(_("Update Available"))
|
||||
self.setWindowIcon(QIcon(BUZZ_ICON_PATH))
|
||||
self.setMinimumWidth(450)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
#header
|
||||
header_label = QLabel(
|
||||
_("A new version of Buzz is available!")
|
||||
)
|
||||
|
||||
header_label.setStyleSheet("font-size: 16px; font-weight: bold;")
|
||||
layout.addWidget(header_label)
|
||||
|
||||
#Version info
|
||||
version_layout = QHBoxLayout()
|
||||
|
||||
current_version_label = QLabel(_("Current version:"))
|
||||
current_version_value = QLabel(f"<b>{VERSION}</b>")
|
||||
|
||||
new_version_label = QLabel(_("New version:"))
|
||||
new_version_value = QLabel(f"<b>{self.update_info.version}</b>")
|
||||
new_version_value.setStyleSheet("color: green;")
|
||||
|
||||
version_layout.addWidget(current_version_label)
|
||||
version_layout.addWidget(current_version_value)
|
||||
version_layout.addStretch()
|
||||
version_layout.addWidget(new_version_label)
|
||||
version_layout.addWidget(new_version_value)
|
||||
|
||||
layout.addLayout(version_layout)
|
||||
|
||||
#Release notes
|
||||
if self.update_info.release_notes:
|
||||
notes_label = QLabel(_("Release Notes:"))
|
||||
notes_label.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(notes_label)
|
||||
|
||||
notes_text = QTextEdit()
|
||||
notes_text.setReadOnly(True)
|
||||
notes_text.setMarkdown(self.update_info.release_notes)
|
||||
notes_text.setMaximumHeight(150)
|
||||
layout.addWidget(notes_text)
|
||||
|
||||
#progress bar
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
self.progress_bar.setTextVisible(True)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
#Status label
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
#Buttons
|
||||
button_layout = QVBoxLayout()
|
||||
|
||||
self.download_button = QPushButton(_("Download and Install"))
|
||||
self.download_button.clicked.connect(self._on_download_clicked)
|
||||
self.download_button.setDefault(True)
|
||||
|
||||
self.cancel_button = QPushButton(_("Later"))
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.download_button)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def _on_download_clicked(self):
|
||||
"""Starts downloading the installer"""
|
||||
if not self.update_info.download_url:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
_("Error"),
|
||||
_("No download URL available for your platform.")
|
||||
)
|
||||
return
|
||||
|
||||
self.download_button.setEnabled(False)
|
||||
self.cancel_button.setText(_("Cancel"))
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setValue(0)
|
||||
self.status_label.setText(_("Downloading..."))
|
||||
|
||||
url = QUrl(self.update_info.download_url)
|
||||
request = QNetworkRequest(url)
|
||||
|
||||
self._download_reply = self.network_manager.get(request)
|
||||
self._download_reply.downloadProgress.connect(self._on_download_progress)
|
||||
self._download_reply.finished.connect(self._on_download_finished)
|
||||
|
||||
def _on_download_progress(self, bytes_received: int, bytes_total: int):
|
||||
"""Update the progress bar during download"""
|
||||
if bytes_total > 0:
|
||||
progress = int((bytes_received / bytes_total) * 100)
|
||||
self.progress_bar.setValue(progress)
|
||||
|
||||
#show size info
|
||||
mb_received = bytes_received / (1024 * 1024)
|
||||
mb_total = bytes_total / (1024 * 1024)
|
||||
self.status_label.setText(
|
||||
_("Downloading... {:.1f} MB / {:.1f} MB").format(mb_received, mb_total)
|
||||
)
|
||||
|
||||
def _on_download_finished(self):
|
||||
"""Handles download completion"""
|
||||
if self._download_reply is None:
|
||||
return
|
||||
|
||||
if self._download_reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
error_msg = self._download_reply.errorString()
|
||||
logging.error(f"Download failed: {error_msg}")
|
||||
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
_("Download Failed"),
|
||||
_("Failed to download the update: {}").format(error_msg)
|
||||
)
|
||||
|
||||
self._reset_ui()
|
||||
self._download_reply.deleteLater()
|
||||
self._download_reply = None
|
||||
return
|
||||
|
||||
#save to temp file
|
||||
data = self._download_reply.readAll().data()
|
||||
self._download_reply.deleteLater()
|
||||
self._download_reply = None
|
||||
|
||||
#determine file extension based on platform
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
suffix = ".exe"
|
||||
elif system == "Darwin":
|
||||
suffix = ".dmg"
|
||||
else:
|
||||
suffix = ""
|
||||
|
||||
try:
|
||||
#temp file
|
||||
fd, self._temp_file_path = tempfile.mkstemp(suffix=suffix)
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
logging.info(f"Installer saved to: {self._temp_file_path}")
|
||||
|
||||
self.progress_bar.setValue(100)
|
||||
self.status_label.setText(_("Download complete!"))
|
||||
|
||||
#run the installer
|
||||
self._run_installer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to save installer: {e}")
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
_("Error"),
|
||||
_("Failed to save the installer: {}").format(str(e))
|
||||
)
|
||||
self._reset_ui()
|
||||
|
||||
|
||||
def _run_installer(self):
|
||||
"""Run the downloaded installer"""
|
||||
if not self._temp_file_path:
|
||||
return
|
||||
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == "Windows":
|
||||
self.status_label.setText(_("Launching installer..."))
|
||||
subprocess.Popen([self._temp_file_path], shell=True)
|
||||
self.accept()
|
||||
|
||||
elif system == "Darwin":
|
||||
#open the DMG file
|
||||
self.status_label.setText(_("Opening disk image..."))
|
||||
subprocess.Popen(["open", self._temp_file_path])
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
_("Install Update"),
|
||||
_("The disk image has been opened. Please drag Buzz to your Applications folder to complete the update.")
|
||||
)
|
||||
self.accept()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to run installer: {e}")
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
_("Error"),
|
||||
_("Failed to run the installer: {}").format(str(e))
|
||||
)
|
||||
|
||||
|
||||
def _reset_ui(self):
|
||||
"""Reset the UI to initial state after an error"""
|
||||
self.download_button.setEnabled(True)
|
||||
self.cancel_button.setText(_("Later"))
|
||||
self.progress_bar.setVisible(False)
|
||||
self.status_label.setText("")
|
||||
|
||||
|
||||
def reject(self):
|
||||
"""Cancel download in progress when user clicks Cancel or Later"""
|
||||
if self._download_reply is not None:
|
||||
self._download_reply.abort()
|
||||
self._download_reply.deleteLater()
|
||||
self._download_reply = None
|
||||
|
||||
super().reject()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue