Merge pull request #12309 from chennes/addonManagerRefactorGUIArea
Addon manager: Refactor GUI area
This commit is contained in:
@@ -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"""
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
58
src/Mod/AddonManager/AddonStats.py
Normal file
58
src/Mod/AddonManager/AddonStats.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
15
src/Mod/AddonManager/Resources/icons/composite_view.svg
Normal file
15
src/Mod/AddonManager/Resources/icons/composite_view.svg
Normal 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 |
7835
src/Mod/AddonManager/Resources/licenses/spdx.json
Normal file
7835
src/Mod/AddonManager/Resources/licenses/spdx.json
Normal file
File diff suppressed because it is too large
Load Diff
14
src/Mod/AddonManager/TODO.md
Normal file
14
src/Mod/AddonManager/TODO.md
Normal 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
|
||||
28
src/Mod/AddonManager/Widgets/CMakeLists.txt
Normal file
28
src/Mod/AddonManager/Widgets/CMakeLists.txt
Normal 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
|
||||
)
|
||||
0
src/Mod/AddonManager/Widgets/__init__.py
Normal file
0
src/Mod/AddonManager/Widgets/__init__.py
Normal file
48
src/Mod/AddonManager/Widgets/addonmanager_colors.py
Normal file
48
src/Mod/AddonManager/Widgets/addonmanager_colors.py
Normal 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)"
|
||||
@@ -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"))
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
104
src/Mod/AddonManager/Widgets/addonmanager_widget_search.py
Normal file
104
src/Mod/AddonManager/Widgets/addonmanager_widget_search.py
Normal 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)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
0
src/Mod/AddonManager/__init__.py
Normal file
0
src/Mod/AddonManager/__init__.py
Normal 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]]:
|
||||
|
||||
187
src/Mod/AddonManager/addonmanager_licenses.py
Normal file
187
src/Mod/AddonManager/addonmanager_licenses.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
258
src/Mod/AddonManager/addonmanager_package_details_controller.py
Normal file
258
src/Mod/AddonManager/addonmanager_package_details_controller.py
Normal 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)
|
||||
@@ -14,6 +14,9 @@
|
||||
"HideNewerFreeCADRequired": true,
|
||||
"HideObsolete": true,
|
||||
"HidePy2": true,
|
||||
"HideNonOSIApproved": false,
|
||||
"HideNonFSFFreeLibre": false,
|
||||
"HideUnlicensed": false,
|
||||
"KnownPythonVersions": "[]",
|
||||
"LastCacheUpdate": "never",
|
||||
"MacroCacheUpdateFrequency": 7,
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
56
src/Mod/AddonManager/composite_view.py
Normal file
56
src/Mod/AddonManager/composite_view.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
0
src/__init__.py
Normal file
Reference in New Issue
Block a user