diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index dc9e527c49..bedb988620 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -28,6 +28,7 @@ SET(AddonManager_SRCS addonmanager_macro_parser.py addonmanager_metadata.py addonmanager_pyside_interface.py + addonmanager_readme_viewer.py addonmanager_update_all_gui.py addonmanager_uninstaller.py addonmanager_uninstaller_gui.py diff --git a/src/Mod/AddonManager/addonmanager_readme_viewer.py b/src/Mod/AddonManager/addonmanager_readme_viewer.py new file mode 100644 index 0000000000..5e8ec50464 --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_readme_viewer.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * Copyright (c) 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 * +# * . * +# * * +# *************************************************************************** + +""" A Qt Widget for displaying Addon README information """ + +import Addon +from PySide import QtCore, QtGui, QtWidgets + +import addonmanager_freecad_interface as fci +import addonmanager_utilities as utils +import NetworkManager + +translate = fci.translate + + +class ReadmeViewer(QtWidgets.QTextBrowser): + + """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) + NetworkManager.InitializeNetworkManager() + NetworkManager.AM_NETWORK_MANAGER.completed.connect(self._download_completed) + self.request_index = 0 + self.url = "" + self.repo: Addon.Addon = None + self.setOpenExternalLinks(True) + self.setOpenLinks(True) + self.image_map = {} + + def set_addon(self, repo: Addon): + """Set which Addon's information is displayed""" + + self.setPlainText(translate("AddonsInstaller", "Loading README data...")) + self.repo = repo + if self.repo.repo_type == Addon.Addon.Kind.MACRO: + self.url = self.repo.macro.wiki + if not self.url: + self.url = self.repo.macro.url + else: + self.url = utils.get_readme_url(repo) + + self.request_index = NetworkManager.AM_NETWORK_MANAGER.submit_unmonitored_get(self.url) + + def _download_completed(self, index: int, code: int, data: QtCore.QByteArray) -> None: + """Callback for handling a completed README file download.""" + if index == self.request_index: + if code == 200: # HTTP success + self._process_package_download(data.data().decode("utf-8")) + else: + self.setPlainText( + translate( + "AddonsInstaller", + "Failed to download data from {} -- received response code {}.", + ).format(self.url, code) + ) + + def _process_package_download(self, data: str): + if self.repo.repo_type == Addon.Addon.Kind.MACRO: + self.setHtml(data) + else: + if hasattr(self, "setMarkdown"): + self.setMarkdown(data) + else: + self.setPlainText(data) + + 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: + 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") + data = NetworkManager.AM_NETWORK_MANAGER.blocking_get(full_url) + if data and data.data(): + image = QtGui.QImage.fromData(data.data()) + if image: + self.image_map[full_url] = self._ensure_appropriate_width(image) + return self.image_map[full_url] + return super().loadResource(resource_type, name) + + 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 + + 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) diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 36952b7bd3..93dfb8bee9 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -33,6 +33,7 @@ import addonmanager_freecad_interface as fci import addonmanager_utilities as utils from addonmanager_metadata import Version, UrlType, get_first_supported_freecad_version from addonmanager_workers_startup import GetMacroDetailsWorker, CheckSingleUpdateWorker +from addonmanager_readme_viewer import ReadmeViewer from Addon import Addon from change_branch import ChangeBranchDialog @@ -50,20 +51,6 @@ translate = fci.translate show_javascript_console_output = False -try: - from PySide import QtWebEngineWidgets - - HAS_QTWEBENGINE = True -except ImportError: - fci.Console.PrintWarning( - translate( - "AddonsInstaller", - "Addon Manager Warning: Could not import QtWebEngineWidgets -- README data will display as text-only", - ) - + "\n" - ) - HAS_QTWEBENGINE = False - class PackageDetails(QtWidgets.QWidget): """The PackageDetails QWidget shows package README information and provides @@ -95,17 +82,6 @@ class PackageDetails(QtWidgets.QWidget): 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) - if HAS_QTWEBENGINE: - self.ui.webView.loadStarted.connect(self.load_started) - self.ui.webView.loadProgress.connect(self.load_progress) - self.ui.webView.loadFinished.connect(self.load_finished) - - loading_html_file = os.path.join(os.path.dirname(__file__), "loading.html") - with open(loading_html_file, "r", errors="ignore", encoding="utf-8") as f: - html = f.read() - self.ui.loadingLabel.setHtml(html) - self.ui.loadingLabel.show() - self.ui.webView.hide() def show_repo(self, repo: Addon, reload: bool = False) -> None: """The main entry point for this class, shows the package details and related buttons @@ -117,16 +93,6 @@ class PackageDetails(QtWidgets.QWidget): if self.repo != repo or reload: self.repo = repo - if HAS_QTWEBENGINE: - self.ui.loadingLabel.show() - self.ui.slowLoadLabel.hide() - self.ui.webView.setHtml("Loading...") - self.ui.webView.hide() - self.ui.progressBar.show() - self.timeout = QtCore.QTimer.singleShot(6000, self.long_load_running) # Six seconds - else: - self.ui.missingWebViewLabel.setStyleSheet("color:" + utils.warning_color_string()) - if self.worker is not None: if not self.worker.isFinished(): self.worker.requestInterruption() @@ -420,33 +386,13 @@ class PackageDetails(QtWidgets.QWidget): def show_workbench(self, repo: Addon) -> None: """loads information of a given workbench""" - url = utils.get_readme_html_url(repo) - if HAS_QTWEBENGINE: - self.ui.webView.load(QtCore.QUrl(url)) - self.ui.urlBar.setText(url) - else: - readme_data = utils.blocking_get(url) - text = readme_data.decode("utf8") - self.ui.textBrowserReadMe.setHtml(text) + + 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)""" - readme_url = None - if repo.metadata: - for url in repo.metadata.url: - if url.type == UrlType.readme: - readme_url = url.location - break - if not readme_url: - readme_url = utils.get_readme_html_url(repo) - if HAS_QTWEBENGINE: - self.ui.webView.load(QtCore.QUrl(readme_url)) - self.ui.urlBar.setText(readme_url) - else: - readme_data = utils.blocking_get(readme_url) - text = readme_data.decode("utf8") - self.ui.textBrowserReadMe.setHtml(text) + self.ui.textBrowserReadMe.set_addon(repo) def show_macro(self, repo: Addon) -> None: """loads information of a given macro""" @@ -461,136 +407,8 @@ class PackageDetails(QtWidgets.QWidget): def macro_readme_updated(self): """Update the display of a Macro's README data.""" - url = self.repo.macro.wiki - if not url: - url = self.repo.macro.url - if HAS_QTWEBENGINE: - if url: - self.ui.webView.load(QtCore.QUrl(url)) - self.ui.urlBar.setText(url) - else: - self.ui.urlBar.setText( - "(" - + translate("AddonsInstaller", "No URL or wiki page provided by this macro") - + ")" - ) - else: - if url: - readme_data = utils.blocking_get(url) - text = readme_data.decode("utf8") - self.ui.textBrowserReadMe.setHtml(text) - else: - self.ui.textBrowserReadMe.setHtml( - "(" - + translate("AddonsInstaller", "No URL or wiki page provided by this macro") - + ")" - ) - - def run_javascript(self): - """Modify the page for a README to optimize for viewing in a smaller window""" - - s = """ -( function() { - const url = new URL (window.location); - const body = document.getElementsByTagName("body")[0]; - if (url.hostname === "github.com") { - const articles = document.getElementsByTagName("article"); - if (articles.length > 0) { - const article = articles[0]; - body.appendChild (article); - body.style.padding = "1em"; - let sibling = article.previousSibling; - while (sibling) { - sibling.remove(); - sibling = article.previousSibling; - } - } - } else if (url.hostname === "gitlab.com" || - url.hostname === "framagit.org" || - url.hostname === "salsa.debian.org") { - // These all use the GitLab page display... - const articles = document.getElementsByTagName("article"); - if (articles.length > 0) { - const article = articles[0]; - body.appendChild (article); - body.style.padding = "1em"; - let sibling = article.previousSibling; - while (sibling) { - sibling.remove(); - sibling = article.previousSibling; - } - } - } else if (url.hostname === "wiki.freecad.org" || - url.hostname === "wiki.freecad.org") { - const first_heading = document.getElementById('firstHeading'); - const body_content = document.getElementById('bodyContent'); - const new_node = document.createElement("div"); - new_node.appendChild(first_heading); - new_node.appendChild(body_content); - body.appendChild(new_node); - let sibling = new_node.previousSibling; - while (sibling) { - sibling.remove(); - sibling = new_node.previousSibling; - } - } -} -) () -""" - self.ui.webView.page().runJavaScript(s) - - def load_started(self): - """Called when loading is started: sets up the progress bar""" - self.ui.progressBar.show() - self.ui.progressBar.setValue(0) - - def load_progress(self, progress: int): - """Called during load to update the progress bar""" - self.ui.progressBar.setValue(progress) - - def load_finished(self, load_succeeded: bool): - """Once loading is complete, update the display of the progress bar and loading widget.""" - self.ui.loadingLabel.hide() - self.ui.slowLoadLabel.hide() - self.ui.webView.show() - self.ui.progressBar.hide() - url = self.ui.webView.url() - if ( - hasattr(self, "timeout") - and hasattr(self.timeout, "isActive") - and self.timeout.isActive() - ): - self.timeout.stop() - if load_succeeded: - # It says it succeeded, but it might have only succeeded in loading a - # "Page not found" page! - title = self.ui.webView.title() - path_components = url.path().split("/") - expected_content = path_components[-1] - if url.host() == "github.com" and expected_content not in title: - self.show_error_for(url) - elif title == "": - self.show_error_for(url) - else: - self.run_javascript() - else: - self.show_error_for(url) - - def long_load_running(self): - """Displays a message about loading taking a long time.""" - if hasattr(self.ui, "webView") and self.ui.webView.isHidden(): - self.ui.slowLoadLabel.show() - self.ui.loadingLabel.hide() - self.ui.webView.show() - - def show_error_for(self, url: QtCore.QUrl) -> None: - """Displays error information.""" - m = translate("AddonsInstaller", "Could not load README data from URL {}").format( - url.toString() - ) - html = f"

{m}

" - self.ui.webView.setHtml(html) + self.ui.textBrowserReadMe.set_addon(self.repo) def change_branch_clicked(self) -> None: """Loads the branch-switching dialog""" @@ -671,57 +489,6 @@ class PackageDetails(QtWidgets.QWidget): self.update_status.emit(self.repo) -if HAS_QTWEBENGINE: - - class RestrictedWebPage(QtWebEngineWidgets.QWebEnginePage): - """A class that follows links to FreeCAD wiki pages, but opens all other - clicked links in the system web browser""" - - def __init__(self, parent): - super().__init__(parent) - self.settings().setAttribute( - QtWebEngineWidgets.QWebEngineSettings.ErrorPageEnabled, False - ) - self.stored_url = None - - def acceptNavigationRequest(self, requested_url, _type, isMainFrame): - """A callback for navigation requests: this widget will only display - navigation requests to the FreeCAD Wiki (for translation purposes) -- - anything else will open in a new window. - """ - if _type == QtWebEngineWidgets.QWebEnginePage.NavigationTypeLinkClicked: - # See if the link is to a FreeCAD Wiki page -- if so, follow it, - # otherwise ask the OS to open it - if ( - requested_url.host() == "wiki.freecad.org" - or requested_url.host() == "wiki.freecad.org" - ): - return super().acceptNavigationRequest(requested_url, _type, isMainFrame) - QtGui.QDesktopServices.openUrl(requested_url) - self.stored_url = self.url() - QtCore.QTimer.singleShot(0, self._reload_stored_url) - return False - return super().acceptNavigationRequest(requested_url, _type, isMainFrame) - - def javaScriptConsoleMessage(self, level, message, lineNumber, _): - """Handle JavaScript console messages by optionally outputting them to - the FreeCAD Console. This must be manually enabled in this Python file by - setting the global show_javascript_console_output to true.""" - global show_javascript_console_output - if show_javascript_console_output: - tag = translate("AddonsInstaller", "Page JavaScript reported") - if level == QtWebEngineWidgets.QWebEnginePage.InfoMessageLevel: - fci.Console.PrintMessage(f"{tag} {lineNumber}: {message}\n") - elif level == QtWebEngineWidgets.QWebEnginePage.WarningMessageLevel: - fci.Console.PrintWarning(f"{tag} {lineNumber}: {message}\n") - elif level == QtWebEngineWidgets.QWebEnginePage.ErrorMessageLevel: - fci.Console.PrintError(f"{tag} {lineNumber}: {message}\n") - - def _reload_stored_url(self): - if self.stored_url: - self.load(self.stored_url) - - class Ui_PackageDetails(object): """The generated UI from the Qt Designer UI file""" @@ -810,48 +577,10 @@ class Ui_PackageDetails(object): sizePolicy1.setHorizontalStretch(0) sizePolicy1.setVerticalStretch(0) - if HAS_QTWEBENGINE: - self.webView = QtWebEngineWidgets.QWebEngineView(PackageDetails) - self.webView.setObjectName("webView") - self.webView.setSizePolicy(sizePolicy1) - self.webView.setPage(RestrictedWebPage(PackageDetails)) + self.textBrowserReadMe = ReadmeViewer(PackageDetails) + self.textBrowserReadMe.setObjectName("textBrowserReadMe") - self.verticalLayout_2.addWidget(self.webView) - - self.loadingLabel = QtWebEngineWidgets.QWebEngineView(PackageDetails) - self.loadingLabel.setObjectName("loadingLabel") - self.loadingLabel.setSizePolicy(sizePolicy1) - - self.verticalLayout_2.addWidget(self.loadingLabel) - - self.slowLoadLabel = QtWidgets.QLabel(PackageDetails) - self.slowLoadLabel.setObjectName("slowLoadLabel") - - self.verticalLayout_2.addWidget(self.slowLoadLabel) - - self.progressBar = QtWidgets.QProgressBar(PackageDetails) - self.progressBar.setObjectName("progressBar") - self.progressBar.setTextVisible(False) - - self.verticalLayout_2.addWidget(self.progressBar) - - self.urlBar = QtWidgets.QLineEdit(PackageDetails) - self.urlBar.setObjectName("urlBar") - self.urlBar.setReadOnly(True) - - self.verticalLayout_2.addWidget(self.urlBar) - else: - self.missingWebViewLabel = QtWidgets.QLabel(PackageDetails) - self.missingWebViewLabel.setObjectName("missingWebViewLabel") - self.missingWebViewLabel.setWordWrap(True) - self.verticalLayout_2.addWidget(self.missingWebViewLabel) - - self.textBrowserReadMe = QtWidgets.QTextBrowser(PackageDetails) - self.textBrowserReadMe.setObjectName("textBrowserReadMe") - self.textBrowserReadMe.setOpenExternalLinks(True) - self.textBrowserReadMe.setOpenLinks(True) - - self.verticalLayout_2.addWidget(self.textBrowserReadMe) + self.verticalLayout_2.addWidget(self.textBrowserReadMe) self.retranslateUi(PackageDetails) @@ -888,22 +617,5 @@ class Ui_PackageDetails(object): self.buttonBack.setToolTip( QtCore.QCoreApplication.translate("AddonsInstaller", "Return to package list", None) ) - if not HAS_QTWEBENGINE: - self.missingWebViewLabel.setText( - "

" - + QtCore.QCoreApplication.translate( - "AddonsInstaller", - "QtWebEngine Python bindings not installed -- using fallback README display.", - None, - ) - + "

" - ) - else: - self.slowLoadLabel.setText( - QtCore.QCoreApplication.translate( - "AddonsInstaller", - "The page is taking a long time to load... showing the data we have so far...", - ) - ) # retranslateUi