FreeCAD
631 строка · 27.3 Кб
1# SPDX-License-Identifier: LGPL-2.1-or-later
2# ***************************************************************************
3# * *
4# * Copyright (c) 2022 FreeCAD Project Association *
5# * *
6# * This file is part of FreeCAD. *
7# * *
8# * FreeCAD is free software: you can redistribute it and/or modify it *
9# * under the terms of the GNU Lesser General Public License as *
10# * published by the Free Software Foundation, either version 2.1 of the *
11# * License, or (at your option) any later version. *
12# * *
13# * FreeCAD is distributed in the hope that it will be useful, but *
14# * WITHOUT ANY WARRANTY; without even the implied warranty of *
15# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
16# * Lesser General Public License for more details. *
17# * *
18# * You should have received a copy of the GNU Lesser General Public *
19# * License along with FreeCAD. If not, see *
20# * <https://www.gnu.org/licenses/>. *
21# * *
22# ***************************************************************************
23
24""" Contains a class for adding a single content item, as well as auxiliary classes for
25its dependent dialog boxes. """
26
27import os28from typing import Optional, Tuple, List29
30import FreeCAD31import FreeCADGui32
33from Addon import INTERNAL_WORKBENCHES34
35from PySide.QtWidgets import (36QDialog,37QLayout,38QFileDialog,39QTableWidgetItem,40QSizePolicy,41)
42from PySide.QtGui import QIcon43from PySide.QtCore import Qt44
45from addonmanager_devmode_validators import (46VersionValidator,47NameValidator,48PythonIdentifierValidator,49)
50from addonmanager_devmode_people_table import PeopleTable51from addonmanager_devmode_licenses_table import LicensesTable52
53# pylint: disable=too-few-public-methods
54
55translate = FreeCAD.Qt.translate56
57
58class AddContent:59"""A dialog for adding a single content item to the package metadata."""60
61def __init__(self, path_to_addon: str, toplevel_metadata: FreeCAD.Metadata):62"""path_to_addon is the full path to the toplevel directory of this Addon, and63toplevel_metadata is to overall package.xml Metadata object for this Addon. This
64information is used to assist the use in filling out the dialog by providing
65sensible default values."""
66self.dialog = FreeCADGui.PySideUic.loadUi(67os.path.join(os.path.dirname(__file__), "developer_mode_add_content.ui")68)69# These are in alphabetical order in English, but their actual label may be translated in70# the GUI. Store their underlying type as user data.71self.dialog.addonKindComboBox.setItemData(0, "macro")72self.dialog.addonKindComboBox.setItemData(1, "preferencepack")73self.dialog.addonKindComboBox.setItemData(2, "workbench")74
75self.people_table = PeopleTable()76self.licenses_table = LicensesTable()77large_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)78large_size_policy.setHorizontalStretch(2)79small_size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)80small_size_policy.setHorizontalStretch(1)81self.people_table.widget.setSizePolicy(large_size_policy)82self.licenses_table.widget.setSizePolicy(small_size_policy)83self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.people_table.widget)84self.dialog.peopleAndLicenseshorizontalLayout.addWidget(self.licenses_table.widget)85
86self.toplevel_metadata = toplevel_metadata87self.metadata = None88self.path_to_addon = path_to_addon.replace("/", os.path.sep)89if self.path_to_addon[-1] != os.path.sep:90self.path_to_addon += os.path.sep # Make sure the path ends with a separator91
92self.dialog.iconLabel.hide() # Until we have an icon to display93
94self.dialog.iconBrowseButton.clicked.connect(self._browse_for_icon_clicked)95self.dialog.subdirectoryBrowseButton.clicked.connect(self._browse_for_subdirectory_clicked)96self.dialog.tagsButton.clicked.connect(self._tags_clicked)97self.dialog.dependenciesButton.clicked.connect(self._dependencies_clicked)98self.dialog.freecadVersionsButton.clicked.connect(self._freecad_versions_clicked)99
100self.dialog.versionLineEdit.setValidator(VersionValidator())101self.dialog.prefPackNameLineEdit.setValidator(NameValidator())102self.dialog.displayNameLineEdit.setValidator(NameValidator())103self.dialog.workbenchClassnameLineEdit.setValidator(PythonIdentifierValidator())104
105def exec(106self,107content_kind: str = "workbench",108metadata: FreeCAD.Metadata = None,109singleton: bool = True,110) -> Optional[Tuple[str, FreeCAD.Metadata]]:111"""Execute the dialog as a modal, returning a new Metadata object if the dialog112is accepted, or None if it is rejected. This metadata object represents a single
113new content item. It's returned as a tuple with the object type as the first component,
114and the metadata object itself as the second."""
115if metadata:116self.metadata = FreeCAD.Metadata(metadata) # Deep copy117else:118self.metadata = FreeCAD.Metadata()119self.dialog.singletonCheckBox.setChecked(singleton)120if singleton:121# This doesn't happen automatically the first time122self.dialog.otherMetadataGroupBox.hide()123index = self.dialog.addonKindComboBox.findData(content_kind)124if index == -1:125index = 2 # Workbench126FreeCAD.Console.PrintWarning(127translate("AddonsInstaller", "Unrecognized content kind '{}'").format(content_kind)128+ "\n"129)130self.dialog.addonKindComboBox.setCurrentIndex(index)131if metadata:132self._populate_dialog(metadata)133
134self.dialog.layout().setSizeConstraint(QLayout.SetFixedSize)135result = self.dialog.exec()136if result == QDialog.Accepted:137return self._generate_metadata()138return None139
140def _populate_dialog(self, metadata: FreeCAD.Metadata) -> None:141"""Fill in the dialog with the details from the passed metadata object"""142addon_kind = self.dialog.addonKindComboBox.currentData()143if addon_kind == "workbench":144self.dialog.workbenchClassnameLineEdit.setText(metadata.Classname)145elif addon_kind == "macro":146files = self.metadata.File147if files:148self.dialog.macroFileLineEdit.setText(files[0])149elif addon_kind == "preferencepack":150self.dialog.prefPackNameLineEdit.setText(self.metadata.Name)151else:152raise RuntimeError("Invalid data found for selection")153
154# Now set the rest of it155if metadata.Icon:156self._set_icon(metadata.Icon)157elif self.toplevel_metadata.Icon:158if metadata.Subdirectory and metadata.Subdirectory != "./":159self._set_icon("../" + self.toplevel_metadata.Icon)160else:161self._set_icon(self.toplevel_metadata.Icon)162else:163self.dialog.iconLabel.hide()164self.dialog.iconLineEdit.setText("")165
166if metadata.Subdirectory:167self.dialog.subdirectoryLineEdit.setText(metadata.Subdirectory)168else:169self.dialog.subdirectoryLineEdit.setText("")170
171self.dialog.displayNameLineEdit.setText(metadata.Name)172self.dialog.descriptionTextEdit.setPlainText(metadata.Description)173self.dialog.versionLineEdit.setText(metadata.Version)174
175self.people_table.show(metadata)176self.licenses_table.show(metadata, self.path_to_addon)177
178def _set_icon(self, icon_relative_path):179"""Load the icon and display it, and its path, in the dialog."""180icon_path = os.path.join(self.path_to_addon, icon_relative_path.replace("/", os.path.sep))181if os.path.isfile(icon_path):182icon_data = QIcon(icon_path)183if not icon_data.isNull():184self.dialog.iconLabel.setPixmap(icon_data.pixmap(32, 32))185self.dialog.iconLabel.show()186else:187FreeCAD.Console.PrintError(188translate("AddonsInstaller", "Unable to locate icon at {}").format(icon_path) + "\n"189)190self.dialog.iconLineEdit.setText(icon_relative_path)191
192def _generate_metadata(self) -> Tuple[str, FreeCAD.Metadata]:193"""Create and return a new metadata object based on the contents of the dialog."""194
195if not self.metadata:196self.metadata = FreeCAD.Metadata()197
198##########################################################################################199# Required data:200current_data: str = self.dialog.addonKindComboBox.currentData()201if current_data == "preferencepack":202self.metadata.Name = self.dialog.prefPackNameLineEdit.text()203elif self.dialog.displayNameLineEdit.text():204self.metadata.Name = self.dialog.displayNameLineEdit.text()205
206if current_data == "workbench":207self.metadata.Classname = self.dialog.workbenchClassnameLineEdit.text()208elif current_data == "macro":209self.metadata.File = [self.dialog.macroFileLineEdit.text()]210##########################################################################################211
212self.metadata.Subdirectory = self.dialog.subdirectoryLineEdit.text()213self.metadata.Icon = self.dialog.iconLineEdit.text()214
215# Early return if this is the only addon216if self.dialog.singletonCheckBox.isChecked():217return current_data, self.metadata218
219# Otherwise, process the rest of the metadata (display name is already done)220self.metadata.Description = self.dialog.descriptionTextEdit.document().toPlainText()221self.metadata.Version = self.dialog.versionLineEdit.text()222
223maintainers = []224authors = []225for row in range(self.dialog.peopleTableWidget.rowCount()):226person_type = self.dialog.peopleTableWidget.item(row, 0).data()227name = self.dialog.peopleTableWidget.item(row, 1).text()228email = self.dialog.peopleTableWidget.item(row, 2).text()229if person_type == "maintainer":230maintainers.append({"name": name, "email": email})231elif person_type == "author":232authors.append({"name": name, "email": email})233self.metadata.Maintainer = maintainers234self.metadata.Author = authors235
236licenses = []237for row in range(self.dialog.licensesTableWidget.rowCount()):238new_license = {239"name": self.dialog.licensesTableWidget.item(row, 0).text,240"file": self.dialog.licensesTableWidget.item(row, 1).text(),241}242licenses.append(new_license)243self.metadata.License = licenses244
245return self.dialog.addonKindComboBox.currentData(), self.metadata246
247###############################################################################################248# DIALOG SLOTS249###############################################################################################250
251def _browse_for_icon_clicked(self):252"""Callback: when the "Browse..." button for the icon field is clicked"""253subdir = self.dialog.subdirectoryLineEdit.text()254start_dir = os.path.join(self.path_to_addon, subdir)255new_icon_path, _ = QFileDialog.getOpenFileName(256parent=self.dialog,257caption=translate(258"AddonsInstaller",259"Select an icon file for this content item",260),261dir=start_dir,262)263
264if not new_icon_path:265return266
267base_path = self.path_to_addon.replace("/", os.path.sep)268icon_path = new_icon_path.replace("/", os.path.sep)269if base_path[-1] != os.path.sep:270base_path += os.path.sep271
272if not icon_path.startswith(base_path):273FreeCAD.Console.PrintError(274translate("AddonsInstaller", "{} is not a subdirectory of {}").format(275icon_path, base_path276)277+ "\n"278)279return280self._set_icon(new_icon_path[len(base_path) :])281self.metadata.Icon = new_icon_path[len(base_path) :]282
283def _browse_for_subdirectory_clicked(self):284"""Callback: when the "Browse..." button for the subdirectory field is clicked"""285subdir = self.dialog.subdirectoryLineEdit.text()286start_dir = os.path.join(self.path_to_addon, subdir)287new_subdir_path = QFileDialog.getExistingDirectory(288parent=self.dialog,289caption=translate(290"AddonsInstaller",291"Select the subdirectory for this content item",292),293dir=start_dir,294)295if not new_subdir_path:296return297if new_subdir_path[-1] != "/":298new_subdir_path += "/"299
300# Three legal possibilities:301# 1) This might be the toplevel directory, in which case we want to set302# metadata.Subdirectory to "./"303# 2) This might be a subdirectory with the same name as the content item, in which case304# we don't need to set metadata.Subdirectory at all305# 3) This might be some other directory name, but still contained within the top-level306# directory, in which case we want to set metadata.Subdirectory to the relative path307
308# First, reject anything that isn't within the appropriate directory structure:309base_path = self.path_to_addon.replace("/", os.path.sep)310subdir_path = new_subdir_path.replace("/", os.path.sep)311if not subdir_path.startswith(base_path):312FreeCAD.Console.PrintError(313translate("AddonsInstaller", "{} is not a subdirectory of {}").format(314subdir_path, base_path315)316+ "\n"317)318return319
320relative_path = subdir_path[len(base_path) :]321if not relative_path:322relative_path = "./"323elif relative_path[-1] == os.path.sep:324relative_path = relative_path[:-1]325self.dialog.subdirectoryLineEdit.setText(relative_path)326
327def _tags_clicked(self):328"""Show the tag editor"""329tags = []330if not self.metadata:331self.metadata = FreeCAD.Metadata()332if self.metadata:333tags = self.metadata.Tag334dlg = EditTags(tags)335new_tags = dlg.exec()336self.metadata.Tag = new_tags337
338def _freecad_versions_clicked(self):339"""Show the FreeCAD version editor"""340if not self.metadata:341self.metadata = FreeCAD.Metadata()342dlg = EditFreeCADVersions()343dlg.exec(self.metadata)344
345def _dependencies_clicked(self):346"""Show the dependencies editor"""347if not self.metadata:348self.metadata = FreeCAD.Metadata()349dlg = EditDependencies()350dlg.exec(self.metadata) # Modifies metadata directly351
352
353class EditTags:354"""A dialog to edit tags"""355
356def __init__(self, tags: List[str] = None):357self.dialog = FreeCADGui.PySideUic.loadUi(358os.path.join(os.path.dirname(__file__), "developer_mode_tags.ui")359)360self.original_tags = tags361if tags:362self.dialog.lineEdit.setText(", ".join(tags))363
364def exec(self):365"""Execute the dialog, returning a list of tags (which may be empty, but still represents366the expected list of tags to be set, e.g. the user may have removed them all).
367"""
368result = self.dialog.exec()369if result == QDialog.Accepted:370new_tags: List[str] = self.dialog.lineEdit.text().split(",")371clean_tags: List[str] = []372for tag in new_tags:373clean_tags.append(tag.strip())374return clean_tags375return self.original_tags376
377
378class EditDependencies:379"""A dialog to edit dependency information"""380
381def __init__(self):382self.dialog = FreeCADGui.PySideUic.loadUi(383os.path.join(os.path.dirname(__file__), "developer_mode_dependencies.ui")384)385self.dialog.addDependencyToolButton.setIcon(386QIcon.fromTheme("add", QIcon(":/icons/list-add.svg"))387)388self.dialog.removeDependencyToolButton.setIcon(389QIcon.fromTheme("remove", QIcon(":/icons/list-remove.svg"))390)391self.dialog.addDependencyToolButton.clicked.connect(self._add_dependency_clicked)392self.dialog.removeDependencyToolButton.clicked.connect(self._remove_dependency_clicked)393self.dialog.tableWidget.itemDoubleClicked.connect(self._edit_dependency)394self.dialog.tableWidget.itemSelectionChanged.connect(self._current_index_changed)395
396self.dialog.removeDependencyToolButton.setDisabled(True)397self.metadata = None398
399def exec(self, metadata: FreeCAD.Metadata):400"""Execute the dialog"""401self.metadata = FreeCAD.Metadata(metadata) # Make a copy, in case we cancel402row = 0403for dep in self.metadata.Depend:404dep_type = dep["type"]405dep_name = dep["package"]406dep_optional = dep["optional"]407self._add_row(row, dep_type, dep_name, dep_optional)408row += 1409result = self.dialog.exec()410if result == QDialog.Accepted:411metadata.Depend = self.metadata.Depend412
413def _add_dependency_clicked(self):414"""Callback: The add button was clicked"""415dlg = EditDependency()416dep_type, dep_name, dep_optional = dlg.exec()417if dep_name:418row = self.dialog.tableWidget.rowCount()419self._add_row(row, dep_type, dep_name, dep_optional)420self.metadata.addDepend(421{"package": dep_name, "type": dep_type, "optional": dep_optional}422)423
424def _add_row(self, row, dep_type, dep_name, dep_optional):425"""Utility function to add a row to the table."""426translations = {427"automatic": translate("AddonsInstaller", "Automatic"),428"workbench": translate("AddonsInstaller", "Workbench"),429"addon": translate("AddonsInstaller", "Addon"),430"python": translate("AddonsInstaller", "Python"),431}432if dep_type and dep_name:433self.dialog.tableWidget.insertRow(row)434type_item = QTableWidgetItem(translations[dep_type])435type_item.setData(Qt.UserRole, dep_type)436self.dialog.tableWidget.setItem(row, 0, type_item)437self.dialog.tableWidget.setItem(row, 1, QTableWidgetItem(dep_name))438if dep_optional:439self.dialog.tableWidget.setItem(440row, 2, QTableWidgetItem(translate("AddonsInstaller", "Yes"))441)442
443def _remove_dependency_clicked(self):444"""Callback: The remove button was clicked"""445items = self.dialog.tableWidget.selectedItems()446if items:447row = items[0].row()448dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole)449dep_name = self.dialog.tableWidget.item(row, 1).text()450dep_optional = bool(self.dialog.tableWidget.item(row, 2))451self.metadata.removeDepend(452{"package": dep_name, "type": dep_type, "optional": dep_optional}453)454self.dialog.tableWidget.removeRow(row)455
456def _edit_dependency(self, item):457"""Callback: the dependency was double-clicked"""458row = item.row()459dlg = EditDependency()460dep_type = self.dialog.tableWidget.item(row, 0).data(Qt.UserRole)461dep_name = self.dialog.tableWidget.item(row, 1).text()462dep_optional = bool(self.dialog.tableWidget.item(row, 2))463new_dep_type, new_dep_name, new_dep_optional = dlg.exec(dep_type, dep_name, dep_optional)464if dep_type and dep_name:465self.metadata.removeDepend(466{"package": dep_name, "type": dep_type, "optional": dep_optional}467)468self.metadata.addDepend(469{470"package": new_dep_name,471"type": new_dep_type,472"optional": new_dep_optional,473}474)475self.dialog.tableWidget.removeRow(row)476self._add_row(row, dep_type, dep_name, dep_optional)477
478def _current_index_changed(self):479if self.dialog.tableWidget.selectedItems():480self.dialog.removeDependencyToolButton.setDisabled(False)481else:482self.dialog.removeDependencyToolButton.setDisabled(True)483
484
485class EditDependency:486"""A dialog to edit a single piece of dependency information"""487
488def __init__(self):489self.dialog = FreeCADGui.PySideUic.loadUi(490os.path.join(os.path.dirname(__file__), "developer_mode_edit_dependency.ui")491)492
493self.dialog.typeComboBox.addItem(494translate("AddonsInstaller", "Internal Workbench"), "workbench"495)496self.dialog.typeComboBox.addItem(translate("AddonsInstaller", "External Addon"), "addon")497self.dialog.typeComboBox.addItem(translate("AddonsInstaller", "Python Package"), "python")498
499self.dialog.typeComboBox.currentIndexChanged.connect(self._type_selection_changed)500self.dialog.dependencyComboBox.currentIndexChanged.connect(501self._dependency_selection_changed502)503
504# Expect mostly Python dependencies...505self.dialog.typeComboBox.setCurrentIndex(2)506
507self.dialog.layout().setSizeConstraint(QLayout.SetFixedSize)508
509def exec(self, dep_type="", dep_name="", dep_optional=False) -> Tuple[str, str, bool]:510"""Execute the dialog, returning a tuple of the type of dependency (workbench, addon, or511python), the name of the dependency, and a boolean indicating whether this is optional.
512"""
513
514# If we are editing an existing row, set up the dialog:515if dep_type and dep_name:516index = self.dialog.typeComboBox.findData(dep_type)517if index == -1:518raise RuntimeError(f"Invalid dependency type {dep_type}")519self.dialog.typeComboBox.setCurrentIndex(index)520index = self.dialog.dependencyComboBox.findData(dep_name)521if index == -1:522index = self.dialog.dependencyComboBox.findData("other")523self.dialog.dependencyComboBox.setCurrentIndex(index)524self.dialog.lineEdit.setText(dep_name)525self.dialog.optionalCheckBox.setChecked(dep_optional)526
527# Run the dialog (modal)528result = self.dialog.exec()529if result == QDialog.Accepted:530dep_type = self.dialog.typeComboBox.currentData()531dep_optional = self.dialog.optionalCheckBox.isChecked()532dep_name = self.dialog.dependencyComboBox.currentData()533if dep_name == "other":534dep_name = self.dialog.lineEdit.text()535return dep_type, dep_name, dep_optional536return "", "", False537
538def _populate_internal_workbenches(self):539"""Add all known internal FreeCAD Workbenches to the list"""540self.dialog.dependencyComboBox.clear()541for display_name, name in INTERNAL_WORKBENCHES.items():542self.dialog.dependencyComboBox.addItem(display_name, name)543# No "other" option is supported for this type of dependency544
545def _populate_external_addons(self):546"""Add all known addons to the list"""547self.dialog.dependencyComboBox.clear()548# pylint: disable=import-outside-toplevel549from AddonManager import INSTANCE as AM_INSTANCE550
551repo_dict = {}552# We need a case-insensitive sorting of all repo types, displayed and sorted by their553# display name, but keeping track of their official name as well (stored in the UserRole)554for repo in AM_INSTANCE.item_model.repos:555repo_dict[repo.display_name.lower()] = (repo.display_name, repo.name)556sorted_keys = sorted(repo_dict)557for item in sorted_keys:558self.dialog.dependencyComboBox.addItem(repo_dict[item][0], repo_dict[item][1])559self.dialog.dependencyComboBox.addItem(translate("AddonsInstaller", "Other..."), "other")560
561def _populate_allowed_python_packages(self):562"""Add all allowed python packages to the list"""563self.dialog.dependencyComboBox.clear()564# pylint: disable=import-outside-toplevel565from AddonManager import INSTANCE as AM_INSTANCE566
567packages = sorted(AM_INSTANCE.allowed_packages)568for package in packages:569self.dialog.dependencyComboBox.addItem(package, package)570self.dialog.dependencyComboBox.addItem(translate("AddonsInstaller", "Other..."), "other")571
572def _type_selection_changed(self, _):573"""Callback: The type of dependency has been changed"""574selection = self.dialog.typeComboBox.currentData()575if selection == "workbench":576self._populate_internal_workbenches()577elif selection == "addon":578self._populate_external_addons()579elif selection == "python":580self._populate_allowed_python_packages()581else:582raise RuntimeError("Invalid data found for selection")583
584def _dependency_selection_changed(self, _):585selection = self.dialog.dependencyComboBox.currentData()586if selection == "other":587self.dialog.lineEdit.show()588self.dialog.otherNote.show()589else:590self.dialog.lineEdit.hide()591self.dialog.otherNote.hide()592
593
594class EditFreeCADVersions:595"""A dialog to edit minimum and maximum FreeCAD version support"""596
597def __init__(self):598self.dialog = FreeCADGui.PySideUic.loadUi(599os.path.join(os.path.dirname(__file__), "developer_mode_freecad_versions.ui")600)601
602def exec(self, metadata: FreeCAD.Metadata):603"""Execute the dialog"""604if metadata.FreeCADMin != "0.0.0":605self.dialog.minVersionLineEdit.setText(metadata.FreeCADMin)606if metadata.FreeCADMax != "0.0.0":607self.dialog.maxVersionLineEdit.setText(metadata.FreeCADMax)608result = self.dialog.exec()609if result == QDialog.Accepted:610if self.dialog.minVersionLineEdit.text():611metadata.FreeCADMin = self.dialog.minVersionLineEdit.text()612else:613metadata.FreeCADMin = None614if self.dialog.maxVersionLineEdit.text():615metadata.FreeCADMax = self.dialog.maxVersionLineEdit.text()616else:617metadata.FreeCADMax = None618
619
620class EditAdvancedVersions:621"""A dialog to support mapping specific git branches, tags, or commits to specific622versions of FreeCAD."""
623
624def __init__(self):625self.dialog = FreeCADGui.PySideUic.loadUi(626os.path.join(os.path.dirname(__file__), "developer_mode_advanced_freecad_versions.ui")627)628
629def exec(self):630"""Execute the dialog"""631self.dialog.exec()632