Addon Manager: Auto-create toolbar button

When installing a macro, prompt user to install a toolbar button
automatically. Fills in the details of the button using the macro's
metadata, including an icon if the __icon__ metadata variable points to
a file.

Also:
* Support XPM data for macro icon
* Support online icons
* Fix bug in macro uninstall
* Cleaned up macro code
This commit is contained in:
Chris Hennes
2022-02-14 21:57:56 -06:00
parent 3be45f4087
commit 1a7fcd575e
9 changed files with 696 additions and 37 deletions

View File

@@ -28,11 +28,11 @@ import codecs
import shutil
import time
from urllib.parse import urlparse
import tempfile
from typing import Dict, Tuple, List, Union
import FreeCAD
import NetworkManager
from PySide2 import QtCore
translate = FreeCAD.Qt.translate
@@ -68,6 +68,7 @@ class Macro(object):
self.src_filename = ""
self.author = ""
self.icon = ""
self.xpm = "" # Possible alternate icon data
self.other_files = []
self.parsed = False
@@ -115,9 +116,9 @@ class Macro(object):
# __Files__
# __Author__
# __Date__
# __Icon__
max_lines_to_search = 200
line_counter = 0
ic = re.IGNORECASE # Shorten the line for Black
string_search_mapping = {
"__comment__": "comment",
@@ -127,23 +128,29 @@ class Macro(object):
"__author__": "author",
"__date__": "date",
"__icon__": "icon",
"__xpm__": "xpm",
}
string_search_regex = re.compile(r"\s*(['\"])(.*)\1")
f = io.StringIO(code)
while f and line_counter < max_lines_to_search:
line = f.readline()
if not line:
break
if QtCore.QThread.currentThread().isInterruptionRequested():
return
line_counter += 1
# if not line.startswith("__"):
# # Speed things up a bit... this comparison is very cheap
# continue
if not line.startswith("__"):
# Speed things up a bit... this comparison is very cheap
continue
lowercase_line = line.lower()
for key, value in string_search_mapping.items():
if lowercase_line.startswith(key):
_, _, after_equals = line.partition("=")
match = re.match(string_search_regex, after_equals)
if match:
# We do NOT support triple-quoted strings, except for the icon XPM data
if match and '"""' not in after_equals:
if type(self.__dict__[value]) == str:
self.__dict__[value] = match.group(2)
elif type(self.__dict__[value]) == list:
@@ -177,6 +184,32 @@ class Macro(object):
)
self.version = str(after_equals).strip()
break
elif key == "__icon__" or key == "__xpm__":
# If this is an icon, it's possible that the icon was actually directly specified
# in the file as XPM data. This data **must** be between triple double quotes in
# order for the Addon Manager to recognize it.
if '"""' in after_equals:
_, _, xpm_data = after_equals.partition('"""')
while True:
line = f.readline()
if not line:
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
"Syntax error while reading {} from macro {}",
).format(key, self.name)
+ "\n"
)
break
if '"""' in line:
last_line, _, _ = line.partition('"""')
xpm_data += last_line
break
else:
xpm_data += line
self.xpm = xpm_data
break
FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
@@ -273,6 +306,27 @@ class Macro(object):
self.author = self.parse_desc("Author: ")
if not self.date:
self.date = self.parse_desc("Last modified: ")
if self.icon.startswith("http://") or self.icon.startswith("https://"):
# Technically we don't claim to support this, but some macro authors are
# doing it anyway, so let's give it a shot...
FreeCAD.Console.PrintMessage(
translate(
"AddonsInstaller", "Attempting to fetch macro icon from {}"
).format(self.icon)
+ "\n"
)
p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(self.icon)
if p:
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager", "MacroIcons")
os.makedirs(am_path, exist_ok=True)
_, _, filename = self.icon.rpartition("/")
base, _, extension = filename.rpartition(".")
constructed_name = os.path.join(am_path, base + "." + extension)
with open(constructed_name, "wb") as f:
f.write(p.data())
self.icon_source = self.icon
self.icon = constructed_name
def parse_desc(self, line_start: str) -> Union[str, None]:
components = self.desc.split(">")
@@ -306,15 +360,43 @@ class Macro(object):
# self.src_filename.
base_dir = os.path.dirname(self.src_filename)
warnings = []
if self.xpm:
xpm_file = os.path.join(base_dir, self.name + "_icon.xpm")
with open(xpm_file, "w") as f:
f.write(self.xpm)
if self.icon:
if os.path.isabs(self.icon):
dst_file = os.path.normpath(
os.path.join(macro_dir, os.path.basename(self.icon))
)
try:
shutil.copy(self.icon, dst_file)
except IOError:
warnings.append(f"Failed to copy icon to {dst_file}")
elif self.icon not in self.other_files:
self.other_files.append(self.icon)
for other_file in self.other_files:
dst_dir = os.path.join(macro_dir, os.path.dirname(other_file))
if not other_file:
continue
if os.path.isabs(other_file):
dst_dir = macro_dir
else:
dst_dir = os.path.join(macro_dir, os.path.dirname(other_file))
if not os.path.isdir(dst_dir):
try:
os.makedirs(dst_dir)
except OSError:
return False, [f"Failed to create {dst_dir}"]
src_file = os.path.normpath(os.path.join(base_dir, other_file))
dst_file = os.path.normpath(os.path.join(macro_dir, other_file))
if os.path.isabs(other_file):
src_file = other_file
dst_file = os.path.normpath(
os.path.join(macro_dir, os.path.basename(other_file))
)
else:
src_file = os.path.normpath(os.path.join(base_dir, other_file))
dst_file = os.path.normpath(os.path.join(macro_dir, other_file))
if not os.path.isfile(src_file):
warnings.append(
translate(
@@ -351,19 +433,35 @@ class Macro(object):
os.remove(macro_path_with_macro_prefix)
# Remove related files, which are supposed to be given relative to
# self.src_filename.
if self.xpm:
xpm_file = os.path.join(macro_dir, self.name + "_icon.xpm")
if os.path.exists(xpm_file):
os.remove(xpm_file)
for other_file in self.other_files:
if not other_file:
continue
FreeCAD.Console.PrintMessage(f"{other_file}...")
dst_file = os.path.join(macro_dir, other_file)
if not dst_file or not os.path.exists(dst_file):
FreeCAD.Console.PrintMessage(f"X\n")
continue
try:
os.remove(dst_file)
remove_directory_if_empty(os.path.dirname(dst_file))
FreeCAD.Console.PrintMessage("\n")
except Exception:
FreeCAD.Console.PrintWarning(
translate(
"AddonsInstaller",
"Failed to remove macro file '{}': it might not exist, or its permissions changed",
).format(dst_file)
+ "\n"
)
FreeCAD.Console.PrintMessage(f"?\n")
if os.path.isabs(self.icon):
dst_file = os.path.normpath(
os.path.join(macro_dir, os.path.basename(self.icon))
)
if os.path.exists(dst_file):
try:
FreeCAD.Console.PrintMessage(f"{os.path.basename(self.icon)}...")
os.remove(dst_file)
FreeCAD.Console.PrintMessage("\n")
except Exception:
FreeCAD.Console.PrintMessage(f"?\n")
return True