Addon Manager: Add sorting (#12561)

This commit is contained in:
Chris Hennes
2024-02-23 22:33:20 -06:00
committed by GitHub
parent 80b516df45
commit 628bad452a
13 changed files with 567 additions and 132 deletions

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -66,6 +66,8 @@
<file>icons/compact_view.svg</file>
<file>icons/composite_view.svg</file>
<file>icons/expanded_view.svg</file>
<file>icons/sort_ascending.svg</file>
<file>icons/sort_descending.svg</file>
<file>licenses/Apache-2.0.txt</file>
<file>licenses/BSD-2-Clause.txt</file>
<file>licenses/BSD-3-Clause.txt</file>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 16 16"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="ascending.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="64"
inkscape:cx="9"
inkscape:cy="7.40625"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Blur"
id="filter2"
x="-0.2"
y="-0.2"
width="1.4"
height="1.4">
<feGaussianBlur
stdDeviation="1 1"
result="blur"
id="feGaussianBlur2" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
d="m 11,3 v 7 H 10 L 11.5,14.015625 13,10 H 12 V 3 Z"
style="fill-rule:evenodd;stroke-width:0.48;stroke-linecap:square;stroke-miterlimit:100;stroke-dashoffset:3.45;fill:#999999;fill-opacity:1"
id="path4" />
<path
d="m 5,13.015625 v -7 H 6 L 4.5,2 3,6.015625 h 1 v 7 z"
style="fill-rule:evenodd;stroke-width:0.48;stroke-linecap:square;stroke-miterlimit:100;stroke-dashoffset:3.45;fill:#000000;fill-opacity:1"
id="path4-0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="16"
height="16"
viewBox="0 0 16 16"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="descending.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="64"
inkscape:cx="9"
inkscape:cy="7.40625"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="5"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Blur"
id="filter2"
x="-0.2"
y="-0.2"
width="1.4"
height="1.4">
<feGaussianBlur
stdDeviation="1 1"
result="blur"
id="feGaussianBlur2" />
</filter>
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
d="m 11,3 v 7 H 10 L 11.5,14.015625 13,10 H 12 V 3 Z"
style="fill-rule:evenodd;stroke-width:0.48;stroke-linecap:square;stroke-miterlimit:100;stroke-dashoffset:3.45"
id="path4" />
<path
d="m 5,13.015625 v -7 H 6 L 4.5,2 3,6.015625 h 1 v 7 z"
style="fill-rule:evenodd;stroke-width:0.48;stroke-linecap:square;stroke-miterlimit:100;stroke-dashoffset:3.45;fill:#999999;fill-opacity:1"
id="path4-0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -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)

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -122,6 +122,13 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelSort">
<property name="text">
<string>labelSort</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">

View File

@@ -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"<h1>{repo.display_name}</h1>")
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"<b>{repo.display_name}</b>")
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"<i>v{repo.metadata.version}</i>")
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"<h1>{addon.display_name}</h1>")
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"<i>v{addon.metadata.version}</i>")
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"<b>{addon.display_name}</b>")
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"<i>v{addon.metadata.version}</i>")
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("<i>" + version_string + "</i>")
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