diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py index 186bbe8c03..e199b09962 100644 --- a/src/Mod/AddonManager/Addon.py +++ b/src/Mod/AddonManager/Addon.py @@ -168,6 +168,7 @@ class Addon: self.tags = set() # Just a cache, loaded from Metadata self.last_updated = None self.stats = AddonStats() + self.score = 0 # To prevent multiple threads from running git actions on this repo at the # same time @@ -260,7 +261,6 @@ class Addon: 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)" diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index f5c9f51788..03e2f503b1 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -43,6 +43,7 @@ from addonmanager_workers_startup import ( CheckWorkbenchesForUpdatesWorker, CacheMacroCodeWorker, GetBasicAddonStatsWorker, + GetAddonScoreWorker, ) from addonmanager_workers_installation import ( UpdateMetadataCacheWorker, @@ -119,6 +120,7 @@ class CommandAddonManager: "update_all_worker", "check_for_python_package_updates_worker", "get_basic_addon_stats_worker", + "get_addon_score_worker", ] lock = threading.Lock() @@ -202,17 +204,17 @@ class CommandAddonManager: self.button_bar.update_all_addons.hide() # Set up the listing of packages using the model-view-controller architecture - self.packageList = PackageList(self.dialog) + self.package_list = PackageList(self.dialog) self.item_model = PackageListItemModel() - self.packageList.setModel(self.item_model) - self.dialog.layout().addWidget(self.packageList) + self.package_list.setModel(self.item_model) + self.dialog.layout().addWidget(self.package_list) self.dialog.layout().addWidget(self.button_bar) # Package details start out hidden self.packageDetails = PackageDetailsView(self.dialog) self.package_details_controller = PackageDetailsController(self.packageDetails) self.packageDetails.hide() - index = self.dialog.layout().indexOf(self.packageList) + index = self.dialog.layout().indexOf(self.package_list) self.dialog.layout().insertWidget(index, self.packageDetails) # set nice icons to everything, by theme with fallback to FreeCAD icons @@ -241,9 +243,9 @@ class CommandAddonManager: ) self.button_bar.python_dependencies.clicked.connect(self.show_python_updates_dialog) self.button_bar.developer_tools.clicked.connect(self.show_developer_tools) - self.packageList.ui.progressBar.stop_clicked.connect(self.stop_update) - self.packageList.itemSelected.connect(self.table_row_activated) - self.packageList.setEnabled(False) + self.package_list.ui.progressBar.stop_clicked.connect(self.stop_update) + self.package_list.itemSelected.connect(self.table_row_activated) + self.package_list.setEnabled(False) self.package_details_controller.execute.connect(self.executemacro) self.package_details_controller.install.connect(self.launch_installer_gui) self.package_details_controller.uninstall.connect(self.remove) @@ -397,6 +399,7 @@ class CommandAddonManager: self.check_updates, self.check_python_updates, self.fetch_addon_stats, + self.fetch_addon_score, ] pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") if pref.GetBool("DownloadMacros", False): @@ -425,7 +428,7 @@ class CommandAddonManager: ) pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") pref.SetString("LastCacheUpdate", date.today().isoformat()) - self.packageList.item_filter.invalidateFilter() + self.package_list.item_filter.invalidateFilter() def populate_packages_table(self) -> None: self.item_model.clear() @@ -478,8 +481,8 @@ class CommandAddonManager: f.write(json.dumps(self.package_cache, indent=" ")) def activate_table_widgets(self) -> None: - self.packageList.setEnabled(True) - self.packageList.ui.view_bar.search.setFocus() + self.package_list.setEnabled(True) + self.package_list.ui.view_bar.search.setFocus() self.do_next_startup_phase() def populate_macros(self) -> None: @@ -682,6 +685,28 @@ class CommandAddonManager: def update_addon_stats(self, addon: Addon): self.item_model.reload_item(addon) + def fetch_addon_score(self) -> None: + """Fetch the Addon score JSON data from a URL""" + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + url = pref.GetString("AddonsScoreURL", "NONE") + if url and url != "NONE": + self.get_addon_score_worker = GetAddonScoreWorker( + url, self.item_model.repos, self.dialog + ) + self.get_addon_score_worker.finished.connect(self.score_fetched_successfully) + self.get_addon_score_worker.finished.connect(self.do_next_startup_phase) + self.get_addon_score_worker.update_addon_score.connect(self.update_addon_score) + self.get_addon_score_worker.start() + else: + self.package_list.ui.view_bar.set_rankings_available(False) + self.do_next_startup_phase() + + def update_addon_score(self, addon: Addon): + self.item_model.reload_item(addon) + + def score_fetched_successfully(self): + self.package_list.ui.view_bar.set_rankings_available(True) + def show_developer_tools(self) -> None: """Display the developer tools dialog""" if not self.developer_mode: @@ -758,24 +783,24 @@ class CommandAddonManager: def table_row_activated(self, selected_repo: Addon) -> None: """a row was activated, show the relevant data""" - self.packageList.hide() + self.package_list.hide() self.packageDetails.show() self.package_details_controller.show_repo(selected_repo) def show_information(self, message: str) -> None: """shows generic text in the information pane""" - self.packageList.ui.progressBar.set_status(message) - self.packageList.ui.progressBar.repaint() + self.package_list.ui.progressBar.set_status(message) + self.package_list.ui.progressBar.repaint() def show_workbench(self, repo: Addon) -> None: - self.packageList.hide() + self.package_list.hide() self.packageDetails.show() self.package_details_controller.show_repo(repo) def on_buttonBack_clicked(self) -> None: self.packageDetails.hide() - self.packageList.show() + self.package_list.show() def append_to_repos_list(self, repo: Addon) -> None: """this function allows threads to update the main list of workbenches""" @@ -836,12 +861,12 @@ class CommandAddonManager: def hide_progress_widgets(self) -> None: """hides the progress bar and related widgets""" - self.packageList.ui.progressBar.hide() - self.packageList.ui.view_bar.search.setFocus() + self.package_list.ui.progressBar.hide() + self.package_list.ui.view_bar.search.setFocus() def show_progress_widgets(self) -> None: - if self.packageList.ui.progressBar.isHidden(): - self.packageList.ui.progressBar.show() + if self.package_list.ui.progressBar.isHidden(): + self.package_list.ui.progressBar.show() def update_progress_bar(self, current_value: int, max_value: int) -> None: """Update the progress bar, showing it if it's hidden""" @@ -858,10 +883,10 @@ class CommandAddonManager: completed_region_portion = (self.current_progress_region - 1) * region_size current_region_portion = (float(current_value) / float(max_value)) * region_size value = completed_region_portion + current_region_portion - self.packageList.ui.progressBar.set_value( + self.package_list.ui.progressBar.set_value( value * 10 ) # Out of 1000 segments, so it moves sort of smoothly - self.packageList.ui.progressBar.repaint() + self.package_list.ui.progressBar.repaint() def stop_update(self) -> None: self.cleanup_workers() 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 9db1bbb8ae..9343b030e1 100644 --- a/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py +++ b/src/Mod/AddonManager/Widgets/addonmanager_widget_view_control_bar.py @@ -25,6 +25,11 @@ from enum import IntEnum, auto +try: + import FreeCAD +except ImportError: + FreeCAD = None + # Get whatever version of PySide we can try: import PySide # Use the FreeCAD wrapper @@ -52,7 +57,7 @@ class SortOptions(IntEnum): LastUpdated = QtCore.Qt.UserRole + _SortRoleOffset + 1 DateAdded = QtCore.Qt.UserRole + _SortRoleOffset + 2 Stars = QtCore.Qt.UserRole + _SortRoleOffset + 3 - Rank = QtCore.Qt.UserRole + _SortRoleOffset + 4 + Score = QtCore.Qt.UserRole + _SortRoleOffset + 4 default_sort_order = { @@ -60,7 +65,7 @@ default_sort_order = { SortOptions.LastUpdated: QtCore.Qt.DescendingOrder, SortOptions.DateAdded: QtCore.Qt.DescendingOrder, SortOptions.Stars: QtCore.Qt.DescendingOrder, - SortOptions.Rank: QtCore.Qt.DescendingOrder, + SortOptions.Score: QtCore.Qt.DescendingOrder, } @@ -75,6 +80,7 @@ class WidgetViewControlBar(QtWidgets.QWidget): def __init__(self, parent: QtWidgets.QWidget = None): super().__init__(parent) + self.has_rankings = False self._setup_ui() self._setup_connections() self.retranslateUi(None) @@ -124,6 +130,10 @@ class WidgetViewControlBar(QtWidgets.QWidget): ) ) + def set_rankings_available(self, rankings_available: bool) -> None: + self.has_rankings = rankings_available + self.retranslateUi(None) + def _setup_connections(self): self.view_selector.view_changed.connect(self.view_changed.emit) self.filter_selector.filter_changed.connect(self.filter_changed.emit) @@ -133,6 +143,8 @@ class WidgetViewControlBar(QtWidgets.QWidget): def _sort_changed(self, index: int): sort_role = self.sort_selector.itemData(index) + if sort_role is None: + sort_role = SortOptions.Alphabetical self.set_sort_order(default_sort_order[sort_role]) self.sort_changed.emit(sort_role) self.sort_order_changed.emit(self.sort_order) @@ -151,5 +163,7 @@ class WidgetViewControlBar(QtWidgets.QWidget): self.sort_selector.addItem( translate("AddonsInstaller", "GitHub Stars", "Sort order"), SortOptions.Stars ) - # self.sort_selector.addItem(translate("AddonsInstaller", "Rank", "Sort order"), - # SortOptions.Rank) + if self.has_rankings: + self.sort_selector.addItem( + translate("AddonsInstaller", "Score", "Sort order"), SortOptions.Score + ) diff --git a/src/Mod/AddonManager/addonmanager_workers_startup.py b/src/Mod/AddonManager/addonmanager_workers_startup.py index 88fd0dd9df..70efa358b5 100644 --- a/src/Mod/AddonManager/addonmanager_workers_startup.py +++ b/src/Mod/AddonManager/addonmanager_workers_startup.py @@ -945,3 +945,50 @@ class GetBasicAddonStatsWorker(QtCore.QThread): if addon.url in json_result: addon.stats = AddonStats.from_json(json_result[addon.url]) self.update_addon_stats.emit(addon) + + +class GetAddonScoreWorker(QtCore.QThread): + """Fetch data from an addon score file.""" + + update_addon_score = 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""" + + if self.url != "TEST": + 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 score from {} -- sorting by score will fail\n", + ).format(self.url) + ) + return + text_result = fetch_result.data().decode("utf8") + json_result = json.loads(text_result) + else: + FreeCAD.Console.PrintWarning("Running score generation in TEST mode...\n") + json_result = {} + for addon in self.addons: + json_result[addon.url] = len(addon.display_name) + + for addon in self.addons: + score = None + if addon.url in json_result: + score = json_result[addon.url] + elif addon.name in json_result: + score = json_result[addon.name] + if score is not None: + try: + addon.score = int(score) + self.update_addon_score.emit(addon) + except (ValueError, OverflowError): + FreeCAD.Console.PrintLog( + f"Failed to convert score value '{score}' to an integer for addon {addon.name}" + ) diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py index 1a413974e8..6cb5d64c91 100644 --- a/src/Mod/AddonManager/package_list.py +++ b/src/Mod/AddonManager/package_list.py @@ -196,8 +196,8 @@ class PackageListItemModel(QtCore.QAbstractListModel): 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) + if role == SortOptions.Score: + return self.repos[row].score def headerData(self, _unused1, _unused2, _role=QtCore.Qt.DisplayRole): """No header in this implementation: always returns None.""" @@ -386,8 +386,8 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate): 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)) + elif self.sort_order == SortOptions.Score: + return translate("AddonsInstaller", "Score: ") + str(len(addon.display_name)) return "" def _set_sort_string_expanded(self, addon: Addon, label: QtWidgets.QLabel) -> None: