287 lines
11 KiB
Python
287 lines
11 KiB
Python
# 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 *
|
|
# * <https://www.gnu.org/licenses/>. *
|
|
# * *
|
|
# ***************************************************************************
|
|
|
|
""" 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 = "<html><body>"
|
|
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 += "</body></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
|