# 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 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 = FreeCAD.Qt.translate from PySide import QtCore, QtGui class ReadmeDataType(IntEnum): PlainText = 0 Markdown = 1 Html = 2 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.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) self.widget.follow_link.connect(self.follow_link) def set_addon(self, repo: Addon): """Set which Addon's information is displayed""" self.addon = repo self.stop = False 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.addon.macro.url if not self.url: self.widget.setText( translate( "AddonsInstaller", "Loading info for {} from the FreeCAD Macro Recipes wiki...", ).format(self.addon.display_name, self.url) ) return 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 ) def _download_completed(self, index: int, code: int, data: QtCore.QByteArray) -> None: """Callback for handling a completed README file download.""" if index == self.readme_request_index: if code == 200: # HTTP success self._process_package_download(data.data().decode("utf-8")) else: self.widget.setText( translate( "AddonsInstaller", "Failed to download data from {} -- received response code {}.", ).format(self.url, code) ) elif index in self.resource_requests: if code == 200: self._process_resource_download(self.resource_requests[index], data.data()) else: 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: 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.addon.repo_type == Addon.Kind.MACRO: parser = WikiCleaner() parser.feed(data) self.readme_data = parser.final_html self.readme_data_type = ReadmeDataType.Html self.widget.setHtml(parser.final_html) else: 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) self.widget.set_resource(resource_name, image) 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 cancel_resource_loading(self): self.stop = True for request in self.resource_requests: NetworkManager.AM_NETWORK_MANAGER.abort(request) self.resource_requests.clear() def follow_link(self, url: str) -> None: final_url = url if not url.startswith("http"): if url.endswith(".md"): final_url = self._create_markdown_url(url) else: final_url = self._create_full_url(url) FreeCAD.Console.PrintLog(f"Loading {final_url} in the system browser") QtGui.QDesktopServices.openUrl(final_url) 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 _create_markdown_url(self, file: str) -> str: base_url = utils.get_readme_html_url(self.addon) lhs, slash, _ = base_url.rpartition("/") return lhs + slash + file class WikiCleaner(HTMLParser): """This HTML parser cleans up FreeCAD Macro Wiki Page for display in a QTextBrowser widget (which does not deal will with tables used as formatting, etc.) It strips out any tables, and extracts the mw-parser-output div as the only thing that actually gets displayed. It also discards anything inside the [edit] spans that litter wiki output.""" class State(Enum): BeforeMacroContent = auto() InMacroContent = auto() InTable = auto() InEditSpan = auto() AfterMacroContent = auto() def __init__(self): super().__init__() self.depth_in_div = 0 self.depth_in_span = 0 self.depth_in_table = 0 self.final_html = "" self.previous_state = WikiCleaner.State.BeforeMacroContent self.state = WikiCleaner.State.BeforeMacroContent def handle_starttag(self, tag: str, attrs): if tag == "div": self.handle_div_start(attrs) elif tag == "span": self.handle_span_start(attrs) elif tag == "table": self.handle_table_start(attrs) else: if self.state == WikiCleaner.State.InMacroContent: self.add_tag_to_html(tag, attrs) def handle_div_start(self, attrs): for name, value in attrs: if name == "class" and value == "mw-parser-output": self.previous_state = self.state self.state = WikiCleaner.State.InMacroContent if self.state == WikiCleaner.State.InMacroContent: self.depth_in_div += 1 self.add_tag_to_html("div", attrs) def handle_span_start(self, attrs): for name, value in attrs: if name == "class" and value == "mw-editsection": self.previous_state = self.state self.state = WikiCleaner.State.InEditSpan break if self.state == WikiCleaner.State.InEditSpan: self.depth_in_span += 1 elif WikiCleaner.State.InMacroContent: self.add_tag_to_html("span", attrs) def handle_table_start(self, unused): if self.state != WikiCleaner.State.InTable: self.previous_state = self.state self.state = WikiCleaner.State.InTable self.depth_in_table += 1 def add_tag_to_html(self, tag, attrs=None): self.final_html += f"<{tag}" if attrs: self.final_html += " " for attr, value in attrs: self.final_html += f"{attr}='{value}'" self.final_html += ">\n" def handle_endtag(self, tag): if tag == "table": self.handle_table_end() elif tag == "span": self.handle_span_end() elif tag == "div": self.handle_div_end() else: if self.state == WikiCleaner.State.InMacroContent: self.add_tag_to_html(f"/{tag}") def handle_span_end(self): if self.state == WikiCleaner.State.InEditSpan: self.depth_in_span -= 1 if self.depth_in_span <= 0: self.depth_in_span = 0 self.state = self.previous_state else: self.add_tag_to_html(f"/span") def handle_div_end(self): if self.state == WikiCleaner.State.InMacroContent: self.depth_in_div -= 1 if self.depth_in_div <= 0: self.depth_in_div = 0 self.state = WikiCleaner.State.AfterMacroContent self.final_html += "" else: self.add_tag_to_html(f"/div") def handle_table_end(self): if self.state == WikiCleaner.State.InTable: self.depth_in_table -= 1 if self.depth_in_table <= 0: self.depth_in_table = 0 self.state = self.previous_state def handle_data(self, data): if self.state == WikiCleaner.State.InMacroContent: self.final_html += data