Merge pull request #12309 from chennes/addonManagerRefactorGUIArea

Addon manager: Refactor GUI area
This commit is contained in:
Chris Hennes
2024-02-19 11:20:00 -06:00
committed by GitHub
54 changed files with 10298 additions and 1188 deletions

View File

@@ -41,11 +41,14 @@ from addonmanager_metadata import (
Version,
DependencyType,
)
from AddonStats import AddonStats
translate = fci.translate
# A list of internal workbenches that can be used as a dependency of an Addon
INTERNAL_WORKBENCHES = {
"arch": "Arch",
"assembly": "Assembly",
"draft": "Draft",
"fem": "FEM",
"mesh": "Mesh",
@@ -163,6 +166,7 @@ class Addon:
self.description = None
self.tags = set() # Just a cache, loaded from Metadata
self.last_updated = None
self.stats = AddonStats()
# To prevent multiple threads from running git actions on this repo at the
# same time
@@ -205,6 +209,7 @@ class Addon:
self.python_min_version = {"major": 3, "minor": 0}
self._icon_file = None
self._cached_license: str = ""
def __str__(self) -> str:
result = f"FreeCAD {self.repo_type}\n"
@@ -215,6 +220,21 @@ class Addon:
result += "Has linked Macro object\n"
return result
@property
def license(self):
if not self._cached_license:
self._cached_license = "UNLICENSED"
if self.metadata and self.metadata.license:
self._cached_license = self.metadata.license
elif self.stats and self.stats.license:
self._cached_license = self.stats.license
elif self.macro:
if self.macro.license:
self._cached_license = self.macro.license
elif self.macro.on_wiki:
self._cached_license = "CC-BY-3.0"
return self._cached_license
@classmethod
def from_macro(cls, macro: Macro):
"""Create an Addon object from a Macro wrapper object"""

View File

@@ -52,7 +52,9 @@ from addonmanager_update_all_gui import UpdateAllGUI
import addonmanager_utilities as utils
import AddonManager_rc # This is required by Qt, it's not unused
from package_list import PackageList, PackageListItemModel
from package_details import PackageDetails
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 manage_python_dependencies import (
PythonPackageManager,
@@ -130,6 +132,7 @@ class CommandAddonManager:
self.update_all_worker = None
self.developer_mode = None
self.installer_gui = None
self.button_bar = None
self.update_cache = False
self.dialog = None
@@ -185,71 +188,64 @@ class CommandAddonManager:
w = pref.GetInt("WindowWidth", 800)
h = pref.GetInt("WindowHeight", 600)
self.dialog.resize(w, h)
self.button_bar = WidgetGlobalButtonBar(self.dialog)
# If we are checking for updates automatically, hide the Check for updates button:
autocheck = pref.GetBool("AutoCheck", False)
if autocheck:
self.dialog.buttonCheckForUpdates.hide()
self.button_bar.check_for_updates.hide()
else:
self.dialog.buttonUpdateAll.hide()
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.item_model = PackageListItemModel()
self.packageList.setModel(self.item_model)
self.dialog.contentPlaceholder.hide()
self.dialog.layout().replaceWidget(self.dialog.contentPlaceholder, self.packageList)
self.packageList.show()
self.dialog.layout().addWidget(self.packageList)
self.dialog.layout().addWidget(self.button_bar)
# Package details start out hidden
self.packageDetails = PackageDetails(self.dialog)
self.packageDetails = PackageDetailsView(self.dialog)
self.package_details_controller = PackageDetailsController(self.packageDetails)
self.packageDetails.hide()
index = self.dialog.layout().indexOf(self.packageList)
self.dialog.layout().insertWidget(index, self.packageDetails)
# set nice icons to everything, by theme with fallback to FreeCAD icons
self.dialog.setWindowIcon(QtGui.QIcon(":/icons/AddonManager.svg"))
self.dialog.buttonUpdateAll.setIcon(QtGui.QIcon(":/icons/button_valid.svg"))
self.dialog.buttonCheckForUpdates.setIcon(QtGui.QIcon(":/icons/view-refresh.svg"))
self.dialog.buttonClose.setIcon(
QtGui.QIcon.fromTheme("close", QtGui.QIcon(":/icons/process-stop.svg"))
)
self.dialog.buttonPauseUpdate.setIcon(
QtGui.QIcon.fromTheme("pause", QtGui.QIcon(":/icons/media-playback-stop.svg"))
)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
dev_mode_active = pref.GetBool("developerMode", False)
# enable/disable stuff
self.dialog.buttonUpdateAll.setEnabled(False)
self.button_bar.update_all_addons.setEnabled(False)
self.hide_progress_widgets()
self.dialog.buttonUpdateCache.setEnabled(False)
self.dialog.buttonUpdateCache.setText(translate("AddonsInstaller", "Starting up..."))
self.button_bar.refresh_local_cache.setEnabled(False)
self.button_bar.refresh_local_cache.setText(translate("AddonsInstaller", "Starting up..."))
if dev_mode_active:
self.dialog.buttonDevTools.show()
self.button_bar.developer_tools.show()
else:
self.dialog.buttonDevTools.hide()
self.button_bar.developer_tools.hide()
# connect slots
self.dialog.rejected.connect(self.reject)
self.dialog.buttonUpdateAll.clicked.connect(self.update_all)
self.dialog.buttonClose.clicked.connect(self.dialog.reject)
self.dialog.buttonUpdateCache.clicked.connect(self.on_buttonUpdateCache_clicked)
self.dialog.buttonPauseUpdate.clicked.connect(self.stop_update)
self.dialog.buttonCheckForUpdates.clicked.connect(
self.button_bar.update_all_addons.clicked.connect(self.update_all)
self.button_bar.close.clicked.connect(self.dialog.reject)
self.button_bar.refresh_local_cache.clicked.connect(self.on_buttonUpdateCache_clicked)
self.button_bar.check_for_updates.clicked.connect(
lambda: self.force_check_updates(standalone=True)
)
self.dialog.buttonUpdateDependencies.clicked.connect(self.show_python_updates_dialog)
self.dialog.buttonDevTools.clicked.connect(self.show_developer_tools)
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.packageDetails.execute.connect(self.executemacro)
self.packageDetails.install.connect(self.launch_installer_gui)
self.packageDetails.uninstall.connect(self.remove)
self.packageDetails.update.connect(self.update)
self.packageDetails.back.connect(self.on_buttonBack_clicked)
self.packageDetails.update_status.connect(self.status_updated)
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)
self.package_details_controller.update.connect(self.update)
self.package_details_controller.back.connect(self.on_buttonBack_clicked)
self.package_details_controller.update_status.connect(self.status_updated)
# center the dialog over the FreeCAD window
mw = FreeCADGui.getMainWindow()
@@ -257,9 +253,6 @@ class CommandAddonManager:
mw.frameGeometry().topLeft() + mw.rect().center() - self.dialog.rect().center()
)
# set info for the progress bar:
self.dialog.progressBar.setMaximum(1000)
# begin populating the table in a set of sub-threads
self.startup()
@@ -407,8 +400,8 @@ class CommandAddonManager:
if selection:
self.startup_sequence.insert(2, functools.partial(self.select_addon, selection))
pref.SetString("SelectedAddon", "")
self.current_progress_region = 0
self.number_of_progress_regions = len(self.startup_sequence)
self.current_progress_region = 0
self.do_next_startup_phase()
def do_next_startup_phase(self) -> None:
@@ -421,8 +414,8 @@ class CommandAddonManager:
else:
self.hide_progress_widgets()
self.update_cache = False
self.dialog.buttonUpdateCache.setEnabled(True)
self.dialog.buttonUpdateCache.setText(
self.button_bar.refresh_local_cache.setEnabled(True)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Refresh local cache")
)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
@@ -481,7 +474,7 @@ class CommandAddonManager:
def activate_table_widgets(self) -> None:
self.packageList.setEnabled(True)
self.packageList.ui.lineEditFilter.setFocus()
self.packageList.ui.view_bar.search.setFocus()
self.do_next_startup_phase()
def populate_macros(self) -> None:
@@ -551,12 +544,15 @@ class CommandAddonManager:
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager")
utils.rmdir(am_path)
self.dialog.buttonUpdateCache.setEnabled(False)
self.dialog.buttonUpdateCache.setText(translate("AddonsInstaller", "Updating cache..."))
self.button_bar.refresh_local_cache.setEnabled(False)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Updating cache...")
)
self.startup()
# Recaching implies checking for updates, regardless of the user's autocheck option
self.startup_sequence.remove(self.check_updates)
# Re-caching implies checking for updates, regardless of the user's autocheck option
if self.check_updates in self.startup_sequence:
self.startup_sequence.remove(self.check_updates)
self.startup_sequence.append(self.force_check_updates)
def on_package_updated(self, repo: Addon) -> None:
@@ -614,10 +610,12 @@ class CommandAddonManager:
self.do_next_startup_phase()
return
self.dialog.buttonUpdateAll.setText(translate("AddonsInstaller", "Checking for updates..."))
self.button_bar.update_all_addons.setText(
translate("AddonsInstaller", "Checking for updates...")
)
self.packages_with_updates.clear()
self.dialog.buttonUpdateAll.show()
self.dialog.buttonCheckForUpdates.setDisabled(True)
self.button_bar.update_all_addons.show()
self.button_bar.check_for_updates.setDisabled(True)
self.check_worker = CheckWorkbenchesForUpdatesWorker(self.item_model.repos)
self.check_worker.finished.connect(self.do_next_startup_phase)
self.check_worker.finished.connect(self.update_check_complete)
@@ -641,22 +639,17 @@ class CommandAddonManager:
"""enables the update button"""
if number_of_updates:
s = translate("AddonsInstaller", "Apply {} update(s)", "", number_of_updates)
self.dialog.buttonUpdateAll.setText(s.format(number_of_updates))
self.dialog.buttonUpdateAll.setEnabled(True)
self.button_bar.set_number_of_available_updates(number_of_updates)
elif hasattr(self, "check_worker") and self.check_worker.isRunning():
self.dialog.buttonUpdateAll.setText(
self.button_bar.update_all_addons.setText(
translate("AddonsInstaller", "Checking for updates...")
)
else:
self.dialog.buttonUpdateAll.setText(
translate("AddonsInstaller", "No updates available")
)
self.dialog.buttonUpdateAll.setEnabled(False)
self.button_bar.set_number_of_available_updates(0)
def update_check_complete(self) -> None:
self.enable_updates(len(self.packages_with_updates))
self.dialog.buttonCheckForUpdates.setEnabled(True)
self.button_bar.check_for_updates.setEnabled(True)
def check_python_updates(self) -> None:
PythonPackageManager.migrate_old_am_installations() # Migrate 0.20 to 0.21
@@ -745,18 +738,18 @@ class CommandAddonManager:
self.packageList.hide()
self.packageDetails.show()
self.packageDetails.show_repo(selected_repo)
self.package_details_controller.show_repo(selected_repo)
def show_information(self, message: str) -> None:
"""shows generic text in the information pane"""
self.dialog.labelStatusInfo.setText(message)
self.dialog.labelStatusInfo.repaint()
self.packageList.ui.progressBar.set_status(message)
self.packageList.ui.progressBar.repaint()
def show_workbench(self, repo: Addon) -> None:
self.packageList.hide()
self.packageDetails.show()
self.packageDetails.show_repo(repo)
self.package_details_controller.show_repo(repo)
def on_buttonBack_clicked(self) -> None:
self.packageDetails.hide()
@@ -775,7 +768,7 @@ class CommandAddonManager:
else:
repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
self.item_model.reload_item(repo)
self.packageDetails.show_repo(repo)
self.package_details_controller.show_repo(repo)
def launch_installer_gui(self, addon: Addon) -> None:
if self.installer_gui is not None:
@@ -821,16 +814,12 @@ class CommandAddonManager:
def hide_progress_widgets(self) -> None:
"""hides the progress bar and related widgets"""
self.dialog.labelStatusInfo.hide()
self.dialog.progressBar.hide()
self.dialog.buttonPauseUpdate.hide()
self.packageList.ui.lineEditFilter.setFocus()
self.packageList.ui.progressBar.hide()
self.packageList.ui.view_bar.search.setFocus()
def show_progress_widgets(self) -> None:
if self.dialog.progressBar.isHidden():
self.dialog.progressBar.show()
self.dialog.buttonPauseUpdate.show()
self.dialog.labelStatusInfo.show()
if self.packageList.ui.progressBar.isHidden():
self.packageList.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"""
@@ -847,17 +836,19 @@ 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.dialog.progressBar.setValue(
self.packageList.ui.progressBar.set_value(
value * 10
) # Out of 1000 segments, so it moves sort of smoothly
self.dialog.progressBar.repaint()
self.packageList.ui.progressBar.repaint()
def stop_update(self) -> None:
self.cleanup_workers()
self.hide_progress_widgets()
self.write_cache_stopfile()
self.dialog.buttonUpdateCache.setEnabled(True)
self.dialog.buttonUpdateCache.setText(translate("AddonsInstaller", "Refresh local cache"))
self.button_bar.refresh_local_cache.setEnabled(True)
self.button_bar.refresh_local_cache.setText(
translate("AddonsInstaller", "Refresh local cache")
)
def write_cache_stopfile(self) -> None:
stopfile = utils.get_cache_file_name("CACHE_UPDATE_INTERRUPTED")
@@ -872,7 +863,7 @@ class CommandAddonManager:
if repo.status() == Addon.Status.PENDING_RESTART:
self.restart_required = True
self.item_model.reload_item(repo)
self.packageDetails.show_repo(repo)
self.package_details_controller.show_repo(repo)
if repo in self.packages_with_updates:
self.packages_with_updates.remove(repo)
self.enable_updates(len(self.packages_with_updates))

View File

@@ -6,170 +6,14 @@
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<width>928</width>
<height>600</height>
</rect>
</property>
<property name="windowTitle">
<string>Addon Manager</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QWidget" name="contentPlaceholder" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="layoutUpdateInProgress">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_7">
<item>
<widget class="QProgressBar" name="progressBar">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>12</height>
</size>
</property>
<property name="baseSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="value">
<number>0</number>
</property>
<property name="textVisible">
<bool>false</bool>
</property>
<property name="format">
<string>Downloading info...</string>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="buttonPauseUpdate">
<property name="toolTip">
<string>Stop the cache update</string>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="labelStatusInfo">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string notr="true">labelStatusInfo</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<property name="sizeConstraint">
<enum>QLayout::SetDefaultConstraint</enum>
</property>
<item>
<widget class="QPushButton" name="buttonUpdateCache">
<property name="text">
<string>Refresh local cache</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUpdateAll">
<property name="toolTip">
<string>Download and apply all available updates</string>
</property>
<property name="text">
<string>Update all Addons</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonCheckForUpdates">
<property name="text">
<string>Check for updates</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonUpdateDependencies">
<property name="enabled">
<bool>true</bool>
</property>
<property name="toolTip">
<string>View and update Python package dependencies</string>
</property>
<property name="text">
<string>Python dependencies...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="buttonDevTools">
<property name="text">
<string>Developer tools...</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="buttonClose">
<property name="toolTip">
<string>Close the Addon Manager</string>
</property>
<property name="text">
<string>Close</string>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
<layout class="QVBoxLayout" name="verticalLayout_4"/>
</widget>
<resources/>
<connections/>

View File

@@ -90,6 +90,54 @@ installed addons will be checked for available updates
</item>
</layout>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhideunlicensed">
<property name="text">
<string>Hide Addons without a license</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideUnlicensed</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhidenonfsf">
<property name="text">
<string>Hide Addons with non-FSF Free/Libre license</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideNonFSFFreeLibre</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxnonosi">
<property name="text">
<string>Hide Addons with non-OSI-approved license</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
<property name="prefEntry" stdset="0">
<cstring>HideNonOSIApproved</cstring>
</property>
<property name="prefPath" stdset="0">
<cstring>Addons</cstring>
</property>
</widget>
</item>
<item>
<widget class="Gui::PrefCheckBox" name="guiprefcheckboxhidepy2">
<property name="text">

View File

@@ -546,7 +546,7 @@ class TestMetadataReaderIntegration(unittest.TestCase):
self.assertEqual(Version("1.0.1"), metadata.version)
self.assertEqual("2022-01-07", metadata.date)
self.assertEqual("Resources/icons/PackageIcon.svg", metadata.icon)
self.assertListEqual([License(name="LGPLv2.1", file="LICENSE")], metadata.license)
self.assertListEqual([License(name="LGPL-2.1", file="LICENSE")], metadata.license)
self.assertListEqual(
[Contact(name="FreeCAD Developer", email="developer@freecad.org")],
metadata.maintainer,

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
@@ -14,6 +14,7 @@
<classname>MyFirstWorkbench</classname>
<icon>Resources/icons/PackageIcon.svg</icon>
<depend>Arch</depend>
<depend>Assembly</depend>
<depend>DraftWB</depend>
<depend>FEM WB</depend>
<depend>MeshWorkbench</depend>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>
<icon>Resources/icons/PackageIcon.svg</icon>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>

View File

@@ -5,7 +5,7 @@
<version>1.0.1</version>
<date>2022-01-07</date>
<maintainer email="developer@freecad.org">FreeCAD Developer</maintainer>
<license file="LICENSE">LGPLv2.1</license>
<license file="LICENSE">LGPL-2.1</license>
<url type="repository" branch="main">https://github.com/chennes/FreeCAD-Package</url>
<url type="readme">https://github.com/chennes/FreeCAD-Package/blob/main/README.md</url>

View File

@@ -0,0 +1,58 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Classes and structures related to Addon sidecar information """
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
def to_int_or_zero(inp: [str | int | None]):
try:
return int(inp)
except TypeError:
return 0
@dataclass
class AddonStats:
"""Statistics about an addon: not all stats apply to all addon types"""
last_update_time: datetime | None = None
stars: int = 0
open_issues: int = 0
forks: int = 0
license: str = ""
page_views_last_month: int = 0
@classmethod
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"
return new_stats

View File

@@ -1,10 +1,12 @@
IF (BUILD_GUI)
PYSIDE_WRAP_RC(AddonManager_QRC_SRCS Resources/AddonManager.qrc)
add_subdirectory(Widgets)
ENDIF (BUILD_GUI)
SET(AddonManager_SRCS
add_toolbar_button_dialog.ui
Addon.py
AddonStats.py
AddonManager.py
AddonManager.ui
addonmanager_cache.py
@@ -24,11 +26,13 @@ SET(AddonManager_SRCS
addonmanager_git.py
addonmanager_installer.py
addonmanager_installer_gui.py
addonmanager_licenses.py
addonmanager_macro.py
addonmanager_macro_parser.py
addonmanager_metadata.py
addonmanager_package_details_controller.py
addonmanager_pyside_interface.py
addonmanager_readme_viewer.py
addonmanager_readme_controller.py
addonmanager_update_all_gui.py
addonmanager_uninstaller.py
addonmanager_uninstaller_gui.py
@@ -65,7 +69,7 @@ SET(AddonManager_SRCS
loading.html
manage_python_dependencies.py
NetworkManager.py
package_details.py
addonmanager_package_details_controller.py
package_list.py
PythonDependencyUpdateDialog.ui
select_toolbar_dialog.ui

View File

@@ -102,6 +102,12 @@ except ImportError:
if HAVE_QTNETWORK:
# Added in Qt 5.15
if hasattr(QtNetwork.QNetworkRequest, "DefaultTransferTimeoutConstant"):
default_timeout = QtNetwork.QNetworkRequest.DefaultTransferTimeoutConstant
else:
default_timeout = 30000
class QueueItem:
"""A container for information about an item in the network queue."""
@@ -315,7 +321,11 @@ if HAVE_QTNETWORK:
reply.readyRead.connect(self.__ready_to_read)
reply.downloadProgress.connect(self.__download_progress)
def submit_unmonitored_get(self, url: str) -> int:
def submit_unmonitored_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> int:
"""Adds this request to the queue, and returns an index that can be used by calling code
in conjunction with the completed() signal to handle the results of the call. All data is
kept in memory, and the completed() call includes a direct handle to the bytes returned. It
@@ -324,12 +334,18 @@ if HAVE_QTNETWORK:
current_index = next(self.counting_iterator) # A thread-safe counter
# Use a queue because we can only put things on the QNAM from the main event loop thread
self.queue.put(
QueueItem(current_index, self.__create_get_request(url), track_progress=False)
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=False
)
)
self.__request_queued.emit()
return current_index
def submit_monitored_get(self, url: str) -> int:
def submit_monitored_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> int:
"""Adds this request to the queue, and returns an index that can be used by calling code
in conjunction with the progress_made() and progress_completed() signals to handle the
results of the call. All data is cached to disk, and progress is reported periodically
@@ -340,12 +356,18 @@ if HAVE_QTNETWORK:
current_index = next(self.counting_iterator) # A thread-safe counter
# Use a queue because we can only put things on the QNAM from the main event loop thread
self.queue.put(
QueueItem(current_index, self.__create_get_request(url), track_progress=True)
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=True
)
)
self.__request_queued.emit()
return current_index
def blocking_get(self, url: str) -> Optional[QtCore.QByteArray]:
def blocking_get(
self,
url: str,
timeout_ms: int = default_timeout,
) -> Optional[QtCore.QByteArray]:
"""Submits a GET request to the QNetworkAccessManager and block until it is complete"""
current_index = next(self.counting_iterator) # A thread-safe counter
@@ -353,7 +375,9 @@ if HAVE_QTNETWORK:
self.synchronous_complete[current_index] = False
self.queue.put(
QueueItem(current_index, self.__create_get_request(url), track_progress=False)
QueueItem(
current_index, self.__create_get_request(url, timeout_ms), track_progress=False
)
)
self.__request_queued.emit()
while True:
@@ -373,7 +397,8 @@ if HAVE_QTNETWORK:
def __synchronous_process_completion(
self, index: int, code: int, data: QtCore.QByteArray
) -> None:
"""Check the return status of a completed process, and handle its returned data (if any)."""
"""Check the return status of a completed process, and handle its returned data (if
any)."""
with self.synchronous_lock:
if index in self.synchronous_complete:
if code == 200:
@@ -388,7 +413,8 @@ if HAVE_QTNETWORK:
)
self.synchronous_complete[index] = True
def __create_get_request(self, url: str) -> QtNetwork.QNetworkRequest:
@staticmethod
def __create_get_request(url: str, timeout_ms: int) -> QtNetwork.QNetworkRequest:
"""Construct a network request to a given URL"""
request = QtNetwork.QNetworkRequest(QtCore.QUrl(url))
request.setAttribute(
@@ -400,12 +426,15 @@ if HAVE_QTNETWORK:
QtNetwork.QNetworkRequest.CacheLoadControlAttribute,
QtNetwork.QNetworkRequest.PreferNetwork,
)
if hasattr(request, "setTransferTimeout"):
# Added in Qt 5.15
request.setTransferTimeout(timeout_ms)
return request
def abort_all(self):
"""Abort ALL network calls in progress, including clearing the queue"""
for reply in self.replies:
if reply.isRunning():
for reply in self.replies.values():
if reply.abort().isRunning():
reply.abort()
while True:
try:
@@ -428,7 +457,8 @@ if HAVE_QTNETWORK:
authenticator: QtNetwork.QAuthenticator,
):
"""If proxy authentication is required, attempt to authenticate. If the GUI is running this displays
a window asking for credentials. If the GUI is not running, it prompts on the command line."""
a window asking for credentials. If the GUI is not running, it prompts on the command line.
"""
if HAVE_FREECAD and FreeCAD.GuiUp:
proxy_authentication = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "proxy_authentication.ui")
@@ -463,6 +493,9 @@ if HAVE_QTNETWORK:
def __follow_redirect(self, url):
"""Used with the QNetworkAccessManager to follow redirects."""
sender = self.sender()
current_index = -1
timeout_ms = default_timeout
# TODO: Figure out what the actual timeout value should be from the original request
if sender:
for index, reply in self.replies.items():
if reply == sender:
@@ -470,21 +503,24 @@ if HAVE_QTNETWORK:
break
sender.abort()
self.__launch_request(current_index, self.__create_get_request(url))
if current_index != -1:
self.__launch_request(current_index, self.__create_get_request(url, timeout_ms))
def __on_ssl_error(self, reply: str, errors: List[str]):
def __on_ssl_error(self, reply: str, errors: List[str] = None):
"""Called when an SSL error occurs: prints the error information."""
if HAVE_FREECAD:
FreeCAD.Console.PrintWarning(
translate("AddonsInstaller", "Error with encrypted connection") + "\n:"
)
FreeCAD.Console.PrintWarning(reply)
for error in errors:
FreeCAD.Console.PrintWarning(error)
if errors is not None:
for error in errors:
FreeCAD.Console.PrintWarning(error)
else:
print("Error with encrypted connection")
for error in errors:
print(error)
if errors is not None:
for error in errors:
print(error)
def __download_progress(self, bytesReceived: int, bytesTotal: int) -> None:
"""Monitors download progress and emits a progress_made signal"""
@@ -534,21 +570,20 @@ if HAVE_QTNETWORK:
# This can happen during a cancellation operation: silently do nothing
return
if reply.error() == QtNetwork.QNetworkReply.NetworkError.OperationCanceledError:
# Silently do nothing
return
index = None
for key, value in self.replies.items():
if reply == value:
index = key
break
if index is None:
print(f"Lost net request for {reply.url()}")
return
response_code = reply.attribute(QtNetwork.QNetworkRequest.HttpStatusCodeAttribute)
self.queue.task_done()
if response_code == 301: # Permanently moved -- this is a redirect, bail out
return
if reply.error() != QtNetwork.QNetworkReply.NetworkError.OperationCanceledError:
# It this was not a timeout, make sure we mark the queue task done
self.queue.task_done()
if reply.error() == QtNetwork.QNetworkReply.NetworkError.NoError:
if index in self.monitored_connections:
# Make sure to read any remaining data
@@ -618,7 +653,6 @@ def InitializeNetworkManager():
if __name__ == "__main__":
app = QtCore.QCoreApplication()
InitializeNetworkManager()

View File

@@ -64,17 +64,19 @@
<file>icons/workfeature_workbench_icon.svg</file>
<file>icons/yaml-workspace_workbench_icon.svg</file>
<file>icons/compact_view.svg</file>
<file>icons/composite_view.svg</file>
<file>icons/expanded_view.svg</file>
<file>licenses/Apache-2.0.txt</file>
<file>licenses/BSD-2-Clause.txt</file>
<file>licenses/BSD-3-Clause.txt</file>
<file>licenses/CC0v1.txt</file>
<file>licenses/GPLv2.txt</file>
<file>licenses/GPLv3.txt</file>
<file>licenses/LGPLv2.1.txt</file>
<file>licenses/LGPLv3.txt</file>
<file>licenses/CC0-1.0.txt</file>
<file>licenses/GPL-2.0-or-later.txt</file>
<file>licenses/GPL-3.0-or-later.txt</file>
<file>licenses/LGPL-2.1-or-later.txt</file>
<file>licenses/LGPL-3.0-or-later.txt</file>
<file>licenses/MIT.txt</file>
<file>licenses/MPL-2.0.txt</file>
<file>licenses/spdx.json</file>
<file>translations/AddonManager_af.qm</file>
<file>translations/AddonManager_ar.qm</file>
<file>translations/AddonManager_ca.qm</file>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-width:0.9259;stroke-miterlimit:10;}
</style>
<line class="st0" x1="0.3" y1="1.5" x2="6.7" y2="1.5"/>
<line class="st0" x1="0.3" y1="4.5" x2="6.7" y2="4.5"/>
<line class="st0" x1="0.3" y1="7.5" x2="6.7" y2="7.5"/>
<line class="st0" x1="0.3" y1="10.5" x2="6.7" y2="10.5"/>
<line class="st0" x1="0.3" y1="13.5" x2="6.7" y2="13.5"/>
<rect x="9.5" y="1.5" class="st1" width="5" height="13"/>
</svg>

After

Width:  |  Height:  |  Size: 855 B

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
# Addon Manager Future Work
* Restructure widgets into logical groups to better enable showing and hiding those groups all at once.
* Reduce coupling between data and UI, switching logical groupings of widgets into a MVC or similar framework.
* Particularly in the addons list
* Download Addon statistics from central location.
* Allow sorting on those statistics.
* Download a "rank" from user-specified locations.
* Allow sorting on that rank.
* Implement a server-side cache of Addon metadata.
* Implement an "offline mode" that does not attempt to use remote data for anything.
* When installing a Preference Pack, offer to apply it once installed, and to undo after that.
* Better support "headless" mode, with no GUI.
* Add "Composite" display mode, showing compact list and details at the same time

View File

@@ -0,0 +1,28 @@
SET(AddonManagerWidget_SRCS
__init__.py
addonmanager_colors.py
addonmanager_widget_addon_buttons.py
addonmanager_widget_filter_selector.py
addonmanager_widget_global_buttons.py
addonmanager_widget_package_details_view.py
addonmanager_widget_progress_bar.py
addonmanager_widget_readme_browser.py
addonmanager_widget_search.py
addonmanager_widget_view_control_bar.py
addonmanager_widget_view_selector.py
)
SOURCE_GROUP("" FILES ${AddonManagerWidget_SRCS})
ADD_CUSTOM_TARGET(AddonManagerWidget ALL
SOURCES ${AddonManagerWidget_SRCS}
)
fc_copy_sources(AddonManagerWidget "${CMAKE_BINARY_DIR}/Mod/AddonManager/Widgets" ${AddonManagerWidget_SRCS})
INSTALL(
FILES
${AddonManagerWidget_SRCS}
DESTINATION
Mod/AddonManager/Widgets
)

View File

View File

@@ -0,0 +1,48 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
from enum import Enum, auto
import FreeCADGui
from PySide import QtGui
def is_darkmode() -> bool:
"""Heuristics to determine if we are in a darkmode stylesheet"""
pl = FreeCADGui.getMainWindow().palette()
return pl.color(QtGui.QPalette.Window).lightness() < 128
def warning_color_string() -> str:
"""A shade of red, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(255,105,97)" if is_darkmode() else "rgb(215,0,21)"
def bright_color_string() -> str:
"""A shade of green, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(48,219,91)" if is_darkmode() else "rgb(36,138,61)"
def attention_color_string() -> str:
"""A shade of orange, adapted to darkmode if possible. Targets a minimum 7:1 contrast ratio."""
return "rgb(255,179,64)" if is_darkmode() else "rgb(255,149,0)"

View File

@@ -0,0 +1,113 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying the single-addon buttons. """
from enum import Enum, auto
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtGui, QtWidgets
class ButtonBarDisplayMode(Enum):
TextOnly = auto()
IconsOnly = auto()
TextAndIcons = auto()
class WidgetAddonButtons(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.display_mode = ButtonBarDisplayMode.TextAndIcons
self._setup_ui()
self._set_icons()
self.retranslateUi(None)
def set_display_mode(self, mode: ButtonBarDisplayMode):
"""NOTE: Not really implemented yet -- TODO: Implement this functionality"""
if mode == self.display_mode:
return
self._setup_ui()
self._set_icons()
self.retranslateUi(None)
def _setup_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
self.back = QtWidgets.QToolButton(self)
self.install = QtWidgets.QPushButton(self)
self.uninstall = QtWidgets.QPushButton(self)
self.enable = QtWidgets.QPushButton(self)
self.disable = QtWidgets.QPushButton(self)
self.update = QtWidgets.QPushButton(self)
self.run_macro = QtWidgets.QPushButton(self)
self.change_branch = QtWidgets.QPushButton(self)
self.check_for_update = QtWidgets.QPushButton(self)
self.horizontal_layout.addWidget(self.back)
self.horizontal_layout.addStretch()
self.horizontal_layout.addWidget(self.check_for_update)
self.horizontal_layout.addWidget(self.install)
self.horizontal_layout.addWidget(self.uninstall)
self.horizontal_layout.addWidget(self.enable)
self.horizontal_layout.addWidget(self.disable)
self.horizontal_layout.addWidget(self.update)
self.horizontal_layout.addWidget(self.run_macro)
self.horizontal_layout.addWidget(self.change_branch)
self.setLayout(self.horizontal_layout)
def _set_icons(self):
self.back.setIcon(QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/button_left.svg")))
def retranslateUi(self, _):
self.check_for_update.setText(translate("AddonsInstaller", "Check for update"))
self.install.setText(translate("AddonsInstaller", "Install"))
self.uninstall.setText(translate("AddonsInstaller", "Uninstall"))
self.disable.setText(translate("AddonsInstaller", "Disable"))
self.enable.setText(translate("AddonsInstaller", "Enable"))
self.update.setText(translate("AddonsInstaller", "Update"))
self.run_macro.setText(translate("AddonsInstaller", "Run"))
self.change_branch.setText(translate("AddonsInstaller", "Change branch..."))
self.back.setToolTip(translate("AddonsInstaller", "Return to package list"))

View File

@@ -0,0 +1,255 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying the view selection buttons. """
from enum import IntEnum
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtWidgets
class FilterType(IntEnum):
"""There are currently two sections in this drop down, for two different types of filters."""
PACKAGE_CONTENTS = 0
INSTALLATION_STATUS = 1
class StatusFilter(IntEnum):
"""Predefined filters for status"""
ANY = 0
INSTALLED = 1
NOT_INSTALLED = 2
UPDATE_AVAILABLE = 3
class ContentFilter(IntEnum):
"""Predefined filters for addon content type"""
ANY = 0
WORKBENCH = 1
MACRO = 2
PREFERENCE_PACK = 3
class Filter:
def __init__(self):
self.status_filter = StatusFilter.ANY
self.content_filter = ContentFilter.ANY
class WidgetFilterSelector(QtWidgets.QComboBox):
"""A label and menu for selecting what sort of addons are displayed"""
filter_changed = QtCore.Signal(object) # technically, actually class Filter
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.addon_type_index = 0
self.installation_status_index = 0
self.extra_padding = 64
self._setup_ui()
self._setup_connections()
self.retranslateUi(None)
self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToContents)
def _setup_ui(self):
self._build_menu()
def _build_menu(self):
self.clear()
self.addItem(translate("AddonsInstaller", "Filter by..."))
self.insertSeparator(self.count())
self.addItem(translate("AddonsInstaller", "Addon Type"))
self.addon_type_index = self.count() - 1
self.addItem(
translate("AddonsInstaller", "Any"), (FilterType.PACKAGE_CONTENTS, ContentFilter.ANY)
)
self.addItem(
translate("AddonsInstaller", "Workbench"),
(FilterType.PACKAGE_CONTENTS, ContentFilter.WORKBENCH),
)
self.addItem(
translate("AddonsInstaller", "Macro"),
(FilterType.PACKAGE_CONTENTS, ContentFilter.MACRO),
)
self.addItem(
translate("AddonsInstaller", "Preference Pack"),
(FilterType.PACKAGE_CONTENTS, ContentFilter.PREFERENCE_PACK),
)
self.insertSeparator(self.count())
self.addItem(translate("AddonsInstaller", "Installation Status"))
self.installation_status_index = self.count() - 1
self.addItem(
translate("AddonsInstaller", "Any"), (FilterType.INSTALLATION_STATUS, StatusFilter.ANY)
)
self.addItem(
translate("AddonsInstaller", "Not installed"),
(FilterType.INSTALLATION_STATUS, StatusFilter.NOT_INSTALLED),
)
self.addItem(
translate("AddonsInstaller", "Installed"),
(FilterType.INSTALLATION_STATUS, StatusFilter.INSTALLED),
)
self.addItem(
translate("AddonsInstaller", "Update available"),
(FilterType.INSTALLATION_STATUS, StatusFilter.UPDATE_AVAILABLE),
)
model: QtCore.QAbstractItemModel = self.model()
for row in range(model.rowCount()):
if row <= self.addon_type_index:
model.item(row).setEnabled(False)
elif row < self.installation_status_index:
item = model.item(row)
item.setCheckState(QtCore.Qt.Unchecked)
elif row == self.installation_status_index:
model.item(row).setEnabled(False)
else:
item = model.item(row)
item.setCheckState(QtCore.Qt.Unchecked)
for row in range(model.rowCount()):
data = self.itemData(row)
if data:
item = model.item(row)
if data[0] == FilterType.PACKAGE_CONTENTS and data[1] == ContentFilter.ANY:
item.setCheckState(QtCore.Qt.Checked)
elif data[0] == FilterType.INSTALLATION_STATUS and data[1] == StatusFilter.ANY:
item.setCheckState(QtCore.Qt.Checked)
else:
item.setCheckState(QtCore.Qt.Unchecked)
def set_contents_filter(self, contents_filter: ContentFilter):
model = self.model()
for row in range(model.rowCount()):
item = model.item(row)
user_data = self.itemData(row)
if user_data and user_data[0] == FilterType.PACKAGE_CONTENTS:
if user_data[1] == contents_filter:
item.setCheckState(QtCore.Qt.Checked)
else:
item.setCheckState(QtCore.Qt.Unchecked)
self._update_first_row_text()
def set_status_filter(self, status_filter: StatusFilter):
model = self.model()
for row in range(model.rowCount()):
item = model.item(row)
user_data = self.itemData(row)
if user_data and user_data[0] == FilterType.INSTALLATION_STATUS:
if user_data[1] == status_filter:
item.setCheckState(QtCore.Qt.Checked)
else:
item.setCheckState(QtCore.Qt.Unchecked)
self._update_first_row_text()
def _setup_connections(self):
self.activated.connect(self._selected)
def _adjust_dropdown_width(self):
max_width = 0
font_metrics = self.fontMetrics()
for index in range(self.count()):
width = font_metrics.width(self.itemText(index))
max_width = max(max_width, width)
self.view().setMinimumWidth(max_width + self.extra_padding)
def retranslateUi(self, _):
self._build_menu()
self._adjust_dropdown_width()
def _selected(self, row: int):
if row == 0:
return
if row == self.installation_status_index or row == self.addon_type_index:
self.setCurrentIndex(0)
return
model = self.model()
selected_data = self.itemData(row)
if not selected_data:
return
selected_row_type = selected_data[0]
for row in range(model.rowCount()):
item = model.item(row)
user_data = self.itemData(row)
if user_data and user_data[0] == selected_row_type:
if user_data[1] == selected_data[1]:
item.setCheckState(QtCore.Qt.Checked)
else:
item.setCheckState(QtCore.Qt.Unchecked)
self._emit_current_filter()
self.setCurrentIndex(0)
self._update_first_row_text()
def _emit_current_filter(self):
model = self.model()
new_filter = Filter()
for row in range(model.rowCount()):
item = model.item(row)
data = self.itemData(row)
if data and item.checkState() == QtCore.Qt.Checked:
if data[0] == FilterType.INSTALLATION_STATUS:
new_filter.status_filter = data[1]
elif data[0] == FilterType.PACKAGE_CONTENTS:
new_filter.content_filter = data[1]
self.filter_changed.emit(new_filter)
def _update_first_row_text(self):
model = self.model()
state1 = ""
state2 = ""
for row in range(model.rowCount()):
item = model.item(row)
if item.checkState() == QtCore.Qt.Checked:
if not state1:
state1 = item.text()
else:
state2 = item.text()
break
model.item(0).setText(translate("AddonsInstaller", "Filter") + f": {state1}, {state2}")

View File

@@ -0,0 +1,113 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying a set of buttons that affect the Addon
Manager as a whole (rather than a specific Addon). Typically inserted at the bottom of the Addon
Manager main window. """
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtGui, QtWidgets
class WidgetGlobalButtonBar(QtWidgets.QWidget):
"""A QWidget-derived class for displaying a set of buttons that affect the Addon Manager as a
whole (rather than a specific Addon)."""
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.horizontal_layout = None
self.refresh_local_cache = None
self.update_all_addons = None
self.check_for_updates = None
self.python_dependencies = None
self.developer_tools = None
self.close = None
self._update_ui()
self.retranslateUi(None)
self._set_icons()
def _update_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.refresh_local_cache = QtWidgets.QPushButton(self)
self.update_all_addons = QtWidgets.QPushButton(self)
self.check_for_updates = QtWidgets.QPushButton(self)
self.python_dependencies = QtWidgets.QPushButton(self)
self.developer_tools = QtWidgets.QPushButton(self)
self.close = QtWidgets.QPushButton(self)
self.horizontal_layout.addWidget(self.refresh_local_cache)
self.horizontal_layout.addWidget(self.update_all_addons)
self.horizontal_layout.addWidget(self.check_for_updates)
self.horizontal_layout.addWidget(self.python_dependencies)
self.horizontal_layout.addWidget(self.developer_tools)
self.horizontal_layout.addStretch()
self.horizontal_layout.addWidget(self.close)
self.setLayout(self.horizontal_layout)
def _set_icons(self):
self.update_all_addons.setIcon(QtGui.QIcon(":/icons/button_valid.svg"))
self.check_for_updates.setIcon(QtGui.QIcon(":/icons/view-refresh.svg"))
self.close.setIcon(QtGui.QIcon.fromTheme("close", QtGui.QIcon(":/icons/process-stop.svg")))
def retranslateUi(self, _):
self.refresh_local_cache.setText(translate("AddonsInstaller", "Close"))
self.update_all_addons.setText(translate("AddonsInstaller", "Update all addons"))
self.check_for_updates.setText(translate("AddonsInstaller", "Check for updates"))
self.python_dependencies.setText(translate("AddonsInstaller", "Python dependencies..."))
self.developer_tools.setText(translate("AddonsInstaller", "Developer tools..."))
self.close.setText(translate("AddonsInstaller", "Close"))
def set_number_of_available_updates(self, updates: int):
if updates <= 0:
self.update_all_addons.setEnabled(False)
self.update_all_addons.setText(translate("AddonsInstaller", "No updates available"))
elif updates == 1:
self.update_all_addons.setEnabled(True)
self.update_all_addons.setText(translate("AddonsInstaller", "Apply 1 available update"))
else:
self.update_all_addons.setEnabled(True)
self.update_all_addons.setText(
translate("AddonsInstaller", "Apply {} available updates").format(updates)
)

View File

@@ -0,0 +1,338 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
from dataclasses import dataclass
from enum import Enum, auto
import os
from typing import Optional
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtWidgets
from .addonmanager_widget_addon_buttons import WidgetAddonButtons
from .addonmanager_widget_readme_browser import WidgetReadmeBrowser
from .addonmanager_colors import warning_color_string, attention_color_string, bright_color_string
class MessageType(Enum):
Message = auto()
Warning = auto()
Error = auto()
@dataclass
class UpdateInformation:
check_in_progress: bool = False
update_available: bool = False
detached_head: bool = False
version: str = ""
tag: str = ""
branch: Optional[str] = None
@dataclass
class WarningFlags:
obsolete: bool = False
python2: bool = False
required_freecad_version: Optional[str] = None
non_osi_approved = False
non_fsf_libre = False
class PackageDetailsView(QtWidgets.QWidget):
"""The view class for the package details"""
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.button_bar = None
self.readme_browser = None
self.message_label = None
self.location_label = None
self.installed = False
self.disabled = False
self.update_info = UpdateInformation()
self.warning_flags = WarningFlags()
self.installed_version = None
self.installed_branch = None
self.installed_timestamp = None
self.can_disable = True
self._setup_ui()
def _setup_ui(self):
self.vertical_layout = QtWidgets.QVBoxLayout(self)
self.button_bar = WidgetAddonButtons(self)
self.readme_browser = WidgetReadmeBrowser(self)
self.message_label = QtWidgets.QLabel(self)
self.location_label = QtWidgets.QLabel(self)
self.location_label.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
self.vertical_layout.addWidget(self.button_bar)
self.vertical_layout.addWidget(self.message_label)
self.vertical_layout.addWidget(self.location_label)
self.vertical_layout.addWidget(self.readme_browser)
def set_location(self, location: Optional[str]):
if location is not None:
text = (
translate("AddonsInstaller", "Installation location")
+ ": "
+ os.path.normpath(location)
)
self.location_label.setText(text)
self.location_label.show()
else:
self.location_label.hide()
def set_installed(
self,
installed: bool,
on_date: Optional[str] = None,
version: Optional[str] = None,
branch: Optional[str] = None,
):
self.installed = installed
self.installed_timestamp = on_date
self.installed_version = version
self.installed_branch = branch
if not self.installed:
self.set_location(None)
self._sync_ui_state()
def set_update_available(self, info: UpdateInformation):
self.update_info = info
self._sync_ui_state()
def set_disabled(self, disabled: bool):
self.disabled = disabled
self._sync_ui_state()
def allow_disabling(self, allow: bool):
self.can_disable = allow
self._sync_ui_state()
def allow_running(self, show: bool):
self.button_bar.run_macro.setVisible(show)
def set_warning_flags(self, flags: WarningFlags):
self.warning_flags = flags
self._sync_ui_state()
def set_new_disabled_status(self, disabled: bool):
"""If the user just changed the enabled/disabled state of the addon, display a message
indicating that will not take place until restart. Do not call except in a case of a
state change during this run."""
if disabled:
message = translate(
"AddonsInstaller", "This Addon will be disabled next time you restart FreeCAD."
)
else:
message = translate(
"AddonsInstaller", "This Addon will be enabled next time you restart FreeCAD."
)
self.message_label.setText(f"<h3>{message}</h3>")
self.message_label.setStyleSheet("color:" + attention_color_string())
def set_new_branch(self, branch: str):
"""If the user just changed branches, update the message to show that a restart is
needed."""
message_string = "<h3>"
message_string += translate(
"AddonsInstaller", "Changed to branch '{}' -- please restart to use Addon."
).format(branch)
message_string += "</h3>"
self.message_label.setText(message_string)
self.message_label.setStyleSheet("color:" + attention_color_string())
def set_updated(self):
"""If the user has just updated the addon but not yet restarted, show an indication that
we are awaiting a restart."""
message = translate(
"AddonsInstaller", "This Addon has been updated. Restart FreeCAD to see changes."
)
self.message_label.setText(f"<h3>{message}</h3>")
self.message_label.setStyleSheet("color:" + attention_color_string())
def _sync_ui_state(self):
self._sync_button_state()
self._create_status_label_text()
def _sync_button_state(self):
self.button_bar.install.setVisible(not self.installed)
self.button_bar.uninstall.setVisible(self.installed)
if not self.installed:
self.button_bar.disable.hide()
self.button_bar.enable.hide()
self.button_bar.update.hide()
self.button_bar.check_for_update.hide()
else:
self.button_bar.update.setVisible(self.update_info.update_available)
if self.update_info.detached_head:
self.button_bar.check_for_update.hide()
else:
self.button_bar.check_for_update.setVisible(not self.update_info.update_available)
if self.can_disable:
self.button_bar.enable.setVisible(self.disabled)
self.button_bar.disable.setVisible(not self.disabled)
else:
self.button_bar.enable.hide()
self.button_bar.disable.hide()
def _create_status_label_text(self):
if self.installed:
installation_details = self._get_installation_details_string()
update_details = self._get_update_status_string()
message_text = f"{installation_details} {update_details}"
if self.disabled:
message_text += " [" + translate("AddonsInstaller", "Disabled") + "]"
self.message_label.setText(f"<h3>{message_text}</h3>")
if self.disabled:
self.message_label.setStyleSheet("color:" + warning_color_string())
elif self.update_info.update_available:
self.message_label.setStyleSheet("color:" + attention_color_string())
else:
self.message_label.setStyleSheet("color:" + bright_color_string())
self.message_label.show()
elif self._there_are_warnings_to_show():
warnings = self._get_warning_string()
self.message_label.setText(f"<h3>{warnings}</h3>")
self.message_label.setStyleSheet("color:" + warning_color_string())
self.message_label.show()
else:
self.message_label.hide()
def _get_installation_details_string(self) -> str:
version = self.installed_version
date = ""
installed_version_string = ""
if self.installed_timestamp:
date = QtCore.QLocale().toString(
QtCore.QDateTime.fromSecsSinceEpoch(int(round(self.installed_timestamp, 0))),
QtCore.QLocale.ShortFormat,
)
if version and date:
installed_version_string += (
translate("AddonsInstaller", "Version {version} installed on {date}").format(
version=version, date=date
)
+ ". "
)
elif version:
installed_version_string += (
translate("AddonsInstaller", "Version {version} installed") + "."
).format(version=version)
elif date:
installed_version_string += (
translate("AddonsInstaller", "Installed on {date}") + "."
).format(date=date)
else:
installed_version_string += translate("AddonsInstaller", "Installed") + "."
return installed_version_string
def _get_update_status_string(self) -> str:
if self.update_info.check_in_progress:
return translate("AddonsInstaller", "Update check in progress") + "."
if self.update_info.detached_head:
return (
translate(
"AddonsInstaller", "Git tag '{}' checked out, no updates possible"
).format(self.update_info.tag)
+ "."
)
if self.update_info.update_available:
if self.installed_branch and self.update_info.branch:
if self.installed_branch != self.update_info.branch:
return (
translate(
"AddonsInstaller", "Currently on branch {}, name changed to {}"
).format(self.installed_branch, self.update_info.branch)
+ "."
)
if self.update_info.version:
return (
translate(
"AddonsInstaller",
"Currently on branch {}, update available to version {}",
).format(self.installed_branch, str(self.update_info.version).strip())
+ "."
)
return translate("AddonsInstaller", "Update available") + "."
if self.update_info.version:
return (
translate("AddonsInstaller", "Update available to version {}").format(
str(self.update_info.version).strip()
)
+ "."
)
return translate("AddonsInstaller", "Update available") + "."
return translate("AddonsInstaller", "This is the latest version available") + "."
def _there_are_warnings_to_show(self) -> bool:
if self.disabled:
return True
if (
self.warning_flags.obsolete
or self.warning_flags.python2
or self.warning_flags.required_freecad_version
):
return True
return False # TODO: Someday support optional warnings on license types
def _get_warning_string(self) -> str:
if self.installed and self.disabled:
return translate(
"AddonsInstaller",
"WARNING: This addon is currently installed, but disabled. Use the 'enable' "
"button to re-enable.",
)
if self.warning_flags.obsolete:
return translate("AddonsInstaller", "WARNING: This addon is obsolete")
if self.warning_flags.python2:
return translate("AddonsInstaller", "WARNING: This addon is Python 2 only")
if self.warning_flags.required_freecad_version:
return translate("AddonsInstaller", "WARNING: This addon requires FreeCAD {}").format(
self.warning_flags.required_freecad_version
)
return ""

View File

@@ -0,0 +1,95 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying the cache load status. """
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtGui, QtWidgets
_TOTAL_INCREMENTS = 1000
class WidgetProgressBar(QtWidgets.QWidget):
"""A multipart progress bar widget, including a stop button and a status label. Defaults to a
single range with 100 increments, but can be configured with any number of major and minor
ranges. Clicking the stop button will emit a signal, but does not otherwise affect the
widget."""
stop_clicked = QtCore.Signal()
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.vertical_layout = None
self.horizontal_layout = None
self.progress_bar = None
self.status_label = None
self.stop_button = None
self._setup_ui()
def _setup_ui(self):
self.vertical_layout = QtWidgets.QVBoxLayout(self)
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.progress_bar = QtWidgets.QProgressBar(self)
self.status_label = QtWidgets.QLabel(self)
self.stop_button = QtWidgets.QToolButton(self)
self.progress_bar.setMaximum(_TOTAL_INCREMENTS)
self.stop_button.clicked.connect(self.stop_clicked)
self.stop_button.setIcon(
QtGui.QIcon.fromTheme("stop", QtGui.QIcon(":/icons/media-playback-stop.svg"))
)
self.vertical_layout.addLayout(self.horizontal_layout)
self.vertical_layout.addWidget(self.status_label)
self.horizontal_layout.addWidget(self.progress_bar)
self.horizontal_layout.addWidget(self.stop_button)
self.vertical_layout.setContentsMargins(0, 0, 0, 0)
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
def set_status(self, status: str):
self.status_label.setText(status)
def set_value(self, value: int):
self.progress_bar.setValue(value)

View File

@@ -0,0 +1,111 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
import FreeCAD
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtGui, QtWidgets
from typing import Optional
class WidgetReadmeBrowser(QtWidgets.QTextBrowser):
"""A QTextBrowser widget that emits signals for each requested image resource, allowing an external controller
to load and re-deliver those images. Once all resources have been re-delivered, the original data is redisplayed
with the images in-line. Call setUrl prior to calling setMarkdown or setHtml to ensure URLs are resolved
correctly."""
load_resource = QtCore.Signal(str) # Str is a URL to a resource
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.image_map = {}
self.url = ""
self.stop = False
self.setOpenExternalLinks(True)
def setUrl(self, url: str):
"""Set the base URL of the page. Used to resolve relative URLs in the page source."""
self.url = url
def setMarkdown(self, md: str):
"""Provides an optional fallback to the markdown library for older versions of Qt (prior to 5.15) that did not
have native markdown support. Lacking that, plaintext is displayed."""
if hasattr(super(), "setMarkdown"):
super().setMarkdown(md)
else:
try:
import markdown
html = markdown.markdown(md)
self.setHtml(html)
except ImportError:
self.setText(md)
FreeCAD.Console.Warning(
"Qt < 5.15 and no `import markdown` -- falling back to plain text display\n"
)
def set_resource(self, resource_url: str, image: Optional[QtGui.QImage]):
"""Once a resource has been fetched (or the fetch has failed), this method should be used to inform the widget
that the resource has been loaded. Note that the incoming image is scaled to 97% of the widget width if it is
larger than that."""
self.image_map[resource_url] = self._ensure_appropriate_width(image)
def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object:
"""Callback for resource loading. Called automatically by underlying Qt
code when external resources are needed for rendering. In particular,
here it is used to download and cache (in RAM) the images needed for the
README and Wiki pages."""
if resource_type == QtGui.QTextDocument.ImageResource and not self.stop:
full_url = self._create_full_url(name.toString())
if full_url not in self.image_map:
self.load_resource.emit(full_url)
self.image_map[full_url] = None
return self.image_map[full_url]
return super().loadResource(resource_type, name)
def _ensure_appropriate_width(self, image: QtGui.QImage) -> QtGui.QImage:
ninety_seven_percent = self.width() * 0.97
if image.width() < ninety_seven_percent:
return image
return image.scaledToWidth(ninety_seven_percent)
def _create_full_url(self, url: str) -> str:
if url.startswith("http"):
return url
if not self.url:
return url
lhs, slash, _ = self.url.rpartition("/")
return lhs + slash + url

View File

@@ -0,0 +1,104 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying the view selection buttons. """
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(_: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtGui, QtWidgets
class WidgetSearch(QtWidgets.QWidget):
"""A widget for selecting the Addon Manager's primary view mode"""
search_changed = QtCore.Signal(str)
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self._setup_ui()
self._setup_connections()
self.retranslateUi(None)
def _setup_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
self.filter_line_edit = QtWidgets.QLineEdit(self)
self.filter_line_edit.setClearButtonEnabled(True)
self.horizontal_layout.addWidget(self.filter_line_edit)
self.filter_validity_label = QtWidgets.QLabel(self)
self.horizontal_layout.addWidget(self.filter_validity_label)
self.filter_validity_label.hide() # This widget starts hidden
self.setLayout(self.horizontal_layout)
def _setup_connections(self):
self.filter_line_edit.textChanged.connect(self.set_text_filter)
def set_text_filter(self, text_filter: str) -> None:
"""Set the current filter. If the filter is valid, this will emit a filter_changed
signal. text_filter may be regular expression."""
if text_filter:
test_regex = QtCore.QRegularExpression(text_filter)
if test_regex.isValid():
self.filter_validity_label.setToolTip(
translate("AddonsInstaller", "Filter is valid")
)
icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg"))
self.filter_validity_label.setPixmap(icon.pixmap(16, 16))
else:
self.filter_validity_label.setToolTip(
translate("AddonsInstaller", "Filter regular expression is invalid")
)
icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))
self.filter_validity_label.setPixmap(icon.pixmap(16, 16))
self.filter_validity_label.show()
else:
self.filter_validity_label.hide()
self.search_changed.emit(text_filter)
def retranslateUi(self, _):
self.filter_line_edit.setPlaceholderText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Search...", None)
)

View File

@@ -0,0 +1,75 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a class derived from QWidget for displaying the bar at the top of the addons list. """
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtWidgets
from .addonmanager_widget_view_selector import WidgetViewSelector
from .addonmanager_widget_filter_selector import WidgetFilterSelector
from .addonmanager_widget_search import WidgetSearch
class WidgetViewControlBar(QtWidgets.QWidget):
"""A bar containing a view selection widget, a filter widget, and a search widget"""
view_changed = QtCore.Signal(int)
filter_changed = QtCore.Signal(object)
search_changed = QtCore.Signal(str)
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self._setup_ui()
self._setup_connections()
self.retranslateUi(None)
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.search = WidgetSearch(self)
self.horizontal_layout.addWidget(self.view_selector)
self.horizontal_layout.addWidget(self.filter_selector)
self.horizontal_layout.addWidget(self.search)
self.setLayout(self.horizontal_layout)
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)
def retranslateUi(self, _=None):
pass

View File

@@ -0,0 +1,155 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Defines a QWidget-derived class for displaying the view selection buttons. """
from enum import IntEnum
try:
import FreeCAD
translate = FreeCAD.Qt.translate
except ImportError:
FreeCAD = None
def translate(context: str, text: str):
return text
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtGui, QtWidgets
class AddonManagerDisplayStyle(IntEnum):
"""The display mode of the Addon Manager"""
COMPACT = 0
EXPANDED = 1
COMPOSITE = 2
class WidgetViewSelector(QtWidgets.QWidget):
"""A widget for selecting the Addon Manager's primary view mode"""
view_changed = QtCore.Signal(int)
def __init__(self, parent: QtWidgets.QWidget = None):
super().__init__(parent)
self.horizontal_layout = None
self.composite_button = None
self.expanded_button = None
self.compact_button = None
self._setup_ui()
self._setup_connections()
def set_current_view(self, view: AddonManagerDisplayStyle):
"""Set the current selection. Does NOT emit a view_changed signal, only changes the
interface display."""
self.compact_button.setChecked(False)
self.expanded_button.setChecked(False)
self.composite_button.setChecked(False)
if view == AddonManagerDisplayStyle.COMPACT:
self.compact_button.setChecked(True)
elif view == AddonManagerDisplayStyle.EXPANDED:
self.expanded_button.setChecked(True)
elif view == AddonManagerDisplayStyle.COMPOSITE:
self.composite_button.setChecked(True)
else:
if FreeCAD is not None:
FreeCAD.Console.PrintWarning(f"Unrecognized display style {view}")
def _setup_ui(self):
self.horizontal_layout = QtWidgets.QHBoxLayout()
self.horizontal_layout.setContentsMargins(0, 0, 0, 0)
self.horizontal_layout.setSpacing(2)
self.compact_button = QtWidgets.QToolButton(self)
self.compact_button.setObjectName("compact_button")
self.compact_button.setIcon(
QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/compact_view.svg"))
)
self.compact_button.setCheckable(True)
self.compact_button.setAutoExclusive(True)
self.expanded_button = QtWidgets.QToolButton(self)
self.expanded_button.setObjectName("expanded_button")
self.expanded_button.setCheckable(True)
self.expanded_button.setChecked(True)
self.expanded_button.setAutoExclusive(True)
self.expanded_button.setIcon(
QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/expanded_view.svg"))
)
self.composite_button = QtWidgets.QToolButton(self)
self.composite_button.setObjectName("expanded_button")
self.composite_button.setCheckable(True)
self.composite_button.setChecked(True)
self.composite_button.setAutoExclusive(True)
self.composite_button.setIcon(
QtGui.QIcon.fromTheme("composite_button", QtGui.QIcon(":/icons/composite_view.svg"))
)
self.composite_button.hide() # TODO: Implement this view
self.horizontal_layout.addWidget(self.compact_button)
self.horizontal_layout.addWidget(self.expanded_button)
self.horizontal_layout.addWidget(self.composite_button)
self.compact_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPACT)
)
self.expanded_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.EXPANDED)
)
self.composite_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPOSITE)
)
self.setLayout(self.horizontal_layout)
self.retranslateUi(None)
def _setup_connections(self):
self.compact_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPACT)
)
self.expanded_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.EXPANDED)
)
self.composite_button.clicked.connect(
lambda: self.view_changed.emit(AddonManagerDisplayStyle.COMPOSITE)
)
def retranslateUi(self, _):
self.composite_button.setToolTip(translate("AddonsInstaller", "Composite view"))
self.expanded_button.setToolTip(translate("AddonsInstaller", "Expanded view"))
self.compact_button.setToolTip(translate("AddonsInstaller", "Compact view"))

View File

View File

@@ -75,23 +75,23 @@ class LicenseSelector:
"The 3-Clause BSD License",
"https://opensource.org/licenses/BSD-3-Clause",
),
"CC0v1": (
"CC0-1.0": (
"No Rights Reserved/Public Domain",
"https://creativecommons.org/choose/zero/",
),
"GPLv2": (
"GPL-2.0-or-later": (
"GNU General Public License version 2",
"https://opensource.org/licenses/GPL-2.0",
),
"GPLv3": (
"GPL-3.0-or-later": (
"GNU General Public License version 3",
"https://opensource.org/licenses/GPL-3.0",
),
"LGPLv2.1": (
"LGPL-2.1-or-later": (
"GNU Lesser General Public License version 2.1",
"https://opensource.org/licenses/LGPL-2.1",
),
"LGPLv3": (
"LGPL-3.0-or-later": (
"GNU Lesser General Public License version 3",
"https://opensource.org/licenses/LGPL-3.0",
),
@@ -129,7 +129,7 @@ class LicenseSelector:
self.dialog.createButton.clicked.connect(self._create_clicked)
# Set up the first selection to whatever the user chose last time
short_code = self.pref.GetString("devModeLastSelectedLicense", "LGPLv2.1")
short_code = self.pref.GetString("devModeLastSelectedLicense", "LGPL-2.1-or-later")
self.set_license(short_code)
def exec(self, short_code: str = None, license_path: str = "") -> Optional[Tuple[str, str]]:

View File

@@ -0,0 +1,187 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Utilities for working with licenses. Based on SPDX info downloaded from
https://github.com/spdx/license-list-data and stored as part of the FreeCAD repo, loaded into a Qt
resource. """
import json
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore
import addonmanager_freecad_interface as fci
class SPDXLicenseManager:
"""A class that loads a list of licenses from an internal Qt resource and provides access to
some information about those licenses."""
def __init__(self):
self.license_data = {}
self._load_license_data()
def _load_license_data(self):
qf = QtCore.QFile(f":/licenses/spdx.json")
if qf.exists():
qf.open(QtCore.QIODevice.ReadOnly)
byte_data = qf.readAll()
qf.close()
string_data = str(byte_data, encoding="utf-8")
raw_license_data = json.loads(string_data)
self._process_raw_spdx_json(raw_license_data)
def _process_raw_spdx_json(self, raw_license_data: dict):
"""The raw JSON data is a list of licenses, with the ID as an element of the contained
data members. More useful for our purposes is a dictionary with the SPDX IDs as the keys
and the remaining data as the values."""
for entry in raw_license_data["licenses"]:
self.license_data[entry["licenseId"]] = entry
def is_osi_approved(self, spdx_id: str) -> bool:
"""Check to see if the license is OSI-approved, according to the SPDX database. Returns
False if the license is not in the database, or is not marked as "isOsiApproved"."""
if spdx_id == "UNLICENSED" or spdx_id == "UNLICENCED" or spdx_id.startswith("SEE LIC"):
return False
if spdx_id not in self.license_data:
fci.Console.PrintWarning(
f"WARNING: License ID {spdx_id} is not in the SPDX license "
f"list. The Addon author must correct their metadata.\n"
)
return False
return (
"isOsiApproved" in self.license_data[spdx_id]
and self.license_data[spdx_id]["isOsiApproved"]
)
def is_fsf_libre(self, spdx_id: str) -> bool:
"""Check to see if the license is FSF Free/Libre, according to the SPDX database. Returns
False if the license is not in the database, or is not marked as "isFsfLibre"."""
if spdx_id == "UNLICENSED" or spdx_id == "UNLICENCED" or spdx_id.startswith("SEE LIC"):
return False
if spdx_id not in self.license_data:
fci.Console.PrintWarning(
f"WARNING: License ID {spdx_id} is not in the SPDX license "
f"list. The Addon author must correct their metadata.\n"
)
return False
return (
"isFsfLibre" in self.license_data[spdx_id] and self.license_data[spdx_id]["isFsfLibre"]
)
def name(self, spdx_id: str) -> str:
if spdx_id == "UNLICENSED":
return "All rights reserved"
if spdx_id.startswith("SEE LIC"): # "SEE LICENSE IN" or "SEE LICENCE IN"
return f"Custom license: {spdx_id}"
if spdx_id not in self.license_data:
return ""
return self.license_data[spdx_id]["name"]
def url(self, spdx_id: str) -> str:
if spdx_id not in self.license_data:
return ""
return self.license_data[spdx_id]["reference"]
def details_json_url(self, spdx_id: str):
"""The "detailsUrl" entry in the SPDX database, which is a link to a JSON file containing
the details of the license. As of SPDX v3 the fields are:
* isDeprecatedLicenseId
* isFsfLibre
* licenseText
* standardLicenseHeaderTemplate
* standardLicenseTemplate
* name
* licenseId
* standardLicenseHeader
* crossRef
* seeAlso
* isOsiApproved
* licenseTextHtml
* standardLicenseHeaderHtml"""
if spdx_id not in self.license_data:
return ""
return self.license_data[spdx_id]["detailsUrl"]
def normalize(self, license_string: str) -> str:
"""Given a potentially non-compliant license string, attempt to normalize it to match an
SPDX record. Takes a conservative view and tries not to over-expand stated rights (e.g.
it will select 'GPL-3.0-only' rather than 'GPL-3.0-or-later' when given just GPL3)."""
if self.name(license_string):
return license_string
fci.Console.PrintLog(
f"Attempting to normalize non-compliant license '" f"{license_string}'... "
)
normed = license_string.replace("lgpl", "LGPL").replace("gpl", "GPL")
normed = (
normed.replace(" ", "-")
.replace("v", "-")
.replace("GPL2", "GPL-2")
.replace("GPL3", "GPL-3")
)
or_later = ""
if normed.endswith("+"):
normed = normed[:-1]
or_later = "-or-later"
if self.name(normed + or_later):
fci.Console.PrintLog(f"found valid SPDX license ID {normed}\n")
return normed + or_later
# If it still doesn't match, try some other things
while "--" in normed:
normed = normed.replace("--", "-")
if self.name(normed + or_later):
fci.Console.PrintLog(f"found valid SPDX license ID {normed}\n")
return normed + or_later
normed += ".0"
if self.name(normed + or_later):
fci.Console.PrintLog(f"found valid SPDX license ID {normed}\n")
return normed + or_later
fci.Console.PrintLog(f"failed to normalize (typo in ID or invalid version number??)\n")
return license_string # We failed to normalize this one
_LICENSE_MANAGER = None # Internal use only, see get_license_manager()
def get_license_manager() -> SPDXLicenseManager:
"""Get the license manager. Prevents multiple re-loads of the license list by keeping a
single copy of the manager."""
global _LICENSE_MANAGER
if _LICENSE_MANAGER is None:
_LICENSE_MANAGER = SPDXLicenseManager()
return _LICENSE_MANAGER

View File

@@ -67,6 +67,7 @@ class Macro:
self.raw_code_url = ""
self.wiki = ""
self.version = ""
self.license = ""
self.date = ""
self.src_filename = ""
self.filename_from_url = ""
@@ -111,8 +112,8 @@ class Macro:
def is_installed(self):
"""Returns True if this macro is currently installed (that is, if it exists
in the user macro directory), or False if it is not. Both the exact filename,
as well as the filename prefixed with "Macro", are considered an installation
in the user macro directory), or False if it is not. Both the exact filename
and the filename prefixed with "Macro", are considered an installation
of this macro.
"""
if self.on_git and not self.src_filename:
@@ -227,7 +228,7 @@ class Macro:
code = re.findall(r"<pre>(.*?)</pre>", p.replace("\n", "--endl--"))
if code:
# take the biggest code block
code = sorted(code, key=len)[-1]
code = str(sorted(code, key=len)[-1])
code = code.replace("--endl--", "\n")
# Clean HTML escape codes.
code = unescape(code)
@@ -327,7 +328,7 @@ class Macro:
self.other_files.append(self.icon)
def _copy_other_files(self, macro_dir, warnings) -> bool:
"""Copy any specified "other files" into the install directory"""
"""Copy any specified "other files" into the installation directory"""
base_dir = os.path.dirname(self.src_filename)
for other_file in self.other_files:
if not other_file:
@@ -382,7 +383,7 @@ class Macro:
)
def parse_wiki_page_for_icon(self, page_data: str) -> None:
"""Attempt to find a url for the icon in the wiki page. Sets self.icon if
"""Attempt to find the url for the icon in the wiki page. Sets 'self.icon' if
found."""
# Method 1: the text "toolbar icon" appears on the page, and provides a direct

View File

@@ -36,8 +36,10 @@ except ImportError:
try:
import FreeCAD
from addonmanager_licenses import get_license_manager
except ImportError:
FreeCAD = None
get_license_manager = None
class DummyThread:
@@ -63,6 +65,7 @@ class MacroParser:
"other_files": [""],
"author": "",
"date": "",
"license": "",
"icon": "",
"xpm": "",
}
@@ -83,6 +86,8 @@ class MacroParser:
"__files__": "other_files",
"__author__": "author",
"__date__": "date",
"__license__": "license",
"__licence__": "license", # accept either spelling
"__icon__": "icon",
"__xpm__": "xpm",
}
@@ -185,6 +190,8 @@ class MacroParser:
self.parse_results[value] = match_group
if value == "comment":
self._cleanup_comment()
elif value == "license":
self._cleanup_license()
elif isinstance(self.parse_results[value], list):
self.parse_results[value] = [of.strip() for of in match_group.split(",")]
else:
@@ -197,6 +204,11 @@ class MacroParser:
if len(self.parse_results["comment"]) > 512:
self.parse_results["comment"] = self.parse_results["comment"][:511] + ""
def _cleanup_license(self):
if get_license_manager is not None:
lm = get_license_manager()
self.parse_results["license"] = lm.normalize(self.parse_results["license"])
def _apply_special_handling(self, key: str, line: str):
# Macro authors are supposed to be providing strings here, but in some
# cases they are not doing so. If this is the "__version__" tag, try

View File

@@ -30,6 +30,9 @@ from dataclasses import dataclass, field
from enum import IntEnum, auto
from typing import Tuple, Dict, List, Optional
from addonmanager_licenses import get_license_manager
import addonmanager_freecad_interface as fci
try:
# If this system provides a secure parser, use that:
import defusedxml.ElementTree as ET
@@ -315,7 +318,10 @@ class MetadataReader:
@staticmethod
def _parse_license(child: ET.Element) -> License:
file = child.attrib["file"] if "file" in child.attrib else ""
return License(name=child.text, file=file)
license_id = child.text
lm = get_license_manager()
license_id = lm.normalize(license_id)
return License(name=license_id, file=file)
@staticmethod
def _parse_url(child: ET.Element) -> Url:

View File

@@ -0,0 +1,258 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 The FreeCAD Project Association AISBL *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Provides the PackageDetails widget. """
import os
from typing import Optional
from PySide import QtCore, QtWidgets
import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
from addonmanager_metadata import (
Version,
get_first_supported_freecad_version,
get_branch_from_metadata,
)
from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
from addonmanager_git import GitManager, NoGitFound
from Addon import Addon
from change_branch import ChangeBranchDialog
from addonmanager_readme_controller import ReadmeController
from Widgets.addonmanager_widget_package_details_view import UpdateInformation, WarningFlags
translate = fci.translate
class PackageDetailsController(QtCore.QObject):
"""Manages the display of the package README information."""
back = QtCore.Signal()
install = QtCore.Signal(Addon)
uninstall = QtCore.Signal(Addon)
update = QtCore.Signal(Addon)
execute = QtCore.Signal(Addon)
update_status = QtCore.Signal(Addon)
check_for_update = QtCore.Signal(Addon)
def __init__(self, widget=None):
super().__init__()
self.ui = widget
self.readme_controller = ReadmeController(self.ui.readme_browser)
self.worker = None
self.addon = None
self.status_update_thread = None
self.original_disabled_state = None
self.original_status = None
try:
self.git_manager = GitManager()
except NoGitFound:
self.git_manager = None
self.ui.button_bar.back.clicked.connect(self.back.emit)
self.ui.button_bar.run_macro.clicked.connect(lambda: self.execute.emit(self.addon))
self.ui.button_bar.install.clicked.connect(lambda: self.install.emit(self.addon))
self.ui.button_bar.uninstall.clicked.connect(lambda: self.uninstall.emit(self.addon))
self.ui.button_bar.update.clicked.connect(lambda: self.update.emit(self.addon))
self.ui.button_bar.check_for_update.clicked.connect(
lambda: self.check_for_update.emit(self.addon)
)
self.ui.button_bar.change_branch.clicked.connect(self.change_branch_clicked)
self.ui.button_bar.enable.clicked.connect(self.enable_clicked)
self.ui.button_bar.disable.clicked.connect(self.disable_clicked)
def show_repo(self, repo: Addon) -> None:
"""The main entry point for this class, shows the package details and related buttons
for the provided repo."""
self.addon = repo
self.readme_controller.set_addon(repo)
self.original_disabled_state = self.addon.is_disabled()
if self.worker is not None:
if not self.worker.isFinished():
self.worker.requestInterruption()
self.worker.wait()
installed = self.addon.status() != Addon.Status.NOT_INSTALLED
self.ui.set_installed(installed)
update_info = UpdateInformation()
if installed:
update_info.update_available = self.addon.status() == Addon.Status.UPDATE_AVAILABLE
update_info.check_in_progress = False # TODO: Implement the "check in progress" status
if repo.metadata:
update_info.branch = get_branch_from_metadata(repo.metadata)
update_info.version = repo.metadata.version
elif repo.macro:
update_info.version = repo.macro.version
self.ui.set_update_available(update_info)
self.ui.set_location(os.path.join(self.addon.mod_directory, self.addon.name))
self.ui.set_location(os.path.join(self.addon.mod_directory, self.addon.name))
self.ui.set_disabled(self.addon.is_disabled())
self.ui.allow_running(repo.repo_type == Addon.Kind.MACRO)
self.ui.allow_disabling(repo.repo_type != Addon.Kind.MACRO)
if repo.repo_type == Addon.Kind.MACRO:
self.update_macro_info(repo)
if repo.status() == Addon.Status.UNCHECKED:
if not self.status_update_thread:
self.status_update_thread = QtCore.QThread()
self.status_create_addon_list_worker = CheckSingleUpdateWorker(repo)
self.status_create_addon_list_worker.moveToThread(self.status_update_thread)
self.status_update_thread.finished.connect(
self.status_create_addon_list_worker.deleteLater
)
self.check_for_update.connect(self.status_create_addon_list_worker.do_work)
self.status_create_addon_list_worker.update_status.connect(self.display_repo_status)
self.status_update_thread.start()
update_info.check_in_progress = True
self.ui.set_update_available(update_info)
self.check_for_update.emit(self.addon)
flags = WarningFlags()
flags.required_freecad_version = self.requires_newer_freecad()
flags.obsolete = repo.obsolete
flags.python2 = repo.python2
self.ui.set_warning_flags(flags)
def requires_newer_freecad(self) -> Optional[Version]:
"""If the current package is not installed, returns the first supported version of
FreeCAD, if one is set, or None if no information is available (or if the package is
already installed)."""
# If it's not installed, check to see if it's for a newer version of FreeCAD
if self.addon.status() == Addon.Status.NOT_INSTALLED and self.addon.metadata:
# Only hide if ALL content items require a newer version, otherwise
# it's possible that this package actually provides versions of itself
# for newer and older versions
first_supported_version = get_first_supported_freecad_version(self.addon.metadata)
if first_supported_version is not None:
fc_version = Version(from_list=fci.Version())
if first_supported_version > fc_version:
return first_supported_version
return None
def set_change_branch_button_state(self):
"""The change branch button is only available for installed Addons that have a .git directory
and in runs where the git is available."""
self.ui.button_bar.change_branch_button.hide()
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
show_switcher = pref.GetBool("ShowBranchSwitcher", False)
if not show_switcher:
return
# Is this repo installed? If not, return.
if self.addon.status() == Addon.Status.NOT_INSTALLED:
return
# Is it a Macro? If so, return:
if self.addon.repo_type == Addon.Kind.MACRO:
return
# Can we actually switch branches? If not, return.
if not self.git_manager:
return
# Is there a .git subdirectory? If not, return.
basedir = fci.getUserAppDataDir()
path_to_git = os.path.join(basedir, "Mod", self.addon.name, ".git")
if not os.path.isdir(path_to_git):
return
# If all four above checks passed, then it's possible for us to switch
# branches, if there are any besides the one we are on: show the button
self.ui.button_bar.change_branch_button.show()
def update_macro_info(self, repo: Addon) -> None:
if not repo.macro.url:
# We need to populate the macro information... may as well do it while the user reads
# the wiki page
self.worker = GetMacroDetailsWorker(repo)
self.worker.readme_updated.connect(self.macro_readme_updated)
self.worker.start()
def change_branch_clicked(self) -> None:
"""Loads the branch-switching dialog"""
basedir = fci.getUserAppDataDir()
path_to_repo = os.path.join(basedir, "Mod", self.addon.name)
change_branch_dialog = ChangeBranchDialog(path_to_repo, self.ui)
change_branch_dialog.branch_changed.connect(self.branch_changed)
change_branch_dialog.exec()
def enable_clicked(self) -> None:
"""Called by the Enable button, enables this Addon and updates GUI to reflect
that status."""
self.addon.enable()
self.ui.set_disabled(False)
if self.original_disabled_state:
self.ui.set_new_disabled_status(False)
self.original_status = self.addon.status()
self.addon.set_status(Addon.Status.PENDING_RESTART)
else:
self.addon.set_status(self.original_status)
self.update_status.emit(self.addon)
def disable_clicked(self) -> None:
"""Called by the Disable button, disables this Addon and updates the GUI to
reflect that status."""
self.addon.disable()
self.ui.set_disabled(True)
if not self.original_disabled_state:
self.ui.set_new_disabled_status(True)
self.original_status = self.addon.status()
self.addon.set_status(Addon.Status.PENDING_RESTART)
else:
self.addon.set_status(self.original_status)
self.update_status.emit(self.addon)
def branch_changed(self, name: str) -> None:
"""Displays a dialog confirming the branch changed, and tries to access the
metadata file from that branch."""
QtWidgets.QMessageBox.information(
self.ui,
translate("AddonsInstaller", "Success"),
translate(
"AddonsInstaller",
"Branch change succeeded, please restart to use the new version.",
),
)
# See if this branch has a package.xml file:
basedir = fci.getUserAppDataDir()
path_to_metadata = os.path.join(basedir, "Mod", self.addon.name, "package.xml")
if os.path.isfile(path_to_metadata):
self.addon.load_metadata_file(path_to_metadata)
self.addon.installed_version = self.addon.metadata.version
else:
self.addon.repo_type = Addon.Kind.WORKBENCH
self.addon.metadata = None
self.addon.installed_version = None
self.addon.updated_timestamp = QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
self.addon.branch = name
self.addon.set_status(Addon.Status.PENDING_RESTART)
self.ui.set_new_branch(name)
self.update_status.emit(self.addon)

View File

@@ -14,6 +14,9 @@
"HideNewerFreeCADRequired": true,
"HideObsolete": true,
"HidePy2": true,
"HideNonOSIApproved": false,
"HideNonFSFFreeLibre": false,
"HideUnlicensed": false,
"KnownPythonVersions": "[]",
"LastCacheUpdate": "never",
"MacroCacheUpdateFrequency": 7,

View File

@@ -23,50 +23,66 @@
""" A Qt Widget for displaying Addon README information """
import Addon
from PySide import QtCore, QtGui, QtWidgets
from enum import Enum, auto
from html.parser import HTMLParser
import addonmanager_freecad_interface as fci
import FreeCAD
from Addon import Addon
import addonmanager_utilities as utils
from enum import IntEnum, Enum, auto
from html.parser import HTMLParser
from typing import Optional
import NetworkManager
translate = fci.translate
translate = FreeCAD.Qt.translate
from PySide import QtCore, QtGui
class ReadmeViewer(QtWidgets.QTextBrowser):
class ReadmeDataType(IntEnum):
PlainText = 0
Markdown = 1
Html = 2
"""A QTextBrowser widget that, when given an Addon, downloads the README data as appropriate
and renders it with whatever technology is available (usually Qt's Markdown renderer for
workbenches and its HTML renderer for Macros)."""
def __init__(self, parent=None):
super().__init__(parent)
class ReadmeController(QtCore.QObject):
"""A class that can provide README data from an Addon, possibly loading external resources such
as images"""
def __init__(self, widget):
super().__init__()
NetworkManager.InitializeNetworkManager()
NetworkManager.AM_NETWORK_MANAGER.completed.connect(self._download_completed)
self.readme_request_index = 0
self.resource_requests = {}
self.resource_failures = []
self.url = ""
self.repo: Addon.Addon = None
self.setOpenExternalLinks(True)
self.setOpenLinks(True)
self.image_map = {}
self.readme_data = None
self.readme_data_type = None
self.addon: Optional[Addon] = None
self.stop = True
self.widget = widget
self.widget.load_resource.connect(self.loadResource)
def set_addon(self, repo: Addon):
"""Set which Addon's information is displayed"""
self.setPlainText(translate("AddonsInstaller", "Loading README data..."))
self.repo = repo
self.addon = repo
self.stop = False
if self.repo.repo_type == Addon.Addon.Kind.MACRO:
self.url = self.repo.macro.wiki
self.readme_data = None
if self.addon.repo_type == Addon.Kind.MACRO:
self.url = self.addon.macro.wiki
if not self.url:
self.url = self.repo.macro.url
self.url = self.addon.macro.url
else:
self.url = utils.get_readme_url(repo)
self.widget.setUrl(self.url)
self.widget.setText(
translate("AddonsInstaller", "Loading page for {} from {}...").format(
self.addon.display_name, self.url
)
)
self.readme_request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
self.url
)
@@ -77,7 +93,7 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
if code == 200: # HTTP success
self._process_package_download(data.data().decode("utf-8"))
else:
self.setPlainText(
self.widget.setText(
translate(
"AddonsInstaller",
"Failed to download data from {} -- received response code {}.",
@@ -87,47 +103,42 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
if code == 200:
self._process_resource_download(self.resource_requests[index], data.data())
else:
self.image_map[self.resource_requests[index]] = None
FreeCAD.Console.PrintLog(f"Failed to load {self.resource_requests[index]}\n")
self.resource_failures.append(self.resource_requests[index])
del self.resource_requests[index]
if not self.resource_requests:
self.set_addon(self.repo) # Trigger a reload of the page now with resources
if self.readme_data:
if self.readme_data_type == ReadmeDataType.Html:
self.widget.setHtml(self.readme_data)
elif self.readme_data_type == ReadmeDataType.Markdown:
self.widget.setMarkdown(self.readme_data)
else:
self.widget.setText(self.readme_data)
else:
self.set_addon(self.addon) # Trigger a reload of the page now with resources
def _process_package_download(self, data: str):
if self.repo.repo_type == Addon.Addon.Kind.MACRO:
if self.addon.repo_type == Addon.Kind.MACRO:
parser = WikiCleaner()
parser.feed(data)
self.setHtml(parser.final_html)
self.readme_data = parser.final_html
self.readme_data_type = ReadmeDataType.Html
self.widget.setHtml(parser.final_html)
else:
# Check for recent Qt (e.g. Qt5.15 or later). Check can be removed when
# we no longer support Ubuntu 20.04LTS for compiling.
if hasattr(self, "setMarkdown"):
self.setMarkdown(data)
else:
self.setPlainText(data)
self.readme_data = data
self.readme_data_type = ReadmeDataType.Markdown
self.widget.setMarkdown(data)
def _process_resource_download(self, resource_name: str, resource_data: bytes):
image = QtGui.QImage.fromData(resource_data)
if image:
self.image_map[resource_name] = self._ensure_appropriate_width(image)
else:
self.image_map[resource_name] = None
self.widget.set_resource(resource_name, image)
def loadResource(self, resource_type: int, name: QtCore.QUrl) -> object:
"""Callback for resource loading. Called automatically by underlying Qt
code when external resources are needed for rendering. In particular,
here it is used to download and cache (in RAM) the images needed for the
README and Wiki pages."""
if resource_type == QtGui.QTextDocument.ImageResource and not self.stop:
full_url = self._create_full_url(name.toString())
if full_url not in self.image_map:
self.image_map[full_url] = None
fci.Console.PrintMessage(f"Downloading image from {full_url}...\n")
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(full_url)
self.resource_requests[index] = full_url
return self.image_map[full_url]
return super().loadResource(resource_type, name)
def loadResource(self, full_url: str):
if full_url not in self.resource_failures:
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(full_url)
self.resource_requests[index] = full_url
def hideEvent(self, event: QtGui.QHideEvent):
def cancel_resource_loading(self):
self.stop = True
for request in self.resource_requests:
NetworkManager.AM_NETWORK_MANAGER.abort(request)
@@ -141,12 +152,6 @@ class ReadmeViewer(QtWidgets.QTextBrowser):
lhs, slash, _ = self.url.rpartition("/")
return lhs + slash + url
def _ensure_appropriate_width(self, image: QtGui.QImage) -> QtGui.QImage:
ninety_seven_percent = self.width() * 0.97
if image.width() < ninety_seven_percent:
return image
return image.scaledToWidth(ninety_seven_percent)
class WikiCleaner(HTMLParser):
"""This HTML parser cleans up FreeCAD Macro Wiki Page for display in a

View File

@@ -40,6 +40,7 @@ try:
except ImportError:
QtCore = None
QtWidgets = None
QtGui = None
import addonmanager_freecad_interface as fci
@@ -95,7 +96,7 @@ def symlink(source, link_name):
raise ctypes.WinError()
def rmdir(path: os.PathLike) -> bool:
def rmdir(path: str) -> bool:
try:
if os.path.islink(path):
os.unlink(path) # Remove symlink
@@ -379,7 +380,7 @@ def blocking_get(url: str, method=None) -> bytes:
p = b""
if fci.FreeCADGui and method is None or method == "networkmanager":
NetworkManager.InitializeNetworkManager()
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url, 10000) # 10 second timeout
if p:
try:
p = p.data()

View File

@@ -87,7 +87,7 @@ class UpdateMetadataCacheWorker(QtCore.QThread):
current_thread = QtCore.QThread.currentThread()
for repo in self.repos:
if repo.url and utils.recognized_git_location(repo):
if not repo.macro and repo.url and utils.recognized_git_location(repo):
# package.xml
index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
utils.construct_git_url(repo, "package.xml")
@@ -120,7 +120,6 @@ class UpdateMetadataCacheWorker(QtCore.QThread):
while self.requests:
if current_thread.isInterruptionRequested():
NetworkManager.AM_NETWORK_MANAGER.completed.disconnect(self.download_completed)
for request in self.requests:
NetworkManager.AM_NETWORK_MANAGER.abort(request)
return

View File

@@ -93,7 +93,7 @@ class CreateAddonListWorker(QtCore.QThread):
def _get_freecad_addon_repo_data(self):
# update info lists
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json"
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/addonflags.json", 5000
)
if p:
p = p.data().decode("utf8")
@@ -203,7 +203,7 @@ class CreateAddonListWorker(QtCore.QThread):
def _get_official_addons(self):
# querying official addons
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules"
"https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules", 5000
)
if not p:
return
@@ -369,7 +369,7 @@ class CreateAddonListWorker(QtCore.QThread):
"""
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
"https://wiki.freecad.org/Macros_recipes"
"https://wiki.freecad.org/Macros_recipes", 5000
)
if not p:
# The Qt Python translation extractor doesn't support splitting this string (yet)
@@ -859,7 +859,7 @@ class CacheMacroCodeWorker(QtCore.QThread):
).format(macro_name)
+ "\n"
)
worker.blockSignals(True)
# worker.blockSignals(True)
worker.requestInterruption()
worker.wait(100)
if worker.isRunning():
@@ -871,8 +871,6 @@ class CacheMacroCodeWorker(QtCore.QThread):
)
with self.lock:
self.failed.append(macro_name)
self.repo_queue.task_done()
self.counter += 1
class GetMacroDetailsWorker(QtCore.QThread):

View File

@@ -55,7 +55,9 @@ class ConnectionChecker(QtCore.QThread):
url = "https://api.github.com/zen"
self.done = False
NetworkManager.AM_NETWORK_MANAGER.completed.connect(self.connection_data_received)
self.request_id = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(url)
self.request_id = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(
url, timeout_ms=10000
)
while not self.done:
if QtCore.QThread.currentThread().isInterruptionRequested():
FreeCAD.Console.PrintLog("Connection check cancelled\n")

View File

@@ -42,7 +42,12 @@ class Ui_CompactView(object):
self.labelPackageName = QLabel(CompactView)
self.labelPackageName.setObjectName("labelPackageName")
self.labelPackageNameSpacer = QLabel(CompactView)
self.labelPackageNameSpacer.setText("")
self.labelPackageNameSpacer.setObjectName("labelPackageNameSpacer")
self.horizontalLayout_2.addWidget(self.labelPackageName)
self.horizontalLayout_2.addWidget(self.labelPackageNameSpacer)
self.labelVersion = QLabel(CompactView)
self.labelVersion.setObjectName("labelVersion")

View File

@@ -0,0 +1,56 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2024 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Provides a class for showing the list view and detail view at the same time. """
import addonmanager_freecad_interface
# Get whatever version of PySide we can
try:
import PySide # Use the FreeCAD wrapper
except ImportError:
try:
import PySide6 # Outside FreeCAD, try Qt6 first
PySide = PySide6
except ImportError:
import PySide2 # Fall back to Qt5 (if this fails, Python will kill this module's import)
PySide = PySide2
from PySide import QtCore, QtWidgets
class CompositeView(QtWidgets.QWidget):
"""A widget that displays the Addon Manager's top bar, the list of Addons, and the detail
view, all on a single pane (with no switching). Detail view is shown in its "icon-only" mode
for the installation, etc. buttons. The bottom bar remains visible throughout."""
def __init__(self, parent=None):
super().__init__(parent)
# TODO: Refactor the Addon Manager's display into four custom widgets:
# 1) The top bar showing the filter and search
# 2) The package list widget, which can take three forms (expanded, compact, and list)
# 3) The installer bar, which can take two forms (text and icon)
# 4) The bottom bar

View File

@@ -1,633 +0,0 @@
# SPDX-License-Identifier: LGPL-2.1-or-later
# ***************************************************************************
# * *
# * Copyright (c) 2022-2023 FreeCAD Project Association *
# * *
# * This file is part of FreeCAD. *
# * *
# * FreeCAD is free software: you can redistribute it and/or modify it *
# * under the terms of the GNU Lesser General Public License as *
# * published by the Free Software Foundation, either version 2.1 of the *
# * License, or (at your option) any later version. *
# * *
# * FreeCAD is distributed in the hope that it will be useful, but *
# * WITHOUT ANY WARRANTY; without even the implied warranty of *
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
# * Lesser General Public License for more details. *
# * *
# * You should have received a copy of the GNU Lesser General Public *
# * License along with FreeCAD. If not, see *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" Provides the PackageDetails widget. """
import os
from typing import Optional
from PySide import QtCore, QtGui, QtWidgets
import addonmanager_freecad_interface as fci
import addonmanager_utilities as utils
from addonmanager_metadata import (
Version,
get_first_supported_freecad_version,
get_branch_from_metadata,
)
from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker
from addonmanager_readme_viewer import ReadmeViewer
from addonmanager_git import GitManager, NoGitFound
from Addon import Addon
from change_branch import ChangeBranchDialog
translate = fci.translate
class PackageDetails(QtWidgets.QWidget):
"""The PackageDetails QWidget shows package README information and provides
install, uninstall, and update buttons."""
back = QtCore.Signal()
install = QtCore.Signal(Addon)
uninstall = QtCore.Signal(Addon)
update = QtCore.Signal(Addon)
execute = QtCore.Signal(Addon)
update_status = QtCore.Signal(Addon)
check_for_update = QtCore.Signal(Addon)
def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_PackageDetails()
self.ui.setupUi(self)
self.worker = None
self.repo = None
self.status_update_thread = None
try:
self.git_manager = GitManager()
except NoGitFound:
self.git_manager = None
self.ui.buttonBack.clicked.connect(self.back.emit)
self.ui.buttonExecute.clicked.connect(lambda: self.execute.emit(self.repo))
self.ui.buttonInstall.clicked.connect(lambda: self.install.emit(self.repo))
self.ui.buttonUninstall.clicked.connect(lambda: self.uninstall.emit(self.repo))
self.ui.buttonUpdate.clicked.connect(lambda: self.update.emit(self.repo))
self.ui.buttonCheckForUpdate.clicked.connect(lambda: self.check_for_update.emit(self.repo))
self.ui.buttonChangeBranch.clicked.connect(self.change_branch_clicked)
self.ui.buttonEnable.clicked.connect(self.enable_clicked)
self.ui.buttonDisable.clicked.connect(self.disable_clicked)
def show_repo(self, repo: Addon, reload: bool = False) -> None:
"""The main entry point for this class, shows the package details and related buttons
for the provided repo. If reload is true, then even if this is already the current repo
the data is reloaded."""
# If this is the same repo we were already showing, we do not have to do the
# expensive refetch unless reload is true
if True or self.repo != repo or reload:
self.repo = repo
if self.worker is not None:
if not self.worker.isFinished():
self.worker.requestInterruption()
self.worker.wait()
if repo.repo_type == Addon.Kind.MACRO:
self.show_macro(repo)
self.ui.buttonExecute.show()
elif repo.repo_type == Addon.Kind.WORKBENCH:
self.show_workbench(repo)
self.ui.buttonExecute.hide()
elif repo.repo_type == Addon.Kind.PACKAGE:
self.show_package(repo)
self.ui.buttonExecute.hide()
if repo.status() == Addon.Status.UNCHECKED:
if not self.status_update_thread:
self.status_update_thread = QtCore.QThread()
self.status_create_addon_list_worker = CheckSingleUpdateWorker(repo)
self.status_create_addon_list_worker.moveToThread(self.status_update_thread)
self.status_update_thread.finished.connect(
self.status_create_addon_list_worker.deleteLater
)
self.check_for_update.connect(self.status_create_addon_list_worker.do_work)
self.status_create_addon_list_worker.update_status.connect(self.display_repo_status)
self.status_update_thread.start()
self.check_for_update.emit(self.repo)
self.display_repo_status(self.repo.update_status)
def display_repo_status(self, status):
"""Updates the contents of the widget to display the current install status of the widget."""
repo = self.repo
self.set_change_branch_button_state()
self.set_disable_button_state()
if status != Addon.Status.NOT_INSTALLED:
version = repo.installed_version
date = ""
installed_version_string = "<h3>"
if repo.updated_timestamp:
date = QtCore.QLocale().toString(
QtCore.QDateTime.fromSecsSinceEpoch(int(round(repo.updated_timestamp, 0))),
QtCore.QLocale.ShortFormat,
)
if version and date:
installed_version_string += (
translate("AddonsInstaller", "Version {version} installed on {date}").format(
version=version, date=date
)
+ ". "
)
elif version:
installed_version_string += (
translate("AddonsInstaller", "Version {version} installed") + ". "
).format(version=version)
elif date:
installed_version_string += (
translate("AddonsInstaller", "Installed on {date}") + ". "
).format(date=date)
else:
installed_version_string += translate("AddonsInstaller", "Installed") + ". "
if status == Addon.Status.UPDATE_AVAILABLE:
if repo.metadata:
name_change = False
if repo.installed_metadata:
old_branch = get_branch_from_metadata(repo.installed_metadata)
new_branch = get_branch_from_metadata(repo.metadata)
if old_branch != new_branch:
installed_version_string += (
"<b>"
+ translate(
"AddonsInstaller",
"Currently on branch {}, name changed to {}",
).format(old_branch, new_branch)
) + ".</b> "
name_change = True
if not name_change:
installed_version_string += (
"<b>"
+ translate(
"AddonsInstaller",
"On branch {}, update available to version",
).format(repo.branch)
+ " "
)
installed_version_string += str(repo.metadata.version)
installed_version_string += ".</b>"
elif repo.macro and repo.macro.version:
installed_version_string += (
"<b>" + translate("AddonsInstaller", "Update available to version") + " "
)
installed_version_string += repo.macro.version
installed_version_string += ".</b>"
else:
installed_version_string += (
"<b>"
+ translate(
"AddonsInstaller",
"An update is available",
)
+ ".</b>"
)
elif status == Addon.Status.NO_UPDATE_AVAILABLE:
detached_head = False
branch = repo.branch
if self.git_manager and repo.repo_type != Addon.Kind.MACRO:
basedir = fci.getUserAppDataDir()
moddir = os.path.join(basedir, "Mod", repo.name)
repo_path = os.path.join(moddir, ".git")
if os.path.exists(repo_path):
branch = self.git_manager.current_branch(repo_path)
if self.git_manager.detached_head(repo_path):
tag = self.git_manager.current_tag(repo_path)
branch = tag
detached_head = True
if detached_head:
installed_version_string += (
translate(
"AddonsInstaller",
"Git tag '{}' checked out, no updates possible",
).format(branch)
+ "."
)
else:
installed_version_string += (
translate(
"AddonsInstaller",
"This is the latest version available for branch {}",
).format(branch)
+ "."
)
elif status == Addon.Status.PENDING_RESTART:
installed_version_string += (
translate("AddonsInstaller", "Updated, please restart FreeCAD to use") + "."
)
elif status == Addon.Status.UNCHECKED:
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
autocheck = pref.GetBool("AutoCheck", False)
if autocheck:
installed_version_string += (
translate("AddonsInstaller", "Update check in progress") + "."
)
else:
installed_version_string += (
translate("AddonsInstaller", "Automatic update checks disabled") + "."
)
installed_version_string += "</h3>"
self.ui.labelPackageDetails.setText(installed_version_string)
if repo.status() == Addon.Status.UPDATE_AVAILABLE:
self.ui.labelPackageDetails.setStyleSheet("color:" + utils.attention_color_string())
else:
self.ui.labelPackageDetails.setStyleSheet("color:" + utils.bright_color_string())
self.ui.labelPackageDetails.show()
if repo.macro is not None:
moddir = fci.getUserMacroDir(True)
else:
basedir = fci.getUserAppDataDir()
moddir = os.path.join(basedir, "Mod", repo.name)
installationLocationString = (
translate("AddonsInstaller", "Installation location")
+ ": "
+ os.path.normpath(moddir)
)
self.ui.labelInstallationLocation.setText(installationLocationString)
self.ui.labelInstallationLocation.show()
else:
self.ui.labelPackageDetails.hide()
self.ui.labelInstallationLocation.hide()
if status == Addon.Status.NOT_INSTALLED:
self.ui.buttonInstall.show()
self.ui.buttonUninstall.hide()
self.ui.buttonUpdate.hide()
self.ui.buttonCheckForUpdate.hide()
elif status == Addon.Status.NO_UPDATE_AVAILABLE:
self.ui.buttonInstall.hide()
self.ui.buttonUninstall.show()
self.ui.buttonUpdate.hide()
self.ui.buttonCheckForUpdate.hide()
elif status == Addon.Status.UPDATE_AVAILABLE:
self.ui.buttonInstall.hide()
self.ui.buttonUninstall.show()
self.ui.buttonUpdate.show()
self.ui.buttonCheckForUpdate.hide()
elif status == Addon.Status.UNCHECKED:
self.ui.buttonInstall.hide()
self.ui.buttonUninstall.show()
self.ui.buttonUpdate.hide()
self.ui.buttonCheckForUpdate.show()
elif status == Addon.Status.PENDING_RESTART:
self.ui.buttonInstall.hide()
self.ui.buttonUninstall.show()
self.ui.buttonUpdate.hide()
self.ui.buttonCheckForUpdate.hide()
elif status == Addon.Status.CANNOT_CHECK:
self.ui.buttonInstall.hide()
self.ui.buttonUninstall.show()
self.ui.buttonUpdate.show()
self.ui.buttonCheckForUpdate.hide()
required_version = self.requires_newer_freecad()
if repo.obsolete:
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h1>" + translate("AddonsInstaller", "WARNING: This addon is obsolete") + "</h1>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
elif repo.python2:
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h1>"
+ translate("AddonsInstaller", "WARNING: This addon is Python 2 Only")
+ "</h1>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
elif required_version:
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h1>"
+ translate("AddonsInstaller", "WARNING: This addon requires FreeCAD ")
+ required_version
+ "</h1>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
elif repo.is_disabled():
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h2>"
+ translate(
"AddonsInstaller",
"WARNING: This addon is currently installed, but disabled. Use the 'enable' button to re-enable.",
)
+ "</h2>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.warning_color_string())
else:
self.ui.labelWarningInfo.hide()
def requires_newer_freecad(self) -> Optional[Version]:
"""If the current package is not installed, returns the first supported version of
FreeCAD, if one is set, or None if no information is available (or if the package is
already installed)."""
# If it's not installed, check to see if it's for a newer version of FreeCAD
if self.repo.status() == Addon.Status.NOT_INSTALLED and self.repo.metadata:
# Only hide if ALL content items require a newer version, otherwise
# it's possible that this package actually provides versions of itself
# for newer and older versions
first_supported_version = get_first_supported_freecad_version(self.repo.metadata)
if first_supported_version is not None:
fc_version = Version(from_list=fci.Version())
if first_supported_version > fc_version:
return first_supported_version
return None
def set_change_branch_button_state(self):
"""The change branch button is only available for installed Addons that have a .git directory
and in runs where the git is available."""
self.ui.buttonChangeBranch.hide()
pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons")
show_switcher = pref.GetBool("ShowBranchSwitcher", False)
if not show_switcher:
return
# Is this repo installed? If not, return.
if self.repo.status() == Addon.Status.NOT_INSTALLED:
return
# Is it a Macro? If so, return:
if self.repo.repo_type == Addon.Kind.MACRO:
return
# Can we actually switch branches? If not, return.
if not self.git_manager:
return
# Is there a .git subdirectory? If not, return.
basedir = fci.getUserAppDataDir()
path_to_git = os.path.join(basedir, "Mod", self.repo.name, ".git")
if not os.path.isdir(path_to_git):
return
# If all four above checks passed, then it's possible for us to switch
# branches, if there are any besides the one we are on: show the button
self.ui.buttonChangeBranch.show()
def set_disable_button_state(self):
"""Set up the enable/disable button based on the enabled/disabled state of the addon"""
self.ui.buttonEnable.hide()
self.ui.buttonDisable.hide()
status = self.repo.status()
if status != Addon.Status.NOT_INSTALLED:
disabled = self.repo.is_disabled()
if disabled:
self.ui.buttonEnable.show()
else:
self.ui.buttonDisable.show()
def show_workbench(self, repo: Addon) -> None:
"""loads information of a given workbench"""
self.ui.textBrowserReadMe.set_addon(repo)
def show_package(self, repo: Addon) -> None:
"""Show the details for a package (a repo with a package.xml metadata file)"""
self.ui.textBrowserReadMe.set_addon(repo)
def show_macro(self, repo: Addon) -> None:
"""loads information of a given macro"""
if not repo.macro.url:
# We need to populate the macro information... may as well do it while the user reads the wiki page
self.worker = GetMacroDetailsWorker(repo)
self.worker.readme_updated.connect(self.macro_readme_updated)
self.worker.start()
else:
self.macro_readme_updated()
def macro_readme_updated(self):
"""Update the display of a Macro's README data."""
self.ui.textBrowserReadMe.set_addon(self.repo)
def change_branch_clicked(self) -> None:
"""Loads the branch-switching dialog"""
basedir = fci.getUserAppDataDir()
path_to_repo = os.path.join(basedir, "Mod", self.repo.name)
change_branch_dialog = ChangeBranchDialog(path_to_repo, self)
change_branch_dialog.branch_changed.connect(self.branch_changed)
change_branch_dialog.exec()
def enable_clicked(self) -> None:
"""Called by the Enable button, enables this Addon and updates GUI to reflect
that status."""
self.repo.enable()
self.repo.set_status(Addon.Status.PENDING_RESTART)
self.set_disable_button_state()
self.update_status.emit(self.repo)
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h3>"
+ translate(
"AddonsInstaller",
"This Addon will be enabled next time you restart FreeCAD.",
)
+ "</h3>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.bright_color_string())
def disable_clicked(self) -> None:
"""Called by the Disable button, disables this Addon and updates the GUI to
reflect that status."""
self.repo.disable()
self.repo.set_status(Addon.Status.PENDING_RESTART)
self.set_disable_button_state()
self.update_status.emit(self.repo)
self.ui.labelWarningInfo.show()
self.ui.labelWarningInfo.setText(
"<h3>"
+ translate(
"AddonsInstaller",
"This Addon will be disabled next time you restart FreeCAD.",
)
+ "</h3>"
)
self.ui.labelWarningInfo.setStyleSheet("color:" + utils.attention_color_string())
def branch_changed(self, name: str) -> None:
"""Displays a dialog confirming the branch changed, and tries to access the
metadata file from that branch."""
QtWidgets.QMessageBox.information(
self,
translate("AddonsInstaller", "Success"),
translate(
"AddonsInstaller",
"Branch change succeeded, please restart to use the new version.",
),
)
# See if this branch has a package.xml file:
basedir = fci.getUserAppDataDir()
path_to_metadata = os.path.join(basedir, "Mod", self.repo.name, "package.xml")
if os.path.isfile(path_to_metadata):
self.repo.load_metadata_file(path_to_metadata)
self.repo.installed_version = self.repo.metadata.version
else:
self.repo.repo_type = Addon.Kind.WORKBENCH
self.repo.metadata = None
self.repo.installed_version = None
self.repo.updated_timestamp = QtCore.QDateTime.currentDateTime().toSecsSinceEpoch()
self.repo.branch = name
self.repo.set_status(Addon.Status.PENDING_RESTART)
installed_version_string = "<h3>"
installed_version_string += translate(
"AddonsInstaller", "Changed to git ref '{}' -- please restart to use Addon."
).format(name)
installed_version_string += "</h3>"
self.ui.labelPackageDetails.setText(installed_version_string)
self.ui.labelPackageDetails.setStyleSheet("color:" + utils.attention_color_string())
self.update_status.emit(self.repo)
class Ui_PackageDetails(object):
"""The generated UI from the Qt Designer UI file"""
def setupUi(self, PackageDetails):
if not PackageDetails.objectName():
PackageDetails.setObjectName("PackageDetails")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(PackageDetails)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.layoutDetailsBackButton = QtWidgets.QHBoxLayout()
self.layoutDetailsBackButton.setObjectName("layoutDetailsBackButton")
self.buttonBack = QtWidgets.QToolButton(PackageDetails)
self.buttonBack.setObjectName("buttonBack")
self.buttonBack.setIcon(
QtGui.QIcon.fromTheme("back", QtGui.QIcon(":/icons/button_left.svg"))
)
self.layoutDetailsBackButton.addWidget(self.buttonBack)
self.horizontalSpacer = QtWidgets.QSpacerItem(
40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum
)
self.layoutDetailsBackButton.addItem(self.horizontalSpacer)
self.buttonInstall = QtWidgets.QPushButton(PackageDetails)
self.buttonInstall.setObjectName("buttonInstall")
self.layoutDetailsBackButton.addWidget(self.buttonInstall)
self.buttonUninstall = QtWidgets.QPushButton(PackageDetails)
self.buttonUninstall.setObjectName("buttonUninstall")
self.layoutDetailsBackButton.addWidget(self.buttonUninstall)
self.buttonUpdate = QtWidgets.QPushButton(PackageDetails)
self.buttonUpdate.setObjectName("buttonUpdate")
self.layoutDetailsBackButton.addWidget(self.buttonUpdate)
self.buttonCheckForUpdate = QtWidgets.QPushButton(PackageDetails)
self.buttonCheckForUpdate.setObjectName("buttonCheckForUpdate")
self.layoutDetailsBackButton.addWidget(self.buttonCheckForUpdate)
self.buttonChangeBranch = QtWidgets.QPushButton(PackageDetails)
self.buttonChangeBranch.setObjectName("buttonChangeBranch")
self.layoutDetailsBackButton.addWidget(self.buttonChangeBranch)
self.buttonExecute = QtWidgets.QPushButton(PackageDetails)
self.buttonExecute.setObjectName("buttonExecute")
self.layoutDetailsBackButton.addWidget(self.buttonExecute)
self.buttonDisable = QtWidgets.QPushButton(PackageDetails)
self.buttonDisable.setObjectName("buttonDisable")
self.layoutDetailsBackButton.addWidget(self.buttonDisable)
self.buttonEnable = QtWidgets.QPushButton(PackageDetails)
self.buttonEnable.setObjectName("buttonEnable")
self.layoutDetailsBackButton.addWidget(self.buttonEnable)
self.verticalLayout_2.addLayout(self.layoutDetailsBackButton)
self.labelPackageDetails = QtWidgets.QLabel(PackageDetails)
self.labelPackageDetails.hide()
self.verticalLayout_2.addWidget(self.labelPackageDetails)
self.labelInstallationLocation = QtWidgets.QLabel(PackageDetails)
self.labelInstallationLocation.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
self.labelInstallationLocation.hide()
self.verticalLayout_2.addWidget(self.labelInstallationLocation)
self.labelWarningInfo = QtWidgets.QLabel(PackageDetails)
self.labelWarningInfo.hide()
self.verticalLayout_2.addWidget(self.labelWarningInfo)
sizePolicy1 = QtWidgets.QSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
)
sizePolicy1.setHorizontalStretch(0)
sizePolicy1.setVerticalStretch(0)
self.textBrowserReadMe = ReadmeViewer(PackageDetails)
self.textBrowserReadMe.setObjectName("textBrowserReadMe")
self.verticalLayout_2.addWidget(self.textBrowserReadMe)
self.retranslateUi(PackageDetails)
QtCore.QMetaObject.connectSlotsByName(PackageDetails)
# setupUi
def retranslateUi(self, _):
self.buttonBack.setText("")
self.buttonInstall.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Install", None)
)
self.buttonUninstall.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Uninstall", None)
)
self.buttonUpdate.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Update", None)
)
self.buttonCheckForUpdate.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Check for Update", None)
)
self.buttonExecute.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Run Macro", None)
)
self.buttonChangeBranch.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Change Branch", None)
)
self.buttonEnable.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Enable", None)
)
self.buttonDisable.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Disable", None)
)
self.buttonBack.setToolTip(
QtCore.QCoreApplication.translate("AddonsInstaller", "Return to package list", None)
)
# retranslateUi

View File

@@ -37,6 +37,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_selector import AddonManagerDisplayStyle
from Widgets.addonmanager_widget_filter_selector import StatusFilter, Filter, ContentFilter
from Widgets.addonmanager_widget_progress_bar import WidgetProgressBar
from addonmanager_licenses import get_license_manager, SPDXLicenseManager
translate = FreeCAD.Qt.translate
@@ -44,22 +49,6 @@ translate = FreeCAD.Qt.translate
# pylint: disable=too-few-public-methods
class ListDisplayStyle(IntEnum):
"""The display mode of the list"""
COMPACT = 0
EXPANDED = 1
class StatusFilter(IntEnum):
"""Predefined filers"""
ANY = 0
INSTALLED = 1
NOT_INSTALLED = 2
UPDATE_AVAILABLE = 3
class PackageList(QtWidgets.QWidget):
"""A widget that shows a list of packages and various widgets to control the
display of the list"""
@@ -77,25 +66,18 @@ class PackageList(QtWidgets.QWidget):
self.ui.listPackages.setItemDelegate(self.item_delegate)
self.ui.listPackages.clicked.connect(self.on_listPackages_clicked)
self.ui.comboPackageType.currentIndexChanged.connect(self.update_type_filter)
self.ui.comboStatus.currentIndexChanged.connect(self.update_status_filter)
self.ui.lineEditFilter.textChanged.connect(self.update_text_filter)
self.ui.buttonCompactLayout.clicked.connect(
lambda: self.set_view_style(ListDisplayStyle.COMPACT)
)
self.ui.buttonExpandedLayout.clicked.connect(
lambda: self.set_view_style(ListDisplayStyle.EXPANDED)
)
# Only shows when the user types in a filter
self.ui.labelFilterValidity.hide()
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)
# Set up the view the same as the last time:
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
package_type = pref.GetInt("PackageTypeSelection", 1)
self.ui.comboPackageType.setCurrentIndex(package_type)
status = pref.GetInt("StatusSelection", 0)
self.ui.comboStatus.setCurrentIndex(status)
self.ui.view_bar.filter_selector.set_contents_filter(package_type)
self.ui.view_bar.filter_selector.set_status_filter(status)
self.item_filter.setPackageFilter(package_type)
self.item_filter.setStatusFilter(status)
# Pre-init of other members:
self.item_model = None
@@ -107,16 +89,18 @@ class PackageList(QtWidgets.QWidget):
self.item_filter.sort(0)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
style = pref.GetInt("ViewStyle", ListDisplayStyle.EXPANDED)
style = pref.GetInt("ViewStyle", AddonManagerDisplayStyle.EXPANDED)
self.set_view_style(style)
if style == ListDisplayStyle.EXPANDED:
self.ui.buttonExpandedLayout.setChecked(True)
else:
self.ui.buttonCompactLayout.setChecked(True)
self.ui.view_bar.view_selector.set_current_view(style)
self.item_filter.setHidePy2(pref.GetBool("HidePy2", True))
self.item_filter.setHideObsolete(pref.GetBool("HideObsolete", True))
self.item_filter.setHideNewerFreeCADRequired(pref.GetBool("HideNewerFreeCADRequired", True))
self.item_filter.setHideNonOSIApproved(pref.GetBool("HideNonOSIApproved", True))
self.item_filter.setHideNonFSFLibre(pref.GetBool("HideNonFSFFreeLibre", False))
self.item_filter.setHideNewerFreeCADRequired(
pref.GetBool("HideNewerFreeCADRequired", False)
)
self.item_filter.setHideUnlicensed(pref.GetBool("HideUnlicensed", False))
def on_listPackages_clicked(self, index: QtCore.QModelIndex):
"""Determine what addon was selected and emit the itemSelected signal with it as
@@ -125,63 +109,22 @@ class PackageList(QtWidgets.QWidget):
selected_repo = self.item_model.repos[source_selection.row()]
self.itemSelected.emit(selected_repo)
def update_type_filter(self, type_filter: int) -> None:
"""hide/show rows corresponding to the type filter
def update_status_filter(self, new_filter: Filter) -> None:
"""hide/show rows corresponding to the specified filter"""
type_filter is an integer: 0 for all, 1 for workbenches, 2 for macros,
and 3 for preference packs
"""
self.item_filter.setPackageFilter(type_filter)
self.item_filter.setStatusFilter(new_filter.status_filter)
self.item_filter.setPackageFilter(new_filter.content_filter)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetInt("PackageTypeSelection", type_filter)
pref.SetInt("StatusSelection", new_filter.status_filter)
pref.SetInt("PackageTypeSelection", new_filter.content_filter)
self.item_filter.invalidateFilter()
def update_status_filter(self, status_filter: int) -> None:
"""hide/show rows corresponding to the status filter
status_filter is an integer: 0 for any, 1 for installed, 2 for not installed,
and 3 for update available
"""
self.item_filter.setStatusFilter(status_filter)
pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
pref.SetInt("StatusSelection", status_filter)
def update_text_filter(self, text_filter: str) -> None:
"""filter name and description by the regex specified by text_filter"""
if text_filter:
if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
test_regex = QtCore.QRegularExpression(text_filter)
else:
test_regex = QtCore.QRegExp(text_filter)
if test_regex.isValid():
self.ui.labelFilterValidity.setToolTip(
translate("AddonsInstaller", "Filter is valid")
)
icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg"))
self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
else:
self.ui.labelFilterValidity.setToolTip(
translate("AddonsInstaller", "Filter regular expression is invalid")
)
icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg"))
self.ui.labelFilterValidity.setPixmap(icon.pixmap(16, 16))
self.ui.labelFilterValidity.show()
else:
self.ui.labelFilterValidity.hide()
if hasattr(self.item_filter, "setFilterRegularExpression"): # Added in Qt 5.12
self.item_filter.setFilterRegularExpression(text_filter)
else:
self.item_filter.setFilterRegExp(text_filter)
def set_view_style(self, style: ListDisplayStyle) -> None:
def set_view_style(self, style: AddonManagerDisplayStyle) -> None:
"""Set the style (compact or expanded) of the list"""
self.item_model.layoutAboutToBeChanged.emit()
self.item_delegate.set_view(style)
if style == ListDisplayStyle.COMPACT:
# TODO: Update to support composite
if style == AddonManagerDisplayStyle.COMPACT:
self.ui.listPackages.setSpacing(2)
else:
self.ui.listPackages.setSpacing(5)
@@ -324,12 +267,12 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def __init__(self, parent=None):
super().__init__(parent)
self.displayStyle = ListDisplayStyle.EXPANDED
self.displayStyle = AddonManagerDisplayStyle.EXPANDED
self.expanded = ExpandedView()
self.compact = CompactView()
self.widget = self.expanded
def set_view(self, style: ListDisplayStyle) -> None:
def set_view(self, style: AddonManagerDisplayStyle) -> None:
"""Set the view of to style"""
if not self.displayStyle == style:
self.displayStyle = style
@@ -343,7 +286,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
def update_content(self, index):
"""Creates the display of the content for a given index."""
repo = index.data(PackageListItemModel.DataAccessRole)
if self.displayStyle == ListDisplayStyle.EXPANDED:
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)))
@@ -353,23 +296,23 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
self.widget.ui.labelIcon.setPixmap(repo.icon.pixmap(QtCore.QSize(16, 16)))
self.widget.ui.labelIcon.setText("")
if self.displayStyle == ListDisplayStyle.EXPANDED:
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 == ListDisplayStyle.EXPANDED:
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 == ListDisplayStyle.EXPANDED:
if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
self.widget.ui.labelMaintainer.setText("")
# Update status
if self.displayStyle == ListDisplayStyle.EXPANDED:
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))
@@ -417,7 +360,7 @@ class PackageListItemDelegate(QtWidgets.QStyledItemDelegate):
+ repo.macro.date
)
self.widget.ui.labelVersion.setText("<i>" + version_string + "</i>")
if self.displayStyle == ListDisplayStyle.EXPANDED:
if self.displayStyle == AddonManagerDisplayStyle.EXPANDED:
if repo.macro.author:
caption = translate("AddonsInstaller", "Author")
self.widget.ui.labelMaintainer.setText(caption + ": " + repo.macro.author)
@@ -531,6 +474,9 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
self.hide_obsolete = False
self.hide_py2 = False
self.hide_non_OSI_approved = False
self.hide_non_FSF_libre = False
self.hide_unlicensed = False
self.hide_newer_freecad_required = False
def setPackageFilter(
@@ -557,6 +503,21 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
self.hide_obsolete = hide_obsolete
self.invalidateFilter()
def setHideNonOSIApproved(self, hide: bool) -> None:
"""Sets whether to hide Addons with non-OSI-approved licenses"""
self.hide_non_OSI_approved = hide
self.invalidateFilter()
def setHideNonFSFLibre(self, hide: bool) -> None:
"""Sets whether to hide Addons with non-FSF-Libre licenses"""
self.hide_non_FSF_libre = hide
self.invalidateFilter()
def setHideUnlicensed(self, hide: bool) -> None:
"""Sets whether to hide addons without a specified license"""
self.hide_unlicensed = hide
self.invalidateFilter()
def setHideNewerFreeCADRequired(self, hide_nfr: bool) -> None:
"""Sets whether to hide packages that have indicated they need a newer version
of FreeCAD than the one currently running."""
@@ -596,13 +557,58 @@ class PackageListFilter(QtCore.QSortFilterProxyModel):
if data.status() != Addon.Status.UPDATE_AVAILABLE:
return False
# If it's not installed, check to see if it's Py2 only
if data.status() == Addon.Status.NOT_INSTALLED and self.hide_py2 and data.python2:
return False
license_manager = get_license_manager()
if data.status() == Addon.Status.NOT_INSTALLED:
# If it's not installed, check to see if it's marked obsolete
if data.status() == Addon.Status.NOT_INSTALLED and self.hide_obsolete and data.obsolete:
return False
# If it's not installed, check to see if it's Py2 only
if self.hide_py2 and data.python2:
return False
# If it's not installed, check to see if it's marked obsolete
if self.hide_obsolete and data.obsolete:
return False
if self.hide_unlicensed:
if not data.license or data.license in ["UNLICENSED", "UNLICENCED"]:
FreeCAD.Console.PrintLog(f"Hiding {data.name} because it has no license set\n")
return False
# If it is not an OSI-approved license, check to see if we are hiding those
if self.hide_non_OSI_approved or self.hide_non_FSF_libre:
if not data.license:
return False
licenses_to_check = []
if type(data.license) is str:
licenses_to_check.append(data.license)
elif type(data.license) is list:
for license_id in data.license:
if type(license_id) is str:
licenses_to_check.append(license_id)
else:
licenses_to_check.append(license_id.name)
else:
licenses_to_check.append(data.license.name)
fsf_libre = False
osi_approved = False
for license_id in licenses_to_check:
if not osi_approved and license_manager.is_osi_approved(license_id):
osi_approved = True
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"
)
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"
)
return False
# If it's not installed, check to see if it's for a newer version of FreeCAD
if (
@@ -665,65 +671,10 @@ class Ui_PackageList:
self.verticalLayout.setObjectName("verticalLayout")
self.horizontalLayout_6 = QtWidgets.QHBoxLayout()
self.horizontalLayout_6.setObjectName("horizontalLayout_6")
self.buttonCompactLayout = QtWidgets.QToolButton(form)
self.buttonCompactLayout.setObjectName("buttonCompactLayout")
self.buttonCompactLayout.setCheckable(True)
self.buttonCompactLayout.setAutoExclusive(True)
self.buttonCompactLayout.setIcon(
QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/compact_view.svg"))
)
self.horizontalLayout_6.addWidget(self.buttonCompactLayout)
self.buttonExpandedLayout = QtWidgets.QToolButton(form)
self.buttonExpandedLayout.setObjectName("buttonExpandedLayout")
self.buttonExpandedLayout.setCheckable(True)
self.buttonExpandedLayout.setChecked(True)
self.buttonExpandedLayout.setAutoExclusive(True)
self.buttonExpandedLayout.setIcon(
QtGui.QIcon.fromTheme("expanded_view", QtGui.QIcon(":/icons/expanded_view.svg"))
)
self.horizontalLayout_6.addWidget(self.buttonExpandedLayout)
self.labelPackagesContaining = QtWidgets.QLabel(form)
self.labelPackagesContaining.setObjectName("labelPackagesContaining")
self.horizontalLayout_6.addWidget(self.labelPackagesContaining)
self.comboPackageType = QtWidgets.QComboBox(form)
self.comboPackageType.addItem("")
self.comboPackageType.addItem("")
self.comboPackageType.addItem("")
self.comboPackageType.addItem("")
self.comboPackageType.setObjectName("comboPackageType")
self.horizontalLayout_6.addWidget(self.comboPackageType)
self.labelStatus = QtWidgets.QLabel(form)
self.labelStatus.setObjectName("labelStatus")
self.horizontalLayout_6.addWidget(self.labelStatus)
self.comboStatus = QtWidgets.QComboBox(form)
self.comboStatus.addItem("")
self.comboStatus.addItem("")
self.comboStatus.addItem("")
self.comboStatus.addItem("")
self.comboStatus.setObjectName("comboStatus")
self.horizontalLayout_6.addWidget(self.comboStatus)
self.lineEditFilter = QtWidgets.QLineEdit(form)
self.lineEditFilter.setObjectName("lineEditFilter")
self.lineEditFilter.setClearButtonEnabled(True)
self.horizontalLayout_6.addWidget(self.lineEditFilter)
self.labelFilterValidity = QtWidgets.QLabel(form)
self.labelFilterValidity.setObjectName("labelFilterValidity")
self.horizontalLayout_6.addWidget(self.labelFilterValidity)
self.view_bar = WidgetViewControlBar(form)
self.view_bar.setObjectName("ViewControlBar")
self.horizontalLayout_6.addWidget(self.view_bar)
self.verticalLayout.addLayout(self.horizontalLayout_6)
@@ -739,49 +690,7 @@ class Ui_PackageList:
self.verticalLayout.addWidget(self.listPackages)
self.retranslateUi(form)
self.progressBar = WidgetProgressBar()
self.verticalLayout.addWidget(self.progressBar)
QtCore.QMetaObject.connectSlotsByName(form)
def retranslateUi(self, _):
self.labelPackagesContaining.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Show Addons containing:", None)
)
self.comboPackageType.setItemText(
0, QtCore.QCoreApplication.translate("AddonsInstaller", "All", None)
)
self.comboPackageType.setItemText(
1, QtCore.QCoreApplication.translate("AddonsInstaller", "Workbenches", None)
)
self.comboPackageType.setItemText(
2, QtCore.QCoreApplication.translate("AddonsInstaller", "Macros", None)
)
self.comboPackageType.setItemText(
3,
QtCore.QCoreApplication.translate("AddonsInstaller", "Preference Packs", None),
)
self.labelStatus.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Status:", None)
)
self.comboStatus.setItemText(
StatusFilter.ANY,
QtCore.QCoreApplication.translate("AddonsInstaller", "Any", None),
)
self.comboStatus.setItemText(
StatusFilter.INSTALLED,
QtCore.QCoreApplication.translate("AddonsInstaller", "Installed", None),
)
self.comboStatus.setItemText(
StatusFilter.NOT_INSTALLED,
QtCore.QCoreApplication.translate("AddonsInstaller", "Not installed", None),
)
self.comboStatus.setItemText(
StatusFilter.UPDATE_AVAILABLE,
QtCore.QCoreApplication.translate("AddonsInstaller", "Update available", None),
)
self.lineEditFilter.setPlaceholderText(
QtCore.QCoreApplication.translate("AddonsInstaller", "Filter", None)
)
self.labelFilterValidity.setText(
QtCore.QCoreApplication.translate("AddonsInstaller", "OK", None)
)

0
src/__init__.py Normal file
View File