From 628bad452a57aed379c997dfc12f793c5c8085f2 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Fri, 23 Feb 2024 22:33:20 -0600 Subject: [PATCH] Addon Manager: Add sorting (#12561) --- src/Mod/AddonManager/Addon.py | 81 +++++- src/Mod/AddonManager/AddonManager.py | 22 ++ src/Mod/AddonManager/AddonStats.py | 33 ++- .../AddonManager/Resources/AddonManager.qrc | 2 + .../Resources/icons/sort_ascending.svg | 82 ++++++ .../Resources/icons/sort_descending.svg | 82 ++++++ .../addonmanager_widget_view_control_bar.py | 84 +++++- .../AddonManager/addonmanager_macro_parser.py | 3 +- .../addonmanager_preferences_defaults.json | 2 +- .../addonmanager_workers_startup.py | 32 +++ src/Mod/AddonManager/expanded_view.py | 5 + src/Mod/AddonManager/expanded_view.ui | 7 + src/Mod/AddonManager/package_list.py | 264 ++++++++++-------- 13 files changed, 567 insertions(+), 132 deletions(-) create mode 100644 src/Mod/AddonManager/Resources/icons/sort_ascending.svg create mode 100644 src/Mod/AddonManager/Resources/icons/sort_descending.svg 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