diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py
index ca8c0951eb..186bbe8c03 100644
--- a/src/Mod/AddonManager/Addon.py
+++ b/src/Mod/AddonManager/Addon.py
@@ -25,6 +25,7 @@
import os
import re
+from datetime import datetime
from urllib.parse import urlparse
from typing import Dict, Set, List, Optional
from threading import Lock
@@ -176,14 +177,7 @@ class Addon:
self.status_lock = Lock()
self.update_status = status
- # The url should never end in ".git", so strip it if it's there
- parsed_url = urlparse(self.url)
- if parsed_url.path.endswith(".git"):
- self.url = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4]
- if parsed_url.query:
- self.url += "?" + parsed_url.query
- if parsed_url.fragment:
- self.url += "#" + parsed_url.fragment
+ self._clean_url()
if utils.recognized_git_location(self):
self.metadata_url = construct_git_url(self, "package.xml")
@@ -210,6 +204,17 @@ class Addon:
self._icon_file = None
self._cached_license: str = ""
+ self._cached_update_date = None
+
+ def _clean_url(self):
+ # The url should never end in ".git", so strip it if it's there
+ parsed_url = urlparse(self.url)
+ if parsed_url.path.endswith(".git"):
+ self.url = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path[:-4]
+ if parsed_url.query:
+ self.url += "?" + parsed_url.query
+ if parsed_url.fragment:
+ self.url += "#" + parsed_url.fragment
def __str__(self) -> str:
result = f"FreeCAD {self.repo_type}\n"
@@ -235,6 +240,59 @@ class Addon:
self._cached_license = "CC-BY-3.0"
return self._cached_license
+ @property
+ def update_date(self):
+ if self._cached_update_date is None:
+ self._cached_update_date = 0
+ if self.stats and self.stats.last_update_time:
+ self._cached_update_date = self.stats.last_update_time
+ elif self.macro and self.macro.date:
+ # Try to parse the date:
+ try:
+ self._cached_update_date = self._process_date_string_to_python_datetime(
+ self.macro.date
+ )
+ except SyntaxError as e:
+ fci.Console.PrintWarning(str(e) + "\n")
+ else:
+ fci.Console.PrintWarning(f"No update date info for {self.name}\n")
+ return self._cached_update_date
+
+ def _process_date_string_to_python_datetime(self, date_string: str) -> datetime:
+ split_result = re.split(r"[ ./-]+", date_string.strip())
+ print(f"{self.display_name} - {split_result}")
+ if len(split_result) != 3:
+ raise SyntaxError(
+ f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"
+ )
+
+ if int(split_result[0]) > 2000: # Assume YYYY-MM-DD
+ try:
+ year = int(split_result[0])
+ month = int(split_result[1])
+ day = int(split_result[2])
+ return datetime(year, month, day)
+ except (OverflowError, OSError, ValueError):
+ raise SyntaxError(
+ f"In macro {self.name}, unrecognized date string {date_string} (expected YYYY-MM-DD)"
+ )
+ elif int(split_result[2]) > 2000:
+ # Two possibilities, impossible to distinguish in the general case: DD-MM-YYYY and
+ # MM-DD-YYYY. See if the first one makes sense, and if not, try the second
+ if int(split_result[1]) <= 12:
+ year = int(split_result[2])
+ month = int(split_result[1])
+ day = int(split_result[0])
+ else:
+ year = int(split_result[2])
+ month = int(split_result[0])
+ day = int(split_result[1])
+ return datetime(year, month, day)
+ else:
+ raise SyntaxError(
+ f"In macro {self.name}, unrecognized date string '{date_string}' (expected YYYY-MM-DD)"
+ )
+
@classmethod
def from_macro(cls, macro: Macro):
"""Create an Addon object from a Macro wrapper object"""
@@ -262,7 +320,8 @@ class Addon:
instance = Addon(cache_dict["name"], cache_dict["url"], status, cache_dict["branch"])
for key, value in cache_dict.items():
- instance.__dict__[key] = value
+ if not str(key).startswith("_"):
+ instance.__dict__[key] = value
instance.repo_type = Addon.Kind(cache_dict["repo_type"])
if instance.repo_type == Addon.Kind.PACKAGE:
@@ -283,6 +342,8 @@ class Addon:
instance.python_requires = set(cache_dict["python_requires"])
instance.python_optional = set(cache_dict["python_optional"])
+ instance._clean_url()
+
return instance
def to_cache(self) -> Dict:
@@ -313,6 +374,7 @@ class Addon:
if os.path.exists(file):
metadata = MetadataReader.from_file(file)
self.set_metadata(metadata)
+ self._clean_url()
else:
fci.Console.PrintLog(f"Internal error: {file} does not exist")
@@ -338,6 +400,7 @@ class Addon:
if url.type == UrlType.repository:
self.url = url.location
self.branch = url.branch if url.branch else "master"
+ self._clean_url()
self.extract_tags(self.metadata)
self.extract_metadata_dependencies(self.metadata)
diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index ee73ccc8de..f5c9f51788 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -42,6 +42,7 @@ from addonmanager_workers_startup import (
LoadMacrosFromCacheWorker,
CheckWorkbenchesForUpdatesWorker,
CacheMacroCodeWorker,
+ GetBasicAddonStatsWorker,
)
from addonmanager_workers_installation import (
UpdateMetadataCacheWorker,
@@ -50,12 +51,14 @@ from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
from addonmanager_uninstaller_gui import AddonUninstallerGUI
from addonmanager_update_all_gui import UpdateAllGUI
import addonmanager_utilities as utils
+import addonmanager_freecad_interface as fci
import AddonManager_rc # This is required by Qt, it's not unused
from package_list import PackageList, PackageListItemModel
from addonmanager_package_details_controller import PackageDetailsController
from Widgets.addonmanager_widget_package_details_view import PackageDetailsView
from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar
from Addon import Addon
+from AddonStats import AddonStats
from manage_python_dependencies import (
PythonPackageManager,
)
@@ -115,6 +118,7 @@ class CommandAddonManager:
"load_macro_metadata_worker",
"update_all_worker",
"check_for_python_package_updates_worker",
+ "get_basic_addon_stats_worker",
]
lock = threading.Lock()
@@ -392,6 +396,7 @@ class CommandAddonManager:
self.update_metadata_cache,
self.check_updates,
self.check_python_updates,
+ self.fetch_addon_stats,
]
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
if pref.GetBool("DownloadMacros", False):
@@ -660,6 +665,23 @@ class CommandAddonManager:
self.manage_python_packages_dialog = PythonPackageManager(self.item_model.repos)
self.manage_python_packages_dialog.show()
+ def fetch_addon_stats(self) -> None:
+ """Fetch the Addon Stats JSON data from a URL"""
+ pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
+ url = pref.GetString("AddonsStatsURL", "https://freecad.org/addon_stats.json")
+ if url and url != "NONE":
+ self.get_basic_addon_stats_worker = GetBasicAddonStatsWorker(
+ url, self.item_model.repos, self.dialog
+ )
+ self.get_basic_addon_stats_worker.finished.connect(self.do_next_startup_phase)
+ self.get_basic_addon_stats_worker.update_addon_stats.connect(self.update_addon_stats)
+ self.get_basic_addon_stats_worker.start()
+ else:
+ self.do_next_startup_phase()
+
+ def update_addon_stats(self, addon: Addon):
+ self.item_model.reload_item(addon)
+
def show_developer_tools(self) -> None:
"""Display the developer tools dialog"""
if not self.developer_mode:
diff --git a/src/Mod/AddonManager/AddonStats.py b/src/Mod/AddonManager/AddonStats.py
index 60eee8ae5f..60c329dcc8 100644
--- a/src/Mod/AddonManager/AddonStats.py
+++ b/src/Mod/AddonManager/AddonStats.py
@@ -26,6 +26,9 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
+from typing import Optional
+
+import addonmanager_freecad_interface as fci
def to_int_or_zero(inp: [str | int | None]):
@@ -35,11 +38,24 @@ def to_int_or_zero(inp: [str | int | None]):
return 0
+def time_string_to_datetime(inp: str) -> Optional[datetime]:
+ try:
+ return datetime.fromisoformat(inp)
+ except ValueError:
+ try:
+ # Support for the trailing "Z" was added in Python 3.11 -- strip it and see if it works now
+ return datetime.fromisoformat(inp[:-1])
+ except ValueError:
+ fci.Console.PrintWarning(f"Unable to parse '{str}' as a Python datetime")
+ return None
+
+
@dataclass
class AddonStats:
"""Statistics about an addon: not all stats apply to all addon types"""
last_update_time: datetime | None = None
+ date_created: datetime | None = None
stars: int = 0
open_issues: int = 0
forks: int = 0
@@ -50,9 +66,16 @@ class AddonStats:
def from_json(cls, json_dict: dict):
new_stats = AddonStats()
if "pushed_at" in json_dict:
- new_stats.last_update_time = datetime.fromisoformat(json_dict["pushed_at"])
- new_stats.stars = to_int_or_zero(json_dict["stargazers_count"])
- new_stats.forks = to_int_or_zero(json_dict["forks_count"])
- new_stats.open_issues = to_int_or_zero(json_dict["open_issues_count"])
- new_stats.license = json_dict["license"] # Might be None or "NOASSERTION"
+ new_stats.last_update_time = time_string_to_datetime(json_dict["pushed_at"])
+ if "created_at" in json_dict:
+ new_stats.date_created = time_string_to_datetime(json_dict["created_at"])
+ if "stargazers_count" in json_dict:
+ new_stats.stars = to_int_or_zero(json_dict["stargazers_count"])
+ if "forks_count" in json_dict:
+ new_stats.forks = to_int_or_zero(json_dict["forks_count"])
+ if "open_issues_count" in json_dict:
+ new_stats.open_issues = to_int_or_zero(json_dict["open_issues_count"])
+ if "license" in json_dict:
+ if json_dict["license"] != "NOASSERTION" and json_dict["license"] != "None":
+ new_stats.license = json_dict["license"] # Might be None or "NOASSERTION"
return new_stats
diff --git a/src/Mod/AddonManager/Resources/AddonManager.qrc b/src/Mod/AddonManager/Resources/AddonManager.qrc
index a2a0b75b85..4327b5a46d 100644
--- a/src/Mod/AddonManager/Resources/AddonManager.qrc
+++ b/src/Mod/AddonManager/Resources/AddonManager.qrc
@@ -66,6 +66,8 @@
icons/compact_view.svg
icons/composite_view.svg
icons/expanded_view.svg
+ icons/sort_ascending.svg
+ icons/sort_descending.svg
licenses/Apache-2.0.txt
licenses/BSD-2-Clause.txt
licenses/BSD-3-Clause.txt
diff --git a/src/Mod/AddonManager/Resources/icons/sort_ascending.svg b/src/Mod/AddonManager/Resources/icons/sort_ascending.svg
new file mode 100644
index 0000000000..d1c399a150
--- /dev/null
+++ b/src/Mod/AddonManager/Resources/icons/sort_ascending.svg
@@ -0,0 +1,82 @@
+
+
+
+
diff --git a/src/Mod/AddonManager/Resources/icons/sort_descending.svg b/src/Mod/AddonManager/Resources/icons/sort_descending.svg
new file mode 100644
index 0000000000..265a58c20a
--- /dev/null
+++ b/src/Mod/AddonManager/Resources/icons/sort_descending.svg
@@ -0,0 +1,82 @@
+
+
+
+
diff --git a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py
index 46c0430e46..9db1bbb8ae 100644
--- a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py
+++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py
@@ -23,6 +23,8 @@
""" Defines a class derived from QWidget for displaying the bar at the top of the addons list. """
+from enum import IntEnum, auto
+
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
@@ -36,11 +38,31 @@ except ImportError:
PySide = PySide2
-from PySide import QtCore, QtWidgets
+from PySide import QtCore, QtGui, QtWidgets
from .addonmanager_widget_view_selector import WidgetViewSelector
from .addonmanager_widget_filter_selector import WidgetFilterSelector
from .addonmanager_widget_search import WidgetSearch
+translate = QtCore.QCoreApplication.translate
+
+
+class SortOptions(IntEnum):
+ _SortRoleOffset = 100
+ Alphabetical = QtCore.Qt.UserRole + _SortRoleOffset + 0
+ LastUpdated = QtCore.Qt.UserRole + _SortRoleOffset + 1
+ DateAdded = QtCore.Qt.UserRole + _SortRoleOffset + 2
+ Stars = QtCore.Qt.UserRole + _SortRoleOffset + 3
+ Rank = QtCore.Qt.UserRole + _SortRoleOffset + 4
+
+
+default_sort_order = {
+ SortOptions.Alphabetical: QtCore.Qt.AscendingOrder,
+ SortOptions.LastUpdated: QtCore.Qt.DescendingOrder,
+ SortOptions.DateAdded: QtCore.Qt.DescendingOrder,
+ SortOptions.Stars: QtCore.Qt.DescendingOrder,
+ SortOptions.Rank: QtCore.Qt.DescendingOrder,
+}
+
class WidgetViewControlBar(QtWidgets.QWidget):
"""A bar containing a view selection widget, a filter widget, and a search widget"""
@@ -48,28 +70,86 @@ class WidgetViewControlBar(QtWidgets.QWidget):
view_changed = QtCore.Signal(int)
filter_changed = QtCore.Signal(object)
search_changed = QtCore.Signal(str)
+ sort_changed = QtCore.Signal(int)
+ sort_order_changed = QtCore.Signal(QtCore.Qt.SortOrder)
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self._setup_ui()
self._setup_connections()
self.retranslateUi(None)
+ self.sort_order = QtCore.Qt.AscendingOrder
+ self._set_sort_order_icon()
def _setup_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
self.view_selector = WidgetViewSelector(self)
self.filter_selector = WidgetFilterSelector(self)
+ self.sort_selector = QtWidgets.QComboBox(self)
+ self.sort_order_button = QtWidgets.QToolButton(self)
+ self.sort_order_button.setIcon(
+ QtGui.QIcon.fromTheme("ascending", QtGui.QIcon(":/icons/sort_ascending.svg"))
+ )
self.search = WidgetSearch(self)
self.horizontal_layout.addWidget(self.view_selector)
self.horizontal_layout.addWidget(self.filter_selector)
+ self.horizontal_layout.addWidget(self.sort_selector)
+ self.horizontal_layout.addWidget(self.sort_order_button)
self.horizontal_layout.addWidget(self.search)
self.setLayout(self.horizontal_layout)
+ def _sort_order_clicked(self):
+ if self.sort_order == QtCore.Qt.AscendingOrder:
+ self.set_sort_order(QtCore.Qt.DescendingOrder)
+ else:
+ self.set_sort_order(QtCore.Qt.AscendingOrder)
+ self.sort_order_changed.emit(self.sort_order)
+
+ def set_sort_order(self, order: QtCore.Qt.SortOrder) -> None:
+ self.sort_order = order
+ self._set_sort_order_icon()
+
+ def _set_sort_order_icon(self):
+ if self.sort_order == QtCore.Qt.AscendingOrder:
+ self.sort_order_button.setIcon(
+ QtGui.QIcon.fromTheme(
+ "view-sort-ascending", QtGui.QIcon(":/icons/sort_ascending.svg")
+ )
+ )
+ else:
+ self.sort_order_button.setIcon(
+ QtGui.QIcon.fromTheme(
+ "view-sort-descending", QtGui.QIcon(":/icons/sort_descending.svg")
+ )
+ )
+
def _setup_connections(self):
self.view_selector.view_changed.connect(self.view_changed.emit)
self.filter_selector.filter_changed.connect(self.filter_changed.emit)
self.search.search_changed.connect(self.search_changed.emit)
+ self.sort_selector.currentIndexChanged.connect(self._sort_changed)
+ self.sort_order_button.clicked.connect(self._sort_order_clicked)
+
+ def _sort_changed(self, index: int):
+ sort_role = self.sort_selector.itemData(index)
+ self.set_sort_order(default_sort_order[sort_role])
+ self.sort_changed.emit(sort_role)
+ self.sort_order_changed.emit(self.sort_order)
def retranslateUi(self, _=None):
- pass
+ self.sort_selector.clear()
+ self.sort_selector.addItem(
+ translate("AddonsInstaller", "Alphabetical", "Sort order"), SortOptions.Alphabetical
+ )
+ self.sort_selector.addItem(
+ translate("AddonsInstaller", "Last Updated", "Sort order"), SortOptions.LastUpdated
+ )
+ self.sort_selector.addItem(
+ translate("AddonsInstaller", "Date Created", "Sort order"), SortOptions.DateAdded
+ )
+ self.sort_selector.addItem(
+ translate("AddonsInstaller", "GitHub Stars", "Sort order"), SortOptions.Stars
+ )
+ # self.sort_selector.addItem(translate("AddonsInstaller", "Rank", "Sort order"),
+ # SortOptions.Rank)
diff --git a/src/Mod/AddonManager/addonmanager_macro_parser.py b/src/Mod/AddonManager/addonmanager_macro_parser.py
index 5c77037574..26dc41bc26 100644
--- a/src/Mod/AddonManager/addonmanager_macro_parser.py
+++ b/src/Mod/AddonManager/addonmanager_macro_parser.py
@@ -22,12 +22,13 @@
# ***************************************************************************
"""Contains the parser class for extracting metadata from a FreeCAD macro"""
+import datetime
# pylint: disable=too-few-public-methods
import io
import re
-from typing import Any, Tuple
+from typing import Any, Tuple, Optional
try:
from PySide import QtCore
diff --git a/src/Mod/AddonManager/addonmanager_preferences_defaults.json b/src/Mod/AddonManager/addonmanager_preferences_defaults.json
index 07d16d0e15..c785c154a6 100644
--- a/src/Mod/AddonManager/addonmanager_preferences_defaults.json
+++ b/src/Mod/AddonManager/addonmanager_preferences_defaults.json
@@ -2,7 +2,7 @@
"AddonFlagsURL":
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json",
"AddonsRemoteCacheURL": "https://addons.freecad.org/metadata.zip",
- "AddonsUpdateStatsURL": "https://addons.freecad.org/addon_update_stats.json",
+ "AddonsStatsURL": "https://freecad.org/addon_stats.json",
"AutoCheck": false,
"BlockedMacros": "BOLTS,WorkFeatures,how to install,documentation,PartsLibrary,FCGear",
"CustomRepoHash": "",
diff --git a/src/Mod/AddonManager/addonmanager_workers_startup.py b/src/Mod/AddonManager/addonmanager_workers_startup.py
index e08bf2f98c..88fd0dd9df 100644
--- a/src/Mod/AddonManager/addonmanager_workers_startup.py
+++ b/src/Mod/AddonManager/addonmanager_workers_startup.py
@@ -41,6 +41,7 @@ import FreeCAD
import addonmanager_utilities as utils
from addonmanager_macro import Macro
from Addon import Addon
+from AddonStats import AddonStats
import NetworkManager
from addonmanager_git import initialize_git, GitFailed
from addonmanager_metadata import MetadataReader, get_branch_from_metadata
@@ -913,3 +914,34 @@ class GetMacroDetailsWorker(QtCore.QThread):
if QtCore.QThread.currentThread().isInterruptionRequested():
return
self.readme_updated.emit(message)
+
+
+class GetBasicAddonStatsWorker(QtCore.QThread):
+ """Fetch data from an addon stats repository."""
+
+ update_addon_stats = QtCore.Signal(Addon)
+
+ def __init__(self, url: str, addons: List[Addon], parent: QtCore.QObject = None):
+ super().__init__(parent)
+ self.url = url
+ self.addons = addons
+
+ def run(self):
+ """Fetch the remote data and load it into the addons"""
+
+ fetch_result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.url, 5000)
+ if fetch_result is None:
+ FreeCAD.Console.PrintError(
+ translate(
+ "AddonsInstaller",
+ "Failed to get Addon statistics from {} -- only sorting alphabetically will be accurate\n",
+ ).format(self.url)
+ )
+ return
+ text_result = fetch_result.data().decode("utf8")
+ json_result = json.loads(text_result)
+
+ for addon in self.addons:
+ if addon.url in json_result:
+ addon.stats = AddonStats.from_json(json_result[addon.url])
+ self.update_addon_stats.emit(addon)
diff --git a/src/Mod/AddonManager/expanded_view.py b/src/Mod/AddonManager/expanded_view.py
index f0275c7696..765878bb19 100644
--- a/src/Mod/AddonManager/expanded_view.py
+++ b/src/Mod/AddonManager/expanded_view.py
@@ -72,6 +72,11 @@ class Ui_ExpandedView(object):
self.horizontalLayout.addWidget(self.labelTags)
+ self.labelSort = QLabel(ExpandedView)
+ self.labelSort.setObjectName("labelSort")
+
+ self.horizontalLayout.addWidget(self.labelSort)
+
self.horizontalSpacer_2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
self.horizontalLayout.addItem(self.horizontalSpacer_2)
diff --git a/src/Mod/AddonManager/expanded_view.ui b/src/Mod/AddonManager/expanded_view.ui
index 3e5671a266..f0034601df 100644
--- a/src/Mod/AddonManager/expanded_view.ui
+++ b/src/Mod/AddonManager/expanded_view.ui
@@ -122,6 +122,13 @@
+ -
+
+
+ labelSort
+
+
+
-
diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py
index d7a4a128eb..1a413974e8 100644
--- a/src/Mod/AddonManager/package_list.py
+++ b/src/Mod/AddonManager/package_list.py
@@ -22,8 +22,7 @@
# ***************************************************************************
""" Defines the PackageList QWidget for displaying a list of Addons. """
-
-from enum import IntEnum
+import datetime
import threading
import FreeCAD
@@ -37,11 +36,11 @@ from expanded_view import Ui_ExpandedView
import addonmanager_utilities as utils
from addonmanager_metadata import get_first_supported_freecad_version, Version
-from Widgets.addonmanager_widget_view_control_bar import WidgetViewControlBar
+from Widgets.addonmanager_widget_view_control_bar import WidgetViewControlBar, SortOptions
from Widgets.addonmanager_widget_view_selector import AddonManagerDisplayStyle
-from Widgets.addonmanager_widget_filter_selector import StatusFilter, Filter, ContentFilter
+from Widgets.addonmanager_widget_filter_selector import StatusFilter, Filter
from Widgets.addonmanager_widget_progress_bar import WidgetProgressBar
-from addonmanager_licenses import get_license_manager, SPDXLicenseManager
+from addonmanager_licenses import get_license_manager
translate = FreeCAD.Qt.translate
@@ -69,6 +68,9 @@ class PackageList(QtWidgets.QWidget):
self.ui.view_bar.view_changed.connect(self.set_view_style)
self.ui.view_bar.filter_changed.connect(self.update_status_filter)
self.ui.view_bar.search_changed.connect(self.item_filter.setFilterRegularExpression)
+ self.ui.view_bar.sort_changed.connect(self.item_filter.setSortRole)
+ self.ui.view_bar.sort_changed.connect(self.item_delegate.set_sort)
+ self.ui.view_bar.sort_order_changed.connect(lambda order: self.item_filter.sort(0, order))
# Set up the view the same as the last time:
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
@@ -86,7 +88,8 @@ class PackageList(QtWidgets.QWidget):
"""This is a model-view-controller widget: set its model."""
self.item_model = model
self.item_filter.setSourceModel(self.item_model)
- self.item_filter.sort(0)
+ self.item_filter.setSortRole(SortOptions.Alphabetical)
+ self.item_filter.sort(0, QtCore.Qt.AscendingOrder)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
style = pref.GetInt("ViewStyle", AddonManagerDisplayStyle.EXPANDED)
@@ -141,8 +144,6 @@ class PackageListItemModel(QtCore.QAbstractListModel):
write_lock = threading.Lock()
DataAccessRole = QtCore.Qt.UserRole
- StatusUpdateRole = QtCore.Qt.UserRole + 1
- IconUpdateRole = QtCore.Qt.UserRole + 2
def rowCount(self, parent: QtCore.QModelIndex = QtCore.QModelIndex()) -> int:
"""The number of rows"""
@@ -179,30 +180,29 @@ class PackageListItemModel(QtCore.QAbstractListModel):
if role == PackageListItemModel.DataAccessRole:
return self.repos[row]
+ # Sorting
+ if role == SortOptions.Alphabetical:
+ return self.repos[row].display_name
+ if role == SortOptions.LastUpdated:
+ update_date = self.repos[row].update_date
+ if update_date and hasattr(update_date, "timestamp"):
+ return update_date.timestamp()
+ return 0
+ if role == SortOptions.DateAdded:
+ if self.repos[row].stats and self.repos[row].stats.date_created:
+ return self.repos[row].stats.date_created.timestamp()
+ return 0
+ if role == SortOptions.Stars:
+ if self.repos[row].stats and self.repos[row].stats.stars:
+ return self.repos[row].stats.stars
+ return 0
+ if role == SortOptions.Rank:
+ return len(self.repos[row].display_name)
+
def headerData(self, _unused1, _unused2, _role=QtCore.Qt.DisplayRole):
"""No header in this implementation: always returns None."""
return None
- def setData(self, index: QtCore.QModelIndex, value, role=QtCore.Qt.EditRole) -> None:
- """Set the data for this row. The column of the index is ignored."""
-
- row = index.row()
- with self.write_lock:
- if role == PackageListItemModel.StatusUpdateRole:
- self.repos[row].set_status(value)
- self.dataChanged.emit(
- self.index(row, 2),
- self.index(row, 2),
- [PackageListItemModel.StatusUpdateRole],
- )
- elif role == PackageListItemModel.IconUpdateRole:
- self.repos[row].icon = value
- self.dataChanged.emit(
- self.index(row, 0),
- self.index(row, 0),
- [PackageListItemModel.IconUpdateRole],
- )
-
def append_item(self, repo: Addon) -> None:
"""Adds this addon to the end of the model. Thread safe."""
if repo in self.repos:
@@ -221,20 +221,6 @@ class PackageListItemModel(QtCore.QAbstractListModel):
self.repos = []
self.endRemoveRows()
- def update_item_status(self, name: str, status: Addon.Status) -> None:
- """Set the status of addon with name to status."""
- for row, item in enumerate(self.repos):
- if item.name == name:
- self.setData(self.index(row, 0), status, PackageListItemModel.StatusUpdateRole)
- return
-
- def update_item_icon(self, name: str, icon: QtGui.QIcon) -> None:
- """Set the icon for Addon with name to icon"""
- for row, item in enumerate(self.repos):
- if item.name == name:
- self.setData(self.index(row, 0), icon, PackageListItemModel.IconUpdateRole)
- return
-
def reload_item(self, repo: Addon) -> None:
"""Sets the addon data for the given addon (based on its name)"""
for index, item in enumerate(self.repos):
@@ -268,6 +254,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
self.displayStyle = AddonManagerDisplayStyle.EXPANDED
+ self.sort_order = SortOptions.Alphabetical
self.expanded = ExpandedView()
self.compact = CompactView()
self.widget = self.expanded
@@ -277,6 +264,11 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
if not self.displayStyle == style:
self.displayStyle = style
+ def set_sort(self, sort: SortOptions) -> None:
+ """When sorting by various things, we display the thing that's being sorted on."""
+ if not self.sort_order == sort:
+ self.sort_order = sort
+
def sizeHint(self, _option, index):
"""Attempt to figure out the correct height for the widget based on its
current contents."""
@@ -288,40 +280,58 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
repo = index.data(PackageListItemModel.DataAccessRole)
if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget = self.expanded
- self.widget.ui.labelPackageName.setText(f"
{repo.display_name}
")
- self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(48, 48)))
- else:
+ self._setup_expanded_view(repo)
+ elif self.displayStyle == AddonManagerDisplayStyle.COMPACT:
self.widget = self.compact
- self.widget.ui.labelPackageName.setText(f"{repo.display_name}")
- self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(16, 16)))
-
- self.widget.ui.labelIcon.setText("")
- if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
- self.widget.ui.labelTags.setText("")
- if repo.metadata:
- self.widget.ui.labelDescription.setText(repo.metadata.description)
- self.widget.ui.labelVersion.setText(f"v{repo.metadata.version}")
- if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
- self._setup_expanded_package(repo)
- elif repo.macro and repo.macro.parsed:
- self._setup_macro(repo)
- else:
- self.widget.ui.labelDescription.setText("")
- self.widget.ui.labelVersion.setText("")
- if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
- self.widget.ui.labelMaintainer.setText("")
-
- # Update status
- if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
- self.widget.ui.labelStatus.setText(self.get_expanded_update_string(repo))
- else:
- self.widget.ui.labelStatus.setText(self.get_compact_update_string(repo))
-
+ self._setup_compact_view(repo)
self.widget.adjustSize()
- def _setup_expanded_package(self, repo: Addon):
- """Set up the display for a package in expanded view"""
- maintainers = repo.metadata.maintainer
+ def _setup_expanded_view(self, addon: Addon) -> None:
+ self.widget.ui.labelPackageName.setText(f"{addon.display_name}
")
+ self.widget.ui.labelIcon.setPixmap(addon.icon.pixmap(QtCore.QSize(48, 48)))
+ self.widget.ui.labelStatus.setText(self.get_expanded_update_string(addon))
+ self.widget.ui.labelIcon.setText("")
+ self.widget.ui.labelTags.setText("")
+ if addon.metadata:
+ self.widget.ui.labelDescription.setText(addon.metadata.description)
+ self.widget.ui.labelVersion.setText(f"v{addon.metadata.version}")
+ self._set_package_maintainer_label(addon)
+ elif addon.macro:
+ self.widget.ui.labelDescription.setText(addon.macro.comment)
+ self._set_macro_version_label(addon)
+ self._set_macro_maintainer_label(addon)
+ else:
+ self.widget.ui.labelDescription.setText("")
+ self.widget.ui.labelMaintainer.setText("")
+ self.widget.ui.labelVersion.setText("")
+ if addon.tags:
+ self.widget.ui.labelTags.setText(
+ translate("AddonsInstaller", "Tags") + ": " + ", ".join(addon.tags)
+ )
+ if self.sort_order == SortOptions.Alphabetical:
+ self.widget.ui.labelSort.setText("")
+ else:
+ self.widget.ui.labelSort.setText(self._get_sort_label_text(addon))
+
+ def _setup_compact_view(self, addon: Addon) -> None:
+ self.widget.ui.labelPackageName.setText(f"{addon.display_name}")
+ self.widget.ui.labelIcon.setPixmap(addon.icon.pixmap(QtCore.QSize(16, 16)))
+ self.widget.ui.labelStatus.setText(self.get_compact_update_string(addon))
+ self.widget.ui.labelIcon.setText("")
+ if addon.metadata:
+ self.widget.ui.labelVersion.setText(f"v{addon.metadata.version}")
+ elif addon.macro:
+ self._set_macro_version_label(addon)
+ else:
+ self.widget.ui.labelVersion.setText("")
+ if self.sort_order == SortOptions.Alphabetical:
+ description = self._get_compact_description(addon)
+ self.widget.ui.labelDescription.setText(description)
+ else:
+ self.widget.ui.labelDescription.setText(self._get_sort_label_text(addon))
+
+ def _set_package_maintainer_label(self, addon: Addon):
+ maintainers = addon.metadata.maintainer
maintainers_string = ""
if len(maintainers) == 1:
maintainers_string = (
@@ -334,38 +344,64 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
for maintainer in maintainers:
maintainers_string += f"\n{maintainer.name} <{maintainer.email}>"
self.widget.ui.labelMaintainer.setText(maintainers_string)
- if repo.tags:
- self.widget.ui.labelTags.setText(
- translate("AddonsInstaller", "Tags") + ": " + ", ".join(repo.tags)
- )
- def _setup_macro(self, repo: Addon):
- """Set up the display for a macro"""
- self.widget.ui.labelDescription.setText(repo.macro.comment)
+ def _set_macro_maintainer_label(self, repo: Addon):
+ if repo.macro.author:
+ caption = translate("AddonsInstaller", "Author")
+ self.widget.ui.labelMaintainer.setText(caption + ": " + repo.macro.author)
+ else:
+ self.widget.ui.labelMaintainer.setText("")
+
+ def _set_macro_version_label(self, addon: Addon):
version_string = ""
- if repo.macro.version:
- version_string = repo.macro.version + " "
- if repo.macro.on_wiki:
+ if addon.macro.version:
+ version_string = addon.macro.version + " "
+ if addon.macro.on_wiki:
version_string += "(wiki)"
- elif repo.macro.on_git:
+ elif addon.macro.on_git:
version_string += "(git)"
else:
version_string += "(unknown source)"
- if repo.macro.date:
- version_string = (
- version_string
- + ", "
- + translate("AddonsInstaller", "updated")
- + " "
- + repo.macro.date
- )
self.widget.ui.labelVersion.setText("" + version_string + "")
- if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
- if repo.macro.author:
- caption = translate("AddonsInstaller", "Author")
- self.widget.ui.labelMaintainer.setText(caption + ": " + repo.macro.author)
- else:
- self.widget.ui.labelMaintainer.setText("")
+
+ def _get_sort_label_text(self, addon: Addon) -> str:
+ if self.sort_order == SortOptions.Alphabetical:
+ return ""
+ elif self.sort_order == SortOptions.Stars:
+ if addon.stats and addon.stats.stars and addon.stats.stars > 0:
+ return translate("AddonsInstaller", "{} ★ on GitHub").format(addon.stats.stars)
+ return translate("AddonsInstaller", "No ★, or not on GitHub")
+ elif self.sort_order == SortOptions.DateAdded:
+ if addon.stats and addon.stats.date_created:
+ epoch_seconds = addon.stats.date_created.timestamp()
+ qdt = QtCore.QDateTime.fromSecsSinceEpoch(int(epoch_seconds)).date()
+ time_string = QtCore.QLocale().toString(qdt, QtCore.QLocale.ShortFormat)
+ return translate("AddonsInstaller", "Created ") + time_string
+ return ""
+ elif self.sort_order == SortOptions.LastUpdated:
+ update_date = addon.update_date
+ if update_date:
+ epoch_seconds = update_date.timestamp()
+ qdt = QtCore.QDateTime.fromSecsSinceEpoch(int(epoch_seconds)).date()
+ time_string = QtCore.QLocale().toString(qdt, QtCore.QLocale.ShortFormat)
+ return translate("AddonsInstaller", "Updated ") + time_string
+ return ""
+ elif self.sort_order == SortOptions.Rank:
+ return translate("AddonsInstaller", "Rank: ") + str(len(addon.display_name))
+ return ""
+
+ def _set_sort_string_expanded(self, addon: Addon, label: QtWidgets.QLabel) -> None:
+ pass
+
+ def _get_compact_description(self, addon: Addon) -> str:
+ if addon.metadata:
+ trimmed_text = addon.metadata.description
+ # TODO: Un-hardcode the 25 character limiter
+ return trimmed_text.replace("\r\n", " ")[:25] + "..."
+ if addon.macro and addon.macro.comment:
+ trimmed_text = addon.macro.comment
+ return trimmed_text.replace("\r\n", " ")[:25] + "..."
+ return ""
@staticmethod
def get_compact_update_string(repo: Addon) -> str:
@@ -524,13 +560,13 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
self.hide_newer_freecad_required = hide_nfr
self.invalidateFilter()
- def lessThan(self, left_in, right_in) -> bool:
- """Enable sorting of display name (not case-sensitive)."""
-
- left = self.sourceModel().data(left_in, PackageListItemModel.DataAccessRole)
- right = self.sourceModel().data(right_in, PackageListItemModel.DataAccessRole)
-
- return left.display_name.lower() < right.display_name.lower()
+ # def lessThan(self, left_in, right_in) -> bool:
+ # """Enable sorting of display name (not case-sensitive)."""
+ #
+ # left = self.sourceModel().data(left_in, self.sortRole)
+ # right = self.sourceModel().data(right_in, self.sortRole)
+ #
+ # return left.display_name.lower() < right.display_name.lower()
def filterAcceptsRow(self, row, _parent=QtCore.QModelIndex()):
"""Do the actual filtering (called automatically by Qt when drawing the list)"""
@@ -597,17 +633,17 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
if not fsf_libre and license_manager.is_fsf_libre(license_id):
fsf_libre = True
if self.hide_non_OSI_approved and not osi_approved:
- FreeCAD.Console.PrintLog(
- f"Hiding addon {data.name} because its license, {licenses_to_check}, "
- f"is "
- f"not OSI approved\n"
- )
+ # FreeCAD.Console.PrintLog(
+ # f"Hiding addon {data.name} because its license, {licenses_to_check}, "
+ # f"is "
+ # f"not OSI approved\n"
+ # )
return False
if self.hide_non_FSF_libre and not fsf_libre:
- FreeCAD.Console.PrintLog(
- f"Hiding addon {data.name} because its license, {licenses_to_check}, is "
- f"not FSF Libre\n"
- )
+ # FreeCAD.Console.PrintLog(
+ # f"Hiding addon {data.name} because its license, {licenses_to_check}, is "
+ # f"not FSF Libre\n"
+ # )
return False
# If it's not installed, check to see if it's for a newer version of FreeCAD