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",