mirror of
https://github.com/EDCD/EDMarketConnector.git
synced 2026-10-22 17:22:09 +03:00
Merge pull request #2582 from HullSeals/enhancement/plugin-browser
[#2572] Plugin Browser
This commit is contained in:
9 files changed
+994
-142
No files matched your search
+2
-1
@@ -33,6 +33,7 @@ EDMarketConnector.wxs
|
||||
wix/components.wxs
|
||||
edmarketconnector.xml
|
||||
edmarketconnector-beta.xml
|
||||
master_plugin_list.json
|
||||
|
||||
# Ignore Visual Elements Manifest file for Windows
|
||||
EDMarketConnector.VisualElementsManifest.xml
|
||||
@@ -59,4 +60,4 @@ pylint.txt
|
||||
|
||||
# Ignore Submodule data directory
|
||||
coriolis-data/
|
||||
FDevIDs/
|
||||
FDevIDs/
|
||||
+127
-68
@@ -67,7 +67,6 @@ from config import appversion, appversion_nobuild, config, copyright
|
||||
|
||||
from EDMCLogging import edmclogger, logger, logging
|
||||
from journal_lock import JournalLock, JournalLockResult
|
||||
from update import check_for_fdev_updates, check_for_datafile_updates
|
||||
from common_utils import log_locale, SERVER_RETRY
|
||||
|
||||
if __name__ == '__main__': # noqa: C901
|
||||
@@ -445,6 +444,34 @@ from theme import theme
|
||||
from ttkHyperlinkLabel import HyperlinkLabel, SHIPYARD_HTML_TEMPLATE
|
||||
|
||||
|
||||
def _config_plugins(frame: tk.Frame, ui_row: int) -> tk.Frame:
|
||||
for idx, plugin in enumerate(plug.PLUGINS, start=1):
|
||||
plugin_frame = tk.Frame(frame, name=f"plugin_{idx}")
|
||||
appitem = plugin.get_app(plugin_frame)
|
||||
if not appitem:
|
||||
plugin_frame.destroy()
|
||||
continue
|
||||
|
||||
# Per plugin separator
|
||||
plugin_sep = tk.Frame(frame, highlightthickness=1, name=f"plugin_hr_{idx}")
|
||||
plugin_sep.grid(row=ui_row, columnspan=2, sticky=tk.EW)
|
||||
ui_row += 1
|
||||
|
||||
# Plugin frame
|
||||
plugin_frame.grid(row=ui_row, columnspan=2, sticky=tk.NSEW)
|
||||
plugin_frame.columnconfigure(1, weight=1)
|
||||
ui_row += 1
|
||||
|
||||
# Add plugin widgets
|
||||
if isinstance(appitem, tuple) and len(appitem) == 2:
|
||||
appitem[0].grid(row=ui_row - 1, column=0, sticky=tk.W)
|
||||
appitem[1].grid(row=ui_row - 1, column=1, sticky=tk.EW)
|
||||
else:
|
||||
appitem.grid(row=ui_row - 1, column=0, columnspan=2, sticky=tk.EW)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
class AppWindow:
|
||||
"""Define the main application window."""
|
||||
|
||||
@@ -456,13 +483,13 @@ class AppWindow:
|
||||
|
||||
PADX = 5
|
||||
|
||||
def __init__(self, master: tk.Tk): # noqa: C901, CCR001 # TODO - can possibly factor something out
|
||||
def __init__(self, master: tk.Tk): # noqa: CCR001 TODO - can possibly factor something out
|
||||
|
||||
self.early_journal_events: list[MutableMapping[str, Any]] = []
|
||||
|
||||
self.capi_query_holdoff_time = config.get_int('querytime', default=0) + companion.capi_query_cooldown
|
||||
self.capi_fleetcarrier_query_holdoff_time = config.get_int('fleetcarrierquerytime', default=0) \
|
||||
+ companion.capi_fleetcarrier_query_cooldown
|
||||
query_time = config.get_int('querytime', default=0)
|
||||
fleetcarrier_time = config.get_int('fleetcarrierquerytime', default=0)
|
||||
self.capi_query_holdoff_time = query_time + companion.capi_query_cooldown
|
||||
self.capi_fleetcarrier_query_holdoff_time = fleetcarrier_time + companion.capi_fleetcarrier_query_cooldown
|
||||
|
||||
self.w = master
|
||||
self.w.title(applongname)
|
||||
@@ -505,9 +532,9 @@ class AppWindow:
|
||||
# TODO: Export to files and merge from them in future ?
|
||||
self.theme_icon = tk.PhotoImage(
|
||||
data='R0lGODlhFAAQAMZQAAoKCQoKCgsKCQwKCQsLCgwLCg4LCQ4LCg0MCg8MCRAMCRANChINCREOChIOChQPChgQChgRCxwTCyYVCSoXCS0YCTkdCTseCT0fCTsjDU0jB0EnDU8lB1ElB1MnCFIoCFMoCEkrDlkqCFwrCGEuCWIuCGQvCFs0D1w1D2wyCG0yCF82D182EHE0CHM0CHQ1CGQ5EHU2CHc3CHs4CH45CIA6CIE7CJdECIdLEolMEohQE5BQE41SFJBTE5lUE5pVE5RXFKNaFKVbFLVjFbZkFrxnFr9oFsNqFsVrF8RsFshtF89xF9NzGNh1GNl2GP+KG////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////yH5BAEKAH8ALAAAAAAUABAAAAeegAGCgiGDhoeIRDiIjIZGKzmNiAQBQxkRTU6am0tPCJSGShuSAUcLoIIbRYMFra4FAUgQAQCGJz6CDQ67vAFJJBi0hjBBD0w9PMnJOkAiJhaIKEI7HRoc19ceNAolwbWDLD8uAQnl5ga1I9CHEjEBAvDxAoMtFIYCBy+kFDKHAgM3ZtgYSLAGgwkp3pEyBOJCC2ELB31QATGioAoVAwEAOw==') # noqa: E501
|
||||
self.theme_minimize = tk.BitmapImage(
|
||||
self.img_theme_minimize = tk.BitmapImage(
|
||||
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f,\n 0xfc, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
||||
self.theme_close = tk.BitmapImage(
|
||||
self.img_theme_close = tk.BitmapImage(
|
||||
data='#define im_width 16\n#define im_height 16\nstatic unsigned char im_bits[] = {\n 0x00, 0x00, 0x00, 0x00, 0x0c, 0x30, 0x1c, 0x38, 0x38, 0x1c, 0x70, 0x0e,\n 0xe0, 0x07, 0xc0, 0x03, 0xc0, 0x03, 0xe0, 0x07, 0x70, 0x0e, 0x38, 0x1c,\n 0x1c, 0x38, 0x0c, 0x30, 0x00, 0x00, 0x00, 0x00 };\n') # noqa: E501
|
||||
|
||||
frame = tk.Frame(self.w, name=appname.lower())
|
||||
@@ -532,58 +559,27 @@ class AppWindow:
|
||||
|
||||
ui_row = 1
|
||||
|
||||
self.cmdr_label.grid(row=ui_row, column=0, sticky=tk.W)
|
||||
self.cmdr.grid(row=ui_row, column=1, sticky=tk.EW)
|
||||
ui_row += 1
|
||||
label_value_pairs = [
|
||||
(self.cmdr_label, self.cmdr),
|
||||
(self.ship_label, self.ship),
|
||||
]
|
||||
|
||||
self.ship_label.grid(row=ui_row, column=0, sticky=tk.W)
|
||||
self.ship.grid(row=ui_row, column=1, sticky=tk.EW)
|
||||
ui_row += 1
|
||||
|
||||
self.suit_grid_row = ui_row
|
||||
self.suit_grid_row = ui_row + len(label_value_pairs)
|
||||
self.suit_shown = False
|
||||
ui_row += 1
|
||||
label_value_pairs.append((self.suit_label, self.suit))
|
||||
|
||||
self.system_label.grid(row=ui_row, column=0, sticky=tk.W)
|
||||
self.system.grid(row=ui_row, column=1, sticky=tk.EW)
|
||||
ui_row += 1
|
||||
label_value_pairs.extend([
|
||||
(self.system_label, self.system),
|
||||
(self.station_label, self.station),
|
||||
])
|
||||
|
||||
self.station_label.grid(row=ui_row, column=0, sticky=tk.W)
|
||||
self.station.grid(row=ui_row, column=1, sticky=tk.EW)
|
||||
ui_row += 1
|
||||
for label, value in label_value_pairs:
|
||||
label.grid(row=ui_row, column=0, sticky=tk.W)
|
||||
value.grid(row=ui_row, column=1, sticky=tk.EW) # type: ignore
|
||||
ui_row += 1
|
||||
|
||||
plugin_no = 0
|
||||
for plugin in plug.PLUGINS:
|
||||
# Per plugin separator
|
||||
plugin_sep = tk.Frame(
|
||||
frame, highlightthickness=1, name=f"plugin_hr_{plugin_no + 1}"
|
||||
)
|
||||
# Per plugin frame, for it to use as its parent for own widgets
|
||||
plugin_frame = tk.Frame(
|
||||
frame,
|
||||
name=f"plugin_{plugin_no + 1}"
|
||||
)
|
||||
appitem = plugin.get_app(plugin_frame)
|
||||
if appitem:
|
||||
plugin_no += 1
|
||||
plugin_sep.grid(columnspan=2, sticky=tk.EW)
|
||||
ui_row = frame.grid_size()[1]
|
||||
plugin_frame.grid(
|
||||
row=ui_row, columnspan=2, sticky=tk.NSEW
|
||||
)
|
||||
plugin_frame.columnconfigure(1, weight=1)
|
||||
if isinstance(appitem, tuple) and len(appitem) == 2:
|
||||
ui_row = frame.grid_size()[1]
|
||||
appitem[0].grid(row=ui_row, column=0, sticky=tk.W)
|
||||
appitem[1].grid(row=ui_row, column=1, sticky=tk.EW)
|
||||
|
||||
else:
|
||||
appitem.grid(columnspan=2, sticky=tk.EW)
|
||||
|
||||
else:
|
||||
# This plugin didn't provide any UI, so drop the frames
|
||||
plugin_frame.destroy()
|
||||
plugin_sep.destroy()
|
||||
ui_row = frame.grid_size()[1] # start row
|
||||
frame = _config_plugins(frame, ui_row)
|
||||
|
||||
# LANG: Update button in main window
|
||||
self.button = ttk.Button(
|
||||
@@ -614,8 +610,8 @@ class AppWindow:
|
||||
self.status.grid(columnspan=2, sticky=tk.EW)
|
||||
|
||||
for child in frame.winfo_children():
|
||||
child.grid_configure(padx=self.PADX, pady=( # type: ignore
|
||||
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
|
||||
pad_y = 2 if sys.platform != 'win32' or isinstance(child, tk.Frame) else 0
|
||||
child.grid_configure(padx=self.PADX, pady=pad_y) # type: ignore
|
||||
|
||||
self.menubar = tk.Menu()
|
||||
|
||||
@@ -692,12 +688,12 @@ class AppWindow:
|
||||
theme_titlebar.bind('<Button-1>', self.drag_start)
|
||||
theme_titlebar.bind('<B1-Motion>', self.drag_continue)
|
||||
theme_titlebar.bind('<ButtonRelease-1>', self.drag_end)
|
||||
theme_minimize = tk.Label(self.theme_menubar, image=self.theme_minimize)
|
||||
theme_minimize = tk.Label(self.theme_menubar, image=self.img_theme_minimize)
|
||||
theme_minimize.grid(row=0, column=3, padx=2)
|
||||
theme.button_bind(theme_minimize, self.oniconify, image=self.theme_minimize)
|
||||
theme_close = tk.Label(self.theme_menubar, image=self.theme_close)
|
||||
theme.button_bind(theme_minimize, self.oniconify, image=self.img_theme_minimize)
|
||||
theme_close = tk.Label(self.theme_menubar, image=self.img_theme_close)
|
||||
theme_close.grid(row=0, column=4, padx=2)
|
||||
theme.button_bind(theme_close, self.onexit, image=self.theme_close)
|
||||
theme.button_bind(theme_close, self.onexit, image=self.img_theme_close)
|
||||
self.theme_file_menu = tk.Label(self.theme_menubar, anchor=tk.W)
|
||||
self.theme_file_menu.grid(row=1, column=0, padx=self.PADX, sticky=tk.W)
|
||||
theme.button_bind(self.theme_file_menu,
|
||||
@@ -717,15 +713,15 @@ class AppWindow:
|
||||
e.widget.winfo_rooty()
|
||||
+ e.widget.winfo_height()))
|
||||
tk.Frame(self.theme_menubar, highlightthickness=1).grid(columnspan=5, padx=self.PADX, sticky=tk.EW)
|
||||
theme.register(self.theme_minimize) # images aren't automatically registered
|
||||
theme.register(self.theme_close)
|
||||
theme.register(self.img_theme_minimize) # images aren't automatically registered
|
||||
theme.register(self.img_theme_close)
|
||||
self.blank_menubar = tk.Frame(frame, name="blank_menubar")
|
||||
tk.Label(self.blank_menubar).grid()
|
||||
tk.Label(self.blank_menubar).grid()
|
||||
tk.Frame(self.blank_menubar, height=2).grid()
|
||||
theme.register_alternate((self.menubar, self.theme_menubar, self.blank_menubar),
|
||||
{'row': 0, 'columnspan': 2, 'sticky': tk.NSEW})
|
||||
self.w.resizable(tk.TRUE, tk.FALSE)
|
||||
self.w.resizable(True, True)
|
||||
|
||||
# update geometry
|
||||
if config.get_str('geometry'):
|
||||
@@ -771,13 +767,76 @@ class AppWindow:
|
||||
self.status['text'] = tr.tl("Awaiting Full CMDR Login") # LANG: Await Full CMDR Login to Game
|
||||
|
||||
# Start a protocol handler to handle cAPI registration. Requires main loop to be running.
|
||||
self.w.after_idle(lambda: protocol.protocolhandler.start(self.w))
|
||||
self.w.after_idle(lambda: self.postprefs(False)) # Companion login happens in callback from monitor
|
||||
self.w.after_idle(self._after_idle_init)
|
||||
self.toggle_suit_row(visible=False)
|
||||
if args.start_min:
|
||||
logger.warning("Trying to start minimized")
|
||||
self.oniconify() if root.overrideredirect() else self.w.wm_iconify()
|
||||
|
||||
def _after_idle_init(self):
|
||||
"""Combine after_idle tasks for startup to ensure only one queue."""
|
||||
protocol.protocolhandler.start(self.w)
|
||||
self.postprefs(False) # Companion login happens in callback from monitor
|
||||
|
||||
def _set_grabber(self, frame: tk.Frame | ttk.Frame) -> None:
|
||||
if config.get_int("theme") != theme.THEME_DEFAULT:
|
||||
grip_size = 14
|
||||
|
||||
self.resize_grip = tk.Canvas(
|
||||
self.w,
|
||||
width=grip_size,
|
||||
height=grip_size,
|
||||
highlightthickness=0,
|
||||
bg=frame.cget("background"),
|
||||
)
|
||||
|
||||
# draw diagonal grip lines
|
||||
for i in range(4, grip_size, 4):
|
||||
self.resize_grip.create_line(i, grip_size, grip_size, i, fill="#404040")
|
||||
|
||||
self.resize_grip.bind(
|
||||
"<Enter>",
|
||||
lambda e: self.resize_grip.configure(bg=frame.cget("background")),
|
||||
)
|
||||
self.resize_grip.bind(
|
||||
"<Leave>",
|
||||
lambda e: self.resize_grip.configure(bg=frame.cget("background")),
|
||||
)
|
||||
|
||||
self.resize_grip.place(relx=1.0, rely=1.0, anchor="se")
|
||||
|
||||
def _resize_drag(event: tk.Event):
|
||||
dx = event.x_root - self._resize_start_x
|
||||
dy = event.y_root - self._resize_start_y
|
||||
|
||||
# calculate proposed size
|
||||
new_w = self._resize_start_w + dx
|
||||
new_h = self._resize_start_h + dy
|
||||
|
||||
# enforce minimum size
|
||||
min_w, min_h = 50, 50
|
||||
if new_w < min_w:
|
||||
new_w = min_w
|
||||
if new_h < min_h:
|
||||
new_h = min_h
|
||||
|
||||
self.w.geometry(f"{new_w}x{new_h}")
|
||||
|
||||
def _resize_start(event: tk.Event):
|
||||
self._resize_start_x = event.x_root
|
||||
self._resize_start_y = event.y_root
|
||||
self._resize_start_w = self.w.winfo_width()
|
||||
self._resize_start_h = self.w.winfo_height()
|
||||
|
||||
self.resize_grip.bind("<Button-1>", _resize_start)
|
||||
self.resize_grip.bind("<B1-Motion>", _resize_drag)
|
||||
else:
|
||||
try:
|
||||
if self.resize_grip:
|
||||
self.resize_grip.place_forget()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update_suit_text(self) -> None:
|
||||
"""Update the suit text for current type and loadout."""
|
||||
if not monitor.state['Odyssey']:
|
||||
@@ -848,6 +907,8 @@ class AppWindow:
|
||||
if dologin and monitor.cmdr:
|
||||
self.login() # Login if not already logged in with this Cmdr
|
||||
|
||||
self._set_grabber(root.nametowidget(".edmarketconnector"))
|
||||
|
||||
if postargs.get('Update') and postargs.get('Track'):
|
||||
track = postargs.get('Track')
|
||||
# LANG: Inform the user the Update Track has changed
|
||||
@@ -2382,8 +2443,6 @@ sys.path: {sys.path}'''
|
||||
root.after(2, show_killswitch_poppup, root)
|
||||
# Start the main event loop
|
||||
try:
|
||||
check_for_fdev_updates()
|
||||
check_for_datafile_updates()
|
||||
root.mainloop()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Ctrl+C Detected, Attempting Clean Shutdown")
|
||||
|
||||
@@ -845,3 +845,51 @@
|
||||
|
||||
/* common_coreutils.py: Unidentified colonisation ship; In files: common_coreutils.py:84:84 */
|
||||
"System Colonisation Ship" = "System Colonisation Ship";
|
||||
|
||||
/* plugin_browser.py: Filter Plugin Categories */
|
||||
"Filter by Category:" = "Filter by Category:";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Plug Name */
|
||||
"Name" = "Name";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Plug Ver */
|
||||
"Version" = "Version";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Last Update */
|
||||
"Last Updated" = "Last Updated";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Category */
|
||||
"Categories" = "Categories";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Description */
|
||||
"Description" = "Description";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Stars */
|
||||
"Stars" = "Stars";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Title */
|
||||
"Plugin Browser" = "Plugin Browser";
|
||||
|
||||
/* plugin_browser.py: Open Plugin Repo */
|
||||
"Open Plugin Repository" = "Open Plugin Repository";
|
||||
|
||||
/* plugin_browser.py: Report a Malfunctioning Plugin */
|
||||
"Report Plugin" = "Report Plugin";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Authors */
|
||||
"Authors" = "Authors";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser License */
|
||||
"License" = "License";
|
||||
|
||||
/* plugin_browser.py: Plugin Browser Reqs */
|
||||
"Requirements" = "Requirements";
|
||||
|
||||
/* plugin_browser.py: Last Tested EDMC Version */
|
||||
"Last Tested EDMC: {last_tested}" = "Last Tested EDMC: {last_tested}";
|
||||
|
||||
/* plugin_browser.py: Plugin Project Link */
|
||||
"Main Project Link" = "Main Project Link";
|
||||
|
||||
/* plugin_browser.py: Plugin VT Link */
|
||||
"VirusTotal Link" = "VirusTotal Link";
|
||||
+10
@@ -61,6 +61,16 @@ You should also keep an eye on [our GitHub Discussions](https://github.com/EDCD/
|
||||
in case there are any proposed changes to EDMC plugin functionality. You can
|
||||
do this by ensuring 'Discussions' is also ticked when following the steps
|
||||
above to set up a 'Custom' watch on this repository.
|
||||
---
|
||||
|
||||
## Plugin Versioning
|
||||
|
||||
We strongly encourage the use of [Semantic Version](https://semver.org/#semantic-versioning-specification-semver)
|
||||
for plugins. This should be done by establishing a `VERSION` constant or `__version__` dunder as a string with the value `Major.Minor.Patch`.
|
||||
|
||||
This version is used by the EDMC Plugin Browser to compare versions of plugins and allow plugins to use upcoming
|
||||
planned features such as the Plugin Auto-Updater. While this is not a required part of the Plugin spec, it is highly
|
||||
recommended and may become required at a later date.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -177,6 +177,15 @@ class Plugin:
|
||||
logger.exception(f'Failed for Plugin "{self.name}"')
|
||||
return None
|
||||
|
||||
def get_version(self) -> str | None:
|
||||
"""
|
||||
If the plugin provides a VERSION identifier, return it.
|
||||
|
||||
:returns: String with the VERSION information or None.
|
||||
"""
|
||||
has_version = self._get_func('VERSION') or self._get_func('__version__')
|
||||
return has_version if has_version else None
|
||||
|
||||
|
||||
def load_plugins(master: tk.Tk) -> None:
|
||||
"""Find and load all plugins."""
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
"""
|
||||
plugin_browser.py - EDMC's Official Plugin Browser.
|
||||
|
||||
Copyright (c) EDCD, All Rights Reserved
|
||||
Licensed under the GNU General Public License v2 or later.
|
||||
See LICENSE file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import json
|
||||
import sys
|
||||
import webbrowser
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
from PIL import Image, ImageTk
|
||||
from typing import Callable, cast
|
||||
import requests
|
||||
from io import BytesIO
|
||||
from datetime import datetime
|
||||
import myNotebook as nb # noqa: N813
|
||||
import plug
|
||||
from config import appversion_nobuild
|
||||
from EDMCLogging import get_main_logger
|
||||
from l10n import translations as tr
|
||||
from ttkHyperlinkLabel import HyperlinkLabel
|
||||
import semantic_version
|
||||
|
||||
logger = get_main_logger()
|
||||
|
||||
# TODO in 6.2 or later: Install/Uninstall Plugin, notify user a plugin update is available.
|
||||
|
||||
|
||||
def get_plugins():
|
||||
"""DEV: Print Installed Non-Core Plugins."""
|
||||
print("Installed Plugins")
|
||||
for plugin in plug.PLUGINS:
|
||||
if plugin.folder: # Exclude Default Plugins
|
||||
print(vars(plugin))
|
||||
print(plugin.get_version())
|
||||
|
||||
|
||||
def read_plugin_list():
|
||||
"""Read the Plugin List distributed by EDMC."""
|
||||
with open("master_plugin_list.json", encoding="utf-8") as plugin_list_file:
|
||||
plugin_list = json.load(plugin_list_file)
|
||||
plugins_by_id = {p["pluginName"]: p for p in plugin_list}
|
||||
return plugin_list, plugins_by_id
|
||||
|
||||
|
||||
def open_plugin_main(plugin) -> None:
|
||||
"""Open the Plugin's Main Repository."""
|
||||
webbrowser.open(plugin.get("pluginMainLink"))
|
||||
|
||||
|
||||
def report_plugin(plugin) -> None: # FIXME: Update to live repo.
|
||||
"""Open the Webbrowser to the new plugin issue report."""
|
||||
webbrowser.open(
|
||||
f"https://github.com/Rixxan/EDMC_Plugin_Registry_Dev/issues/new?"
|
||||
f"template=report_plugin.md&title=Plugin Report: "
|
||||
f"{plugin.get('pluginName')} ({plugin.get('pluginHash')[:8]})"
|
||||
)
|
||||
|
||||
|
||||
class PluginBrowserMixIn:
|
||||
"""Separated Class for the Plugin Browser."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Plugin Browser."""
|
||||
self.PADX = 10
|
||||
self.BUTTONX = 12 # indent Checkbuttons and Radiobuttons
|
||||
self.LISTX = 25 # indent listed items
|
||||
self.PADY = 1 # close spacing
|
||||
self.BOXY = 2 # box spacing
|
||||
self.SEPY = 10 # separator line spacing
|
||||
|
||||
# Setup Plugin Browser Options
|
||||
self.browser_plugins, self.browser_plugins_by_id = read_plugin_list()
|
||||
self._plugin_sort_reverse = {
|
||||
"pluginName": False,
|
||||
"pluginLastUpdate": False,
|
||||
"pluginStars": False,
|
||||
}
|
||||
self.selected_plugin: dict | None = None
|
||||
self._plugin_last_tested_label: nb.Label | None = None
|
||||
|
||||
# def dev_install_plugin(self, plugin) -> None:
|
||||
# """TEMP."""
|
||||
# print("Install", plugin.get("pluginName"))
|
||||
#
|
||||
# def dev_uninstall_plugin(self, plugin) -> None:
|
||||
# """TEMP."""
|
||||
# print("Uninstall", plugin.get("pluginName"))
|
||||
#
|
||||
# def dev_update_plugin(self, plugin) -> None:
|
||||
# """TEMP."""
|
||||
# print("Update", plugin.get("pluginName"))
|
||||
|
||||
def setup_browser_tab(self, notebook: nb.Notebook, row) -> None:
|
||||
"""Set up the Plugin Browser tab in Preferences."""
|
||||
if not hasattr(self, "browser_plugins"):
|
||||
self.browser_plugins, self.browser_plugins_by_id = read_plugin_list()
|
||||
self._plugin_sort_reverse = {"pluginName": False, "pluginLastUpdate": False}
|
||||
self.selected_plugin = None
|
||||
|
||||
categories = sorted(
|
||||
{
|
||||
cat
|
||||
for plugin in self.browser_plugins
|
||||
for cat in plugin.get("pluginCategory", [])
|
||||
}
|
||||
)
|
||||
categories.insert(0, "All")
|
||||
|
||||
plugins_frame = nb.Frame(notebook)
|
||||
HyperlinkLabel(
|
||||
plugins_frame,
|
||||
text="EDMC Plugin Browser",
|
||||
background=nb.Label().cget("background"),
|
||||
url="https://github.com/Rixxan/EDMC_Plugin_Registry_Dev", # Fixme
|
||||
underline=True,
|
||||
).grid(row=row.get(), padx=self.PADX, pady=(self.PADY, 1), sticky=tk.W)
|
||||
|
||||
# Add after the "Available Plugins" label
|
||||
categories = sorted(
|
||||
{
|
||||
cat
|
||||
for plugin in self.browser_plugins
|
||||
for cat in plugin.get("pluginCategory", [])
|
||||
}
|
||||
)
|
||||
categories.insert(0, "All") # default to show all
|
||||
|
||||
self.selected_category = tk.StringVar(value="All")
|
||||
|
||||
# Place the dropdown on its own row (separate from the tree)
|
||||
# LANG: Filter Plugin Categories
|
||||
category_label = nb.Label(plugins_frame, text=tr.tl("Filter by Category:"))
|
||||
category_label.grid(
|
||||
row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY)
|
||||
)
|
||||
|
||||
category_dropdown = nb.OptionMenu(
|
||||
plugins_frame,
|
||||
self.selected_category,
|
||||
"All",
|
||||
*categories,
|
||||
command=self.__on_category_selected, # callback
|
||||
)
|
||||
category_dropdown.config(width=25) # make the dropdown wider
|
||||
category_dropdown.grid(
|
||||
row=row.get(), column=0, sticky=tk.W, padx=self.PADX, pady=(0, self.PADY)
|
||||
)
|
||||
|
||||
ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
|
||||
columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
|
||||
)
|
||||
tree_container = nb.Frame(plugins_frame) # type: ignore
|
||||
tree_row = row.get()
|
||||
tree_container.grid(row=tree_row, column=0, sticky=tk.NSEW, padx=self.PADX)
|
||||
|
||||
plugins_frame.columnconfigure(0, weight=1)
|
||||
|
||||
columns = ("name", "version", "updated", "category", "description", "stars")
|
||||
|
||||
self.plugins_tree = ttk.Treeview(
|
||||
tree_container,
|
||||
columns=columns,
|
||||
show="headings",
|
||||
selectmode="browse",
|
||||
height=12,
|
||||
)
|
||||
|
||||
self.plugins_tree.heading(
|
||||
"name",
|
||||
# LANG: Plugin Browser Plug Name
|
||||
text=tr.tl("Name"),
|
||||
command=lambda: self.__sort_plugins("pluginName"),
|
||||
)
|
||||
# LANG: Plugin Browser Plug Ver
|
||||
self.plugins_tree.heading("version", text=tr.tl("Version"))
|
||||
self.plugins_tree.heading(
|
||||
"updated",
|
||||
text=tr.tl("Last Updated"), # LANG: Plugin Browser Last Update
|
||||
command=lambda: self.__sort_plugins("pluginLastUpdate"),
|
||||
)
|
||||
self.plugins_tree.heading(
|
||||
"category",
|
||||
text=tr.tl("Categories"), # LANG: Plugin Browser Category
|
||||
)
|
||||
self.plugins_tree.heading(
|
||||
"description",
|
||||
text=tr.tl("Description"), # LANG: Plugin Browser Description
|
||||
)
|
||||
self.plugins_tree.heading(
|
||||
"stars",
|
||||
# LANG: Plugin Browser Stars
|
||||
text=tr.tl("Stars"),
|
||||
command=lambda: self.__sort_plugins("pluginStars"),
|
||||
)
|
||||
|
||||
self.plugins_tree.column("name", width=40)
|
||||
self.plugins_tree.column("version", width=40, anchor=tk.CENTER)
|
||||
self.plugins_tree.column("updated", width=40, anchor=tk.CENTER)
|
||||
self.plugins_tree.column("category", width=120, anchor=tk.CENTER)
|
||||
self.plugins_tree.column("description", width=240)
|
||||
self.plugins_tree.column("stars", width=40)
|
||||
|
||||
scrollbar = ttk.Scrollbar(tree_container, command=self.plugins_tree.yview)
|
||||
self.plugins_tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.plugins_tree.grid(row=0, column=0, sticky=tk.NSEW)
|
||||
scrollbar.grid(row=0, column=1, sticky=tk.NS)
|
||||
|
||||
tree_container.columnconfigure(0, weight=1)
|
||||
tree_container.rowconfigure(0, weight=0)
|
||||
|
||||
ttk.Separator(plugins_frame, orient=tk.HORIZONTAL).grid(
|
||||
columnspan=4, padx=self.PADX, pady=self.SEPY, sticky=tk.EW, row=row.get()
|
||||
)
|
||||
|
||||
self.plugin_details_frame = nb.Frame(plugins_frame) # type: ignore
|
||||
details_row = row.get()
|
||||
self.plugin_details_frame.grid(
|
||||
row=details_row, column=0, sticky=tk.NW, padx=self.PADX
|
||||
)
|
||||
self.plugin_details_frame.columnconfigure(1, weight=1)
|
||||
self.plugin_details_frame.grid_propagate(True)
|
||||
|
||||
self._plugin_icon_cache: dict[str, ImageTk.PhotoImage] = {}
|
||||
self._plugin_icon_label = nb.Label(self.plugin_details_frame)
|
||||
self._plugin_icon_label.grid(
|
||||
row=0, column=0, rowspan=3, sticky=tk.NW, padx=(0, 12)
|
||||
)
|
||||
self._plugin_hyperlinks: list[HyperlinkLabel] = []
|
||||
self._plugin_title = nb.Label(
|
||||
self.plugin_details_frame,
|
||||
font=("TkDefaultFont", 10, "bold"),
|
||||
wraplength=600,
|
||||
justify=tk.LEFT,
|
||||
)
|
||||
self._plugin_title.grid(row=0, column=1, sticky=tk.W)
|
||||
|
||||
self._plugin_meta = nb.Label(
|
||||
self.plugin_details_frame,
|
||||
justify=tk.LEFT,
|
||||
)
|
||||
self._plugin_meta.grid(row=1, column=1, sticky=tk.W, pady=(1, 2))
|
||||
|
||||
self._plugin_description = nb.Label(
|
||||
self.plugin_details_frame,
|
||||
wraplength=600,
|
||||
justify=tk.LEFT,
|
||||
)
|
||||
self._plugin_description.grid(row=2, column=1, sticky=tk.W)
|
||||
self._create_plugin_action_buttons()
|
||||
self.plugins_tree.bind("<<TreeviewSelect>>", self.__on_plugin_selected)
|
||||
self.__populate_plugins_tree()
|
||||
|
||||
notebook.add(
|
||||
# LANG: Plugin Browser Title
|
||||
plugins_frame,
|
||||
text=tr.tl("Plugin Browser"),
|
||||
)
|
||||
|
||||
def __get_filtered_plugins(self) -> list[dict]:
|
||||
category = self.selected_category.get()
|
||||
if category == "All":
|
||||
return self.browser_plugins
|
||||
return [
|
||||
p for p in self.browser_plugins if category in p.get("pluginCategory", [])
|
||||
]
|
||||
|
||||
def _run_plugin_action(self, handler) -> None:
|
||||
if self.selected_plugin is None:
|
||||
return
|
||||
handler(self.selected_plugin)
|
||||
|
||||
def _create_plugin_action_buttons(self) -> None:
|
||||
self.plugin_actions_frame = nb.Frame(self.plugin_details_frame) # type: ignore
|
||||
self.plugin_actions_frame.grid(
|
||||
row=0, column=2, rowspan=6, sticky=tk.N, padx=(12, 0)
|
||||
)
|
||||
|
||||
self._plugin_action_buttons: list[nb.Button] = []
|
||||
|
||||
actions = [
|
||||
(
|
||||
# LANG: Open Plugin Repo
|
||||
tr.tl("Open Plugin Repository"),
|
||||
open_plugin_main,
|
||||
),
|
||||
# ("Install", self.dev_install_plugin), # Upcoming Feature
|
||||
# ("Uninstall", self.dev_uninstall_plugin), # Upcoming Feature
|
||||
# ("Update", self.dev_update_plugin), # Upcoming Feature
|
||||
(
|
||||
# LANG: Report a Malfunctioning Plugin
|
||||
tr.tl("Report Plugin"),
|
||||
report_plugin,
|
||||
),
|
||||
]
|
||||
|
||||
for idx, (label, handler) in enumerate(actions):
|
||||
btn = nb.Button(
|
||||
self.plugin_actions_frame,
|
||||
text=label,
|
||||
command=cast(
|
||||
Callable[[], None], lambda h=handler: self._run_plugin_action(h)
|
||||
),
|
||||
)
|
||||
btn.grid(
|
||||
row=idx,
|
||||
column=0,
|
||||
padx=self.LISTX,
|
||||
pady=(0 if idx == 0 else self.PADY),
|
||||
sticky=tk.EW,
|
||||
)
|
||||
btn.state(["disabled"])
|
||||
self._plugin_action_buttons.append(btn)
|
||||
|
||||
self.plugin_actions_frame.columnconfigure(0, weight=1)
|
||||
self.plugin_actions_frame.grid_remove()
|
||||
|
||||
def __populate_plugins_tree(self, plugins: list[dict] | None = None) -> None:
|
||||
self.plugins_tree.delete(*self.plugins_tree.get_children())
|
||||
|
||||
if plugins is None:
|
||||
plugins = self.__get_filtered_plugins()
|
||||
|
||||
# Rebuild Treeview ID to plugin mapping
|
||||
self.browser_plugins_by_id = {}
|
||||
|
||||
for index, plugin in enumerate(plugins):
|
||||
plugin_id = f"plugin_{index}"
|
||||
self.browser_plugins_by_id[plugin_id] = plugin
|
||||
self.plugins_tree.insert(
|
||||
"",
|
||||
tk.END,
|
||||
iid=plugin_id,
|
||||
values=(
|
||||
plugin.get("pluginName", ""),
|
||||
plugin.get("pluginVer", ""),
|
||||
plugin.get("pluginLastUpdate", ""),
|
||||
", ".join(plugin.get("pluginCategory", [])),
|
||||
plugin.get("pluginDesc", ""),
|
||||
plugin.get("pluginStars", ""),
|
||||
),
|
||||
)
|
||||
self._set_plugin_action_visibility(bool(plugins))
|
||||
self.plugins_tree.selection_remove(self.plugins_tree.selection())
|
||||
self.selected_plugin = None
|
||||
self.__clear_plugin_details()
|
||||
|
||||
def __on_category_selected(self, _event=None):
|
||||
# Simply repopulate Treeview with filtered plugins
|
||||
self.__populate_plugins_tree()
|
||||
|
||||
def __sort_plugins(self, key: str) -> None:
|
||||
# Initialize sort state if missing
|
||||
if key not in self._plugin_sort_reverse:
|
||||
self._plugin_sort_reverse[key] = False
|
||||
|
||||
reverse = self._plugin_sort_reverse[key]
|
||||
self._plugin_sort_reverse[key] = not reverse
|
||||
self._current_sort_key = key
|
||||
|
||||
if key == "pluginLastUpdate":
|
||||
self.browser_plugins.sort(
|
||||
key=lambda p: datetime.fromisoformat(
|
||||
p.get("pluginLastUpdate") or "1900-01-01"
|
||||
),
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
elif key == "pluginStars":
|
||||
self.browser_plugins.sort(
|
||||
key=lambda p: int(p.get("pluginStars") or 0),
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
else:
|
||||
self.browser_plugins.sort(
|
||||
key=lambda p: (p.get(key) or "").casefold(),
|
||||
reverse=reverse,
|
||||
)
|
||||
|
||||
# Repopulate with current category filter
|
||||
self.__populate_plugins_tree()
|
||||
|
||||
def __on_plugin_selected(self, event) -> None:
|
||||
self._clear_plugin_links()
|
||||
|
||||
selection = self.plugins_tree.selection()
|
||||
if not selection:
|
||||
self._clear_selection_state()
|
||||
return
|
||||
|
||||
plugin_id = selection[0]
|
||||
plugin = self.browser_plugins_by_id.get(plugin_id)
|
||||
if not plugin:
|
||||
self._clear_selection_state()
|
||||
return
|
||||
|
||||
self.selected_plugin = plugin
|
||||
self._set_plugin_action_state(True)
|
||||
|
||||
self._plugin_title.configure(text=plugin.get("pluginName", ""))
|
||||
|
||||
meta_lines = self._build_plugin_meta(plugin)
|
||||
self._plugin_meta.configure(text="\n".join(meta_lines))
|
||||
|
||||
link_row = self._update_last_tested(plugin)
|
||||
|
||||
self._build_plugin_links(plugin, link_row)
|
||||
|
||||
self._plugin_description.configure(text=plugin.get("pluginDesc", ""))
|
||||
|
||||
self._update_plugin_icon(plugin_id, plugin)
|
||||
|
||||
self._set_plugin_action_visibility(bool(self.browser_plugins))
|
||||
|
||||
def _clear_selection_state(self) -> None:
|
||||
self.selected_plugin = None
|
||||
self.__clear_plugin_details()
|
||||
|
||||
def _build_plugin_meta(self, plugin: dict) -> list[str]:
|
||||
fields = [
|
||||
(tr.tl("Version"), "pluginVer"), # LANG: Plugin Browser Plug Ver
|
||||
(
|
||||
# LANG: Plugin Browser Last Update
|
||||
tr.tl("Last Updated"),
|
||||
"pluginLastUpdate",
|
||||
),
|
||||
(
|
||||
# LANG: Plugin Browser Category
|
||||
tr.tl("Categories"),
|
||||
"pluginCategory",
|
||||
lambda v: ", ".join(v),
|
||||
),
|
||||
(
|
||||
# LANG: Plugin Browser Authors
|
||||
tr.tl("Authors"),
|
||||
"pluginAuthors",
|
||||
lambda v: ", ".join(v),
|
||||
),
|
||||
(tr.tl("License"), "pluginLicense"), # LANG: Plugin Browser License
|
||||
(tr.tl("Stars"), "pluginStars"), # LANG: Plugin Browser Stars
|
||||
(
|
||||
# LANG: Plugin Browser Reqs
|
||||
tr.tl("Requirements"),
|
||||
"pluginRequirements",
|
||||
lambda v: ", ".join(v),
|
||||
),
|
||||
]
|
||||
|
||||
meta = []
|
||||
for label, key, *formatter in fields:
|
||||
value = plugin.get(key)
|
||||
if value:
|
||||
if formatter:
|
||||
value = formatter[0](value) # type: ignore
|
||||
meta.append(f"{label}: {value}")
|
||||
|
||||
return meta
|
||||
|
||||
def _update_last_tested(self, plugin: dict) -> int:
|
||||
last_tested = plugin.get("pluginLastTestedEDMC")
|
||||
if not last_tested:
|
||||
if getattr(self, "_plugin_last_tested_label", None):
|
||||
self._plugin_last_tested_label.grid_forget() # type: ignore
|
||||
self._plugin_last_tested_label = None
|
||||
return 3
|
||||
|
||||
tested = semantic_version.Version.coerce(last_tested)
|
||||
current = appversion_nobuild()
|
||||
|
||||
major_diff = tested.major - current.major
|
||||
minor_diff = tested.minor - current.minor
|
||||
|
||||
if major_diff == 0 and abs(minor_diff) <= 1:
|
||||
color = "green"
|
||||
elif major_diff == 0 and abs(minor_diff) <= 2:
|
||||
color = "dark goldenrod"
|
||||
else:
|
||||
color = "red"
|
||||
|
||||
# LANG: Last Tested EDMC Version
|
||||
text = tr.tl("Last Tested EDMC: {last_tested}").format(last_tested=last_tested)
|
||||
|
||||
if getattr(self, "_plugin_last_tested_label", None):
|
||||
self._plugin_last_tested_label.configure(text=text, foreground=color) # type: ignore
|
||||
else:
|
||||
self._plugin_last_tested_label = nb.Label(
|
||||
self.plugin_details_frame,
|
||||
text=text,
|
||||
foreground=color,
|
||||
justify=tk.LEFT,
|
||||
)
|
||||
self._plugin_last_tested_label.grid(row=3, column=1, sticky=tk.W)
|
||||
|
||||
return 4
|
||||
|
||||
def _clear_plugin_links(self) -> None:
|
||||
for hl in self._plugin_hyperlinks:
|
||||
hl.destroy()
|
||||
self._plugin_hyperlinks.clear()
|
||||
|
||||
def _build_plugin_links(self, plugin: dict, start_row: int) -> None:
|
||||
links = [
|
||||
(
|
||||
# LANG: Plugin Project Link
|
||||
tr.tl("Main Project Link"),
|
||||
plugin.get("pluginMainLink"),
|
||||
),
|
||||
(tr.tl("VirusTotal Link"), plugin.get("pluginVT")), # LANG: Plugin VT Link
|
||||
]
|
||||
|
||||
bg = nb.Label().cget("background")
|
||||
row = start_row
|
||||
|
||||
for text, url in links:
|
||||
if not url:
|
||||
continue
|
||||
|
||||
hl = HyperlinkLabel(
|
||||
self.plugin_details_frame,
|
||||
text=text,
|
||||
background=bg,
|
||||
url=url,
|
||||
underline=True,
|
||||
)
|
||||
hl.grid(row=row, column=1, padx=self.PADX, pady=self.PADY, sticky=tk.W)
|
||||
self._plugin_hyperlinks.append(hl)
|
||||
row += 1
|
||||
|
||||
def _update_plugin_icon(self, plugin_id: str, plugin: dict) -> None:
|
||||
icon_url = plugin.get("pluginIcon")
|
||||
icon_key = plugin_id if icon_url else "__fallback__"
|
||||
url = icon_url or os.path.join(
|
||||
os.path.dirname(sys.argv[0]),
|
||||
"io.edcd.EDMarketConnector.png",
|
||||
)
|
||||
self.__load_plugin_icon(icon_key, url)
|
||||
|
||||
def __clear_plugin_details(self) -> None:
|
||||
self._plugin_title.configure(text="")
|
||||
self._plugin_meta.configure(text="")
|
||||
self._plugin_description.configure(text="")
|
||||
|
||||
# Last Tested
|
||||
if getattr(self, "_plugin_last_tested_label", None) is not None:
|
||||
self._plugin_last_tested_label.grid_forget() # type: ignore
|
||||
self._plugin_last_tested_label = None
|
||||
|
||||
# Plugin Icon
|
||||
self._plugin_icon_label.configure(image="")
|
||||
self._plugin_icon_label.image = None # type: ignore
|
||||
|
||||
# Hyperlinks
|
||||
if hasattr(self, "_plugin_hyperlinks"):
|
||||
for hl in self._plugin_hyperlinks:
|
||||
hl.destroy()
|
||||
self._plugin_hyperlinks = []
|
||||
|
||||
self._set_plugin_action_state(False)
|
||||
self._set_plugin_action_visibility(False)
|
||||
|
||||
def __load_plugin_icon(self, plugin_id: str, url: str) -> None:
|
||||
if plugin_id in self._plugin_icon_cache:
|
||||
tk_image = self._plugin_icon_cache[plugin_id]
|
||||
self._plugin_icon_label.configure(image=tk_image)
|
||||
self._plugin_icon_label.image = tk_image # type: ignore
|
||||
return
|
||||
|
||||
try:
|
||||
# Open PIL image
|
||||
pil_image = (
|
||||
Image.open(url).convert("RGBA")
|
||||
if os.path.isfile(url)
|
||||
else Image.open(BytesIO(requests.get(url, timeout=5).content)).convert(
|
||||
"RGBA"
|
||||
)
|
||||
)
|
||||
|
||||
# Resize while maintaining aspect ratio
|
||||
pil_image.thumbnail((64, 64), Image.Resampling.LANCZOS)
|
||||
|
||||
# Paste onto fixed 64x64 transparent canvas
|
||||
canvas = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
|
||||
offset = ((64 - pil_image.width) // 2, (64 - pil_image.height) // 2)
|
||||
canvas.paste(pil_image, offset)
|
||||
|
||||
# Convert to Tkinter PhotoImage
|
||||
tk_image = ImageTk.PhotoImage(canvas, master=self.plugins_tree)
|
||||
self._plugin_icon_cache[plugin_id] = tk_image
|
||||
|
||||
self._plugin_icon_label.configure(image=tk_image)
|
||||
self._plugin_icon_label.image = tk_image # type: ignore
|
||||
|
||||
except Exception:
|
||||
self._plugin_icon_label.configure(image="")
|
||||
self._plugin_icon_label.image = None # type: ignore
|
||||
|
||||
def _set_plugin_action_visibility(self, visible: bool) -> None:
|
||||
if not hasattr(self, "plugin_actions_frame"):
|
||||
return
|
||||
|
||||
if visible:
|
||||
self.plugin_actions_frame.grid()
|
||||
else:
|
||||
self.plugin_actions_frame.grid_remove()
|
||||
|
||||
def _set_plugin_action_state(self, enabled: bool) -> None:
|
||||
for btn in self._plugin_action_buttons:
|
||||
if enabled:
|
||||
btn.state(["!disabled"])
|
||||
else:
|
||||
btn.state(["disabled"])
|
||||
@@ -15,6 +15,7 @@ from tkinter import ttk
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
import plugin_browser
|
||||
import myNotebook as nb # noqa: N813
|
||||
import plug
|
||||
from config import appversion_nobuild, config
|
||||
@@ -238,7 +239,7 @@ if sys.platform == 'win32':
|
||||
return '\\'.join(display_components)
|
||||
|
||||
|
||||
class PreferencesDialog(tk.Toplevel):
|
||||
class PreferencesDialog(tk.Toplevel, plugin_browser.PluginBrowserMixIn):
|
||||
"""The EDMC preferences dialog."""
|
||||
|
||||
def __init__(self, parent: tk.Tk, callback: Callable | None):
|
||||
@@ -291,6 +292,7 @@ class PreferencesDialog(tk.Toplevel):
|
||||
self.__setup_output_tab(notebook)
|
||||
self.__setup_privacy_tab(notebook)
|
||||
self.__setup_plugin_tab(notebook)
|
||||
self.setup_browser_tab(notebook, AutoInc())
|
||||
self.__setup_plugin_tabs(notebook)
|
||||
|
||||
# Set up the button frame
|
||||
@@ -981,9 +983,11 @@ class PreferencesDialog(tk.Toplevel):
|
||||
|
||||
for plugin in enabled_plugins:
|
||||
curr_row = row.get()
|
||||
label = nb.Label(plugins_frame,
|
||||
text=plugin.name if plugin.name == plugin.folder
|
||||
else f'{plugin.folder} ({plugin.name})')
|
||||
plugin_name = plugin.name if plugin.name == plugin.folder else f'{plugin.folder} ({plugin.name})'
|
||||
plugver = plugin.get_version()
|
||||
if plugver:
|
||||
plugin_name += f" v. {plugver}"
|
||||
label = nb.Label(plugins_frame, text=plugin_name)
|
||||
|
||||
label.grid(column=0, columnspan=2, padx=self.LISTX, pady=self.PADY, sticky=tk.W, row=curr_row)
|
||||
|
||||
|
||||
@@ -220,38 +220,61 @@ class _Theme:
|
||||
widget.bind('<Enter>', lambda e: self._enter(e, image))
|
||||
widget.bind('<Leave>', lambda e: self._leave(e, image))
|
||||
|
||||
def _enter(self, event: tk.Event, image: tk.BitmapImage | None) -> None:
|
||||
widget = event.widget
|
||||
if widget and widget['state'] != tk.DISABLED:
|
||||
try:
|
||||
widget.configure(state=tk.ACTIVE) # type: ignore
|
||||
def _enter(self, event, widget=None, image=None): # noqa: CCR001
|
||||
"""Handle mouse entering a widget or image."""
|
||||
if widget is not None:
|
||||
# Only apply foreground/background if the widget supports them
|
||||
config_keys = widget.keys() if hasattr(widget, 'keys') else []
|
||||
kwargs = {}
|
||||
if 'foreground' in config_keys:
|
||||
kwargs['foreground'] = self.current.get('highlightforeground', '')
|
||||
if 'background' in config_keys:
|
||||
kwargs['background'] = self.current.get('highlightbackground', '')
|
||||
if kwargs:
|
||||
widget.configure(**kwargs)
|
||||
|
||||
except Exception:
|
||||
logger.exception(f'Failure setting widget active: {widget=}')
|
||||
|
||||
if image:
|
||||
if image is not None:
|
||||
# PhotoImage cannot be configured with fg/bg, skip safely
|
||||
if not isinstance(image, tk.PhotoImage):
|
||||
try:
|
||||
image.configure(foreground=self.current['activeforeground'],
|
||||
background=self.current['activebackground'])
|
||||
config_keys = image.keys() if hasattr(image, 'keys') else []
|
||||
kwargs = {}
|
||||
if 'foreground' in config_keys:
|
||||
kwargs['foreground'] = self.current.get('highlightforeground', '')
|
||||
if 'background' in config_keys:
|
||||
kwargs['background'] = self.current.get('highlightbackground', '')
|
||||
if kwargs:
|
||||
image.configure(**kwargs)
|
||||
except tk.TclError:
|
||||
# silently ignore unsupported image types
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
logger.exception(f'Failure configuring image: {image=}')
|
||||
def _leave(self, event, widget=None, image=None): # noqa: CCR001
|
||||
"""Handle mouse leaving a widget or image."""
|
||||
if widget is not None:
|
||||
config_keys = widget.keys() if hasattr(widget, 'keys') else []
|
||||
kwargs = {}
|
||||
if 'foreground' in config_keys:
|
||||
kwargs['foreground'] = self.current.get('foreground', '')
|
||||
if 'background' in config_keys:
|
||||
kwargs['background'] = self.current.get('background', '')
|
||||
if kwargs:
|
||||
widget.configure(**kwargs)
|
||||
|
||||
def _leave(self, event: tk.Event, image: tk.BitmapImage | None) -> None:
|
||||
widget = event.widget
|
||||
if widget and widget['state'] != tk.DISABLED:
|
||||
try:
|
||||
widget.configure(state=tk.NORMAL) # type: ignore
|
||||
|
||||
except Exception:
|
||||
logger.exception(f'Failure setting widget normal: {widget=}')
|
||||
|
||||
if image:
|
||||
if image is not None:
|
||||
# Skip PhotoImage objects; safely handle other types
|
||||
if not isinstance(image, tk.PhotoImage):
|
||||
try:
|
||||
image.configure(foreground=self.current['foreground'], background=self.current['background'])
|
||||
|
||||
except Exception:
|
||||
logger.exception(f'Failure configuring image: {image=}')
|
||||
config_keys = image.keys() if hasattr(image, 'keys') else []
|
||||
kwargs = {}
|
||||
if 'foreground' in config_keys:
|
||||
kwargs['foreground'] = self.current.get('foreground', '')
|
||||
if 'background' in config_keys:
|
||||
kwargs['background'] = self.current.get('background', '')
|
||||
if kwargs:
|
||||
image.configure(**kwargs)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
# Set up colors
|
||||
def _colors(self, root: tk.Tk, theme: int) -> None:
|
||||
@@ -407,7 +430,7 @@ class _Theme:
|
||||
# Switch menus
|
||||
for pair, gridopts in self.widgets_pair:
|
||||
for widget in pair:
|
||||
if isinstance(widget, tk.Widget):
|
||||
if isinstance(widget, tk.Widget) and not widget.winfo_exists():
|
||||
widget.grid_remove()
|
||||
|
||||
if isinstance(pair[0], tk.Menu):
|
||||
|
||||
@@ -8,10 +8,13 @@ See LICENSE file.
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from tkinter import messagebox
|
||||
from traceback import print_exc
|
||||
@@ -28,35 +31,128 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_main_logger()
|
||||
|
||||
HTTP_TIMEOUT = (5, 20) # (connect, read)
|
||||
HTTP_RETRIES = 3
|
||||
RETRY_BACKOFF = 1.5 # seconds multiplier
|
||||
MAX_WORKERS = 8
|
||||
|
||||
def read_normalized_file(path: pathlib.Path) -> str:
|
||||
|
||||
def read_normalized_file(path: pathlib.Path) -> tuple[str, str, str] | None:
|
||||
"""Read a UTF-8 (with BOM-safe) file and normalize line endings and whitespace."""
|
||||
try:
|
||||
return path.read_bytes().decode('utf-8-sig').replace('\r\n', '\n').strip()
|
||||
raw = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return ''
|
||||
return None
|
||||
newline = '\r\n' if b'\r\n' in raw else '\n'
|
||||
text = raw.decode('utf-8-sig')
|
||||
normalized = text.replace('\r\n', '\n').strip()
|
||||
|
||||
return normalized, hashlib.sha256(normalized.encode()).hexdigest(), newline
|
||||
|
||||
|
||||
def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> str:
|
||||
"""Attempt to copy a missing file from the bundled FDevIDs folder."""
|
||||
def copy_bundle_file(filename: str, dest_dir: pathlib.Path) -> tuple[str, str] | None:
|
||||
"""
|
||||
Attempt to copy a missing file from the bundled FDevIDs folder.
|
||||
|
||||
Returns (content, hash) or None.
|
||||
"""
|
||||
try:
|
||||
shutil.copy(f"FDevIDs/{filename}", dest_dir)
|
||||
return read_normalized_file(dest_dir / filename)
|
||||
dest = dest_dir / filename
|
||||
shutil.copy(f"FDevIDs/{filename}", dest)
|
||||
return read_normalized_file(dest) # type: ignore
|
||||
except (FileNotFoundError, shutil.SameFileError):
|
||||
logger.info("Bundle copy failed or identical; continuing with empty content.")
|
||||
return ''
|
||||
|
||||
|
||||
def fetch_remote_file(url: str) -> str | None:
|
||||
"""Fetch a remote file and normalize its content."""
|
||||
try:
|
||||
response = requests.get(url, timeout=20)
|
||||
response.raise_for_status()
|
||||
return response.text.replace('\r\n', '\n').strip()
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_remote_file(url: str) -> tuple[str, str] | None:
|
||||
"""
|
||||
Fetch a remote file with retries.
|
||||
|
||||
Returns (normalized_text, hash) or None on failure.
|
||||
"""
|
||||
delay = 1.0
|
||||
|
||||
for attempt in range(1, HTTP_RETRIES + 1):
|
||||
try:
|
||||
response = requests.get(url, timeout=HTTP_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
|
||||
text = response.text.replace('\r\n', '\n').strip()
|
||||
return text, hashlib.sha256(text.encode()).hexdigest()
|
||||
|
||||
except requests.RequestException as exc:
|
||||
if attempt >= HTTP_RETRIES:
|
||||
logger.debug("Download failed after %d attempts: %s", attempt, exc)
|
||||
return None
|
||||
|
||||
time.sleep(delay)
|
||||
delay *= RETRY_BACKOFF
|
||||
return None
|
||||
|
||||
|
||||
def update_single_file(
|
||||
directory: pathlib.Path,
|
||||
filename: str,
|
||||
url: str,
|
||||
silent: bool = False,
|
||||
) -> None:
|
||||
"""Update a single bundle file."""
|
||||
file_path = directory / filename
|
||||
|
||||
local = read_normalized_file(file_path)
|
||||
if local is None:
|
||||
local = copy_bundle_file(filename, directory) # type: ignore
|
||||
|
||||
if local:
|
||||
local_text, local_hash, newline = local
|
||||
else:
|
||||
local_text, local_hash, newline = '', '', '\n' # noqa: F841
|
||||
|
||||
remote = fetch_remote_file(url)
|
||||
if not remote:
|
||||
if not silent:
|
||||
logger.error(f'Failed to download {filename}! Unable to continue.')
|
||||
return
|
||||
|
||||
remote_text, remote_hash = remote
|
||||
|
||||
if local_hash == remote_hash:
|
||||
if not silent:
|
||||
logger.info(f'{filename} already up to date.')
|
||||
return
|
||||
|
||||
if not silent:
|
||||
logger.info(f'Updating file {filename}...')
|
||||
|
||||
# Restore original newline style
|
||||
output = remote_text.replace('\n', newline)
|
||||
file_path.write_text(output, encoding='utf-8', newline='')
|
||||
|
||||
|
||||
def update_files(
|
||||
directory: pathlib.Path,
|
||||
files_urls: dict[str, str],
|
||||
silent: bool = False,
|
||||
) -> None:
|
||||
"""Start threads to update bundle files."""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
max_workers = min(MAX_WORKERS, len(files_urls))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(update_single_file, directory, filename, url, silent)
|
||||
for filename, url in files_urls.items()
|
||||
]
|
||||
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
future.result()
|
||||
except Exception:
|
||||
logger.exception("Unexpected error while updating files")
|
||||
|
||||
|
||||
def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None:
|
||||
"""Check for and download FDEV ID file updates."""
|
||||
base_path = config.respath_path if local else config.app_dir_path
|
||||
@@ -67,7 +163,8 @@ def check_for_fdev_updates(silent: bool = False, local: bool = False) -> None:
|
||||
'commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/commodity.csv',
|
||||
'rare_commodity.csv': 'https://raw.githubusercontent.com/EDCD/FDevIDs/master/rare_commodity.csv'
|
||||
}
|
||||
|
||||
if not silent:
|
||||
logger.info(f"Checking for {'local ' if local else ''}FDEVID file updates...")
|
||||
update_files(fdevid_dir, files_urls, silent)
|
||||
|
||||
|
||||
@@ -76,30 +173,13 @@ def check_for_datafile_updates(silent: bool = False, local: bool = False) -> Non
|
||||
base_path = config.respath_path if local else config.app_dir_path
|
||||
files_urls = {
|
||||
'modules.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/modules.json',
|
||||
'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json'
|
||||
'ships.json': 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/refs/heads/releases/ships.json',
|
||||
'master_plugin_list.json': 'https://raw.githubusercontent.com/Rixxan/'
|
||||
'EDMC_Plugin_Registry_Dev/refs/heads/master/master_plugin_list.json'
|
||||
}
|
||||
update_files(base_path, files_urls, silent)
|
||||
|
||||
|
||||
def update_files(directory: pathlib.Path, files_urls: dict[str, str], silent: bool = False) -> None:
|
||||
"""Update the provided files from a remote source."""
|
||||
for filename, url in files_urls.items():
|
||||
file_path = directory / filename
|
||||
local_content = read_normalized_file(file_path) or copy_bundle_file(filename, directory)
|
||||
remote_content = fetch_remote_file(url)
|
||||
if not remote_content:
|
||||
if not silent:
|
||||
logger.error(f'Failed to download {filename}! Unable to continue.')
|
||||
continue
|
||||
|
||||
if local_content == remote_content:
|
||||
if not silent:
|
||||
logger.info(f'{filename} already up to date.')
|
||||
continue
|
||||
|
||||
if not silent:
|
||||
logger.info(f'Updating file {filename}...')
|
||||
file_path.write_text(remote_content, encoding='utf-8', newline='\n')
|
||||
if not silent:
|
||||
logger.info(f"Checking for {'local ' if local else ''}datafile file updates...")
|
||||
update_files(pathlib.Path(base_path), files_urls, silent)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
Reference in new issue
Block a user