diff --git a/pyproject.toml b/pyproject.toml index 4f06cf884..92032439d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "typing_extensions~=4.13", "ujson~=5.10", "wcmatch==10.*", + "requests~=2.31.0", ] [project.optional-dependencies] diff --git a/src/tagstudio/core/ts_core.py b/src/tagstudio/core/ts_core.py index 19aebae88..19fd2d470 100644 --- a/src/tagstudio/core/ts_core.py +++ b/src/tagstudio/core/ts_core.py @@ -5,17 +5,22 @@ """The core classes and methods of TagStudio.""" import json +import re from pathlib import Path +import requests import structlog from tagstudio.core.constants import TS_FOLDER_NAME from tagstudio.core.library.alchemy.fields import FieldID from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap logger = structlog.get_logger(__name__) +MOST_RECENT_RELEASE_VERSION: str | None = None + class TagStudioCore: def __init__(self): @@ -101,11 +106,11 @@ def match_conditions(cls, lib: Library, entry_id: int) -> bool: """Match defined conditions against a file to add Entry data.""" # TODO - what even is this file format? # TODO: Make this stored somewhere better instead of temporarily in this JSON file. - cond_file = lib.library_dir / TS_FOLDER_NAME / "conditions.json" + cond_file = unwrap(lib.library_dir) / TS_FOLDER_NAME / "conditions.json" if not cond_file.is_file(): return False - entry: Entry = lib.get_entry(entry_id) + entry: Entry = unwrap(lib.get_entry(entry_id)) try: with open(cond_file, encoding="utf8") as f: @@ -130,7 +135,9 @@ def match_conditions(cls, lib: Library, entry_id: int) -> bool: is_new = field["id"] not in entry_field_types field_key = field["id"] if is_new: - lib.add_field_to_entry(entry.id, field_key, field["value"]) + lib.add_field_to_entry( + entry.id, field_id=field_key, value=field["value"] + ) else: lib.update_entry_field(entry.id, field_key, field["value"]) @@ -181,3 +188,23 @@ def _build_instagram_url(cls, entry: Entry): except Exception: logger.exception("Error building Instagram URL.", entry=entry) return "" + + @staticmethod + def get_most_recent_release_version() -> str | None: + """Get the version of the most recent Github release.""" + global MOST_RECENT_RELEASE_VERSION + if MOST_RECENT_RELEASE_VERSION is not None: + return MOST_RECENT_RELEASE_VERSION + + resp = requests.get("https://api.github.com/repos/TagStudioDev/TagStudio/releases/latest") + assert resp.status_code == 200, "Could not fetch information on latest release." + + data = resp.json() + tag: str = data["tag_name"] + assert tag.startswith("v") + + version = tag[1:] + assert re.match(r"^\d+\.\d+\.\d+(-\w+)?$", version) is not None, "Invalid version format." + + MOST_RECENT_RELEASE_VERSION = version + return version diff --git a/src/tagstudio/core/utils/str_formatting.py b/src/tagstudio/core/utils/str_formatting.py index e4a870cc6..e399a9e20 100644 --- a/src/tagstudio/core/utils/str_formatting.py +++ b/src/tagstudio/core/utils/str_formatting.py @@ -2,6 +2,10 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +import re + +from tagstudio.core.utils.types import unwrap + def strip_punctuation(string: str) -> str: """Returns a given string stripped of all punctuation characters.""" @@ -32,3 +36,25 @@ def strip_web_protocol(string: str) -> str: for prefix in prefixes: string = string.removeprefix(prefix) return string + + +def is_version_outdated(current, latest) -> bool: + regex = re.compile(r"^(\d+)\.(\d+)\.(\d+)(-\w+)?$") + mcurrent = unwrap(regex.match(current)) + mlatest = unwrap(regex.match(latest)) + + return ( + int(mlatest[1]) > int(mcurrent[1]) + or (mlatest[1] == mcurrent[1] and int(mlatest[2]) > int(mcurrent[2])) + or ( + mlatest[1] == mcurrent[1] + and mlatest[2] == mcurrent[2] + and int(mlatest[3]) > int(mcurrent[3]) + ) + or ( + mlatest[1] == mcurrent[1] + and mlatest[2] == mcurrent[2] + and mlatest[3] == mcurrent[3] + and (mlatest[4] is None and mcurrent[4] is not None) + ) + ) diff --git a/src/tagstudio/qt/controllers/out_of_date_message_box.py b/src/tagstudio/qt/controllers/out_of_date_message_box.py new file mode 100644 index 000000000..5254e57e1 --- /dev/null +++ b/src/tagstudio/qt/controllers/out_of_date_message_box.py @@ -0,0 +1,39 @@ +import structlog +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QMessageBox + +from tagstudio.core.constants import VERSION +from tagstudio.core.ts_core import TagStudioCore +from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.translations import Translations + +logger = structlog.get_logger(__name__) + + +class OutOfDateMessageBox(QMessageBox): + """A warning dialog for if the TagStudio is not running under the latest release version.""" + + def __init__(self): + super().__init__() + + title = Translations.format("version_modal.title") + self.setWindowTitle(title) + self.setIcon(QMessageBox.Icon.Warning) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + self.setStandardButtons( + QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Cancel + ) + self.setDefaultButton(QMessageBox.StandardButton.Ignore) + # Enables the cancel button but hides it to allow for click X to close dialog + self.button(QMessageBox.StandardButton.Cancel).hide() + + red = get_ui_color(ColorType.PRIMARY, UiColor.RED) + green = get_ui_color(ColorType.PRIMARY, UiColor.GREEN) + latest_release_version = TagStudioCore.get_most_recent_release_version() + status = Translations.format( + "version_modal.status", + installed_version=f"{VERSION}", + latest_release_version=f"{latest_release_version}", + ) + self.setText(f"{Translations['version_modal.description']}

{status}") diff --git a/src/tagstudio/qt/mixed/about_modal.py b/src/tagstudio/qt/mixed/about_modal.py index e7d4a2ecb..12942df95 100644 --- a/src/tagstudio/qt/mixed/about_modal.py +++ b/src/tagstudio/qt/mixed/about_modal.py @@ -20,6 +20,7 @@ from tagstudio.core.constants import VERSION, VERSION_BRANCH from tagstudio.core.enums import Theme +from tagstudio.core.ts_core import TagStudioCore from tagstudio.qt.models.palette import ColorType, UiColor, get_ui_color from tagstudio.qt.previews.vendored import ffmpeg from tagstudio.qt.resource_manager import ResourceManager @@ -103,6 +104,19 @@ def __init__(self, config_path): self.system_info_layout = QFormLayout(self.system_info_widget) self.system_info_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight) + # Version + version_title = QLabel("Version") + most_recent_release = TagStudioCore.get_most_recent_release_version() + version_content_style = self.form_content_style + if most_recent_release == VERSION: + version_content = QLabel(f"{VERSION}") + else: + version_content = QLabel(f"{VERSION} (Latest Release: {most_recent_release})") + version_content_style += "color: #d9534f;" + version_content.setStyleSheet(version_content_style) + version_content.setMaximumWidth(version_content.sizeHint().width()) + self.system_info_layout.addRow(version_title, version_content) + # License license_title = QLabel(f"{Translations['about.license']}") license_content = QLabel("GPLv3") diff --git a/src/tagstudio/qt/ts_qt.py b/src/tagstudio/qt/ts_qt.py index 8d7edde30..612ca7511 100644 --- a/src/tagstudio/qt/ts_qt.py +++ b/src/tagstudio/qt/ts_qt.py @@ -62,7 +62,7 @@ from tagstudio.core.media_types import MediaCategories from tagstudio.core.query_lang.util import ParsingError from tagstudio.core.ts_core import TagStudioCore -from tagstudio.core.utils.str_formatting import strip_web_protocol +from tagstudio.core.utils.str_formatting import is_version_outdated, strip_web_protocol from tagstudio.core.utils.types import unwrap from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.controllers.ffmpeg_missing_message_box import FfmpegMissingMessageBox @@ -71,6 +71,7 @@ from tagstudio.qt.controllers.fix_ignored_modal_controller import FixIgnoredEntriesModal from tagstudio.qt.controllers.ignore_modal_controller import IgnoreModal from tagstudio.qt.controllers.library_info_window_controller import LibraryInfoWindow +from tagstudio.qt.controllers.out_of_date_message_box import OutOfDateMessageBox from tagstudio.qt.global_settings import ( DEFAULT_GLOBAL_SETTINGS_PATH, GlobalSettings, @@ -590,6 +591,9 @@ def create_about_modal(): if not which(FFMPEG_CMD) or not which(FFPROBE_CMD): FfmpegMissingMessageBox().show() + if is_version_outdated(VERSION, TagStudioCore.get_most_recent_release_version()): + OutOfDateMessageBox().exec() + self.app.exec() self.shutdown() diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index edda02311..89fb04379 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -348,6 +348,9 @@ "trash.dialog.title.singular": "Delete File", "trash.name.generic": "Trash", "trash.name.windows": "Recycle Bin", + "version_modal.title": "TagStudio Update Available", + "version_modal.description": "A new version of TagStudio is available! You can download the latest release from Github.", + "version_modal.status": "Installed Version: {installed_version}
Latest Release Version: {latest_release_version}", "view.size.0": "Mini", "view.size.1": "Small", "view.size.2": "Medium",