mirror of
https://github.com/DYefremov/DemonEditor.git
synced 2026-01-14 19:43:05 +01:00
369 lines
16 KiB
Python
369 lines
16 KiB
Python
# -*- coding: utf-8 -*-
|
|
#
|
|
# The MIT License (MIT)
|
|
#
|
|
# Copyright (c) 2023-2024 Dmitriy Yefremov
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
# THE SOFTWARE.
|
|
#
|
|
# Author: Dmitriy Yefremov
|
|
#
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
from enum import IntEnum
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
from gi.repository import Gtk, Gdk, GLib, Pango, GObject
|
|
|
|
from app.commons import log, run_task, run_idle
|
|
from app.ui.dialogs import translate
|
|
from app.ui.uicommons import HeaderBar
|
|
|
|
EXT_URL = "https://api.github.com/repos/DYefremov/demoneditor-extensions/contents/extensions/"
|
|
EXT_LIST_FILE = "https://raw.githubusercontent.com/DYefremov/demoneditor-extensions/main/extensions/extension-list"
|
|
# Config file name. The config file must be in json format!
|
|
# E.g. -> {"EXT_URL": "repo URL", "EXT_LIST_FILE": "URL to 'extension-list' file."}
|
|
EXT_CONFIG_FILE = "ext_sources"
|
|
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux i686; rv:112.0) Gecko/20100101 Firefox/112.0",
|
|
"Accept": "application/json"}
|
|
|
|
|
|
class ExtensionManager(Gtk.Window):
|
|
ICON_INFO = "emblem-important-symbolic"
|
|
ICON_UPDATE = "network-receive-symbolic"
|
|
|
|
class Column(IntEnum):
|
|
TITLE = 0
|
|
DESC = 1
|
|
VER = 2
|
|
INFO = 3
|
|
STATUS = 4
|
|
NAME = 5
|
|
URL = 6
|
|
PATH = 7
|
|
|
|
def __init__(self, app, **kwargs):
|
|
super().__init__(title=translate("Extensions"), icon_name="demon-editor", application=app,
|
|
transient_for=app.app_window, destroy_with_parent=True,
|
|
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
|
|
default_width=560, default_height=320, modal=True, **kwargs)
|
|
|
|
self._app = app
|
|
self._ext_path = f"{self._app.app_settings.default_data_path}tools{os.sep}extensions"
|
|
|
|
margin = {"margin_start": 5, "margin_end": 5, "margin_top": 5, "margin_bottom": 5}
|
|
base_margin = {"margin_start": 10, "margin_end": 10, "margin_top": 10, "margin_bottom": 10}
|
|
# Title, Description, Version, Info, Status, Name, URL, Path.
|
|
self._model = Gtk.ListStore.new((str, str, str, str, bool, str, str, object))
|
|
self._model.connect("row-deleted", self.on_model_changed)
|
|
self._model.connect("row-inserted", self.on_model_changed)
|
|
self._view = Gtk.TreeView(activate_on_single_click=True, enable_grid_lines=Gtk.TreeViewGridLines.BOTH)
|
|
self._view.set_model(self._model)
|
|
self._view.set_tooltip_column(self.Column.DESC)
|
|
self._view.connect("row-activated", self.on_row_activated)
|
|
# Title
|
|
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
|
|
column = Gtk.TreeViewColumn(title=translate("Title"), cell_renderer=renderer, text=self.Column.TITLE)
|
|
column.set_alignment(0.5)
|
|
column.set_min_width(170)
|
|
column.set_resizable(True)
|
|
self._view.append_column(column)
|
|
# Description
|
|
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
|
|
column = Gtk.TreeViewColumn(title=translate("Description"), cell_renderer=renderer, text=self.Column.DESC)
|
|
column.set_alignment(0.5)
|
|
column.set_resizable(True)
|
|
column.set_expand(True)
|
|
self._view.append_column(column)
|
|
# Version
|
|
column = Gtk.TreeViewColumn(translate("Ver."))
|
|
column.set_alignment(0.5)
|
|
column.set_fixed_width(70)
|
|
renderer = Gtk.CellRendererText(xalign=0.5)
|
|
column.pack_start(renderer, True)
|
|
column.add_attribute(renderer, "text", self.Column.VER)
|
|
renderer = Gtk.CellRendererPixbuf()
|
|
column.pack_start(renderer, True)
|
|
column.add_attribute(renderer, "icon_name", self.Column.INFO)
|
|
self._view.append_column(column)
|
|
# Status
|
|
renderer = Gtk.CellRendererToggle(xalign=0.5)
|
|
column = Gtk.TreeViewColumn(title=translate("Installed"), cell_renderer=renderer, active=self.Column.STATUS)
|
|
column.set_alignment(0.5)
|
|
column.set_fixed_width(100)
|
|
self._view.append_column(column)
|
|
self._status_column = column
|
|
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
frame = Gtk.Frame(shadow_type=Gtk.ShadowType.IN, **base_margin)
|
|
frame.get_style_context().add_class("view")
|
|
data_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.VERTICAL, **base_margin)
|
|
data_box.set_margin_bottom(margin.get("margin_bottom", 5))
|
|
# Status bar.
|
|
status_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_start=5, margin_end=5)
|
|
count_icon = Gtk.Image.new_from_icon_name("document-properties", Gtk.IconSize.SMALL_TOOLBAR)
|
|
status_box.pack_start(count_icon, False, False, 0)
|
|
self._count_label = Gtk.Label(label="0", width_chars=4, xalign=0)
|
|
status_box.pack_start(self._count_label, False, False, 0)
|
|
status_box.show_all()
|
|
load_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_end=10, no_show_all=True)
|
|
load_box.pack_start(Gtk.Label(label=translate("Loading data..."), visible=True), False, False, 0)
|
|
self._load_spinner = Gtk.Spinner(visible=True)
|
|
self._load_spinner.bind_property("active", load_box, "visible")
|
|
self._load_spinner.bind_property("active", self._view, "sensitive", GObject.BindingFlags.INVERT_BOOLEAN)
|
|
load_box.pack_end(self._load_spinner, False, False, 0)
|
|
status_box.pack_end(load_box, False, False, 0)
|
|
data_box.pack_end(status_box, False, True, 0)
|
|
|
|
scrolled = Gtk.ScrolledWindow(shadow_type=Gtk.ShadowType.IN)
|
|
scrolled.add(self._view)
|
|
data_box.pack_start(scrolled, True, True, 0)
|
|
data_box.set_margin_start(10)
|
|
frame.add(data_box)
|
|
self.add(main_box)
|
|
# Popup menu.
|
|
menu = Gtk.Menu()
|
|
download_menu_item = Gtk.MenuItem.new_with_label(translate("Download"))
|
|
download_menu_item.connect("activate", self.on_download)
|
|
menu.append(download_menu_item)
|
|
remove_menu_item = Gtk.MenuItem.new_with_label(translate("Remove"))
|
|
remove_menu_item.connect("activate", self.on_remove)
|
|
menu.append(remove_menu_item)
|
|
menu.show_all()
|
|
self._view.connect("button-press-event", self.on_view_popup_menu, menu)
|
|
# Header and toolbar.
|
|
self._download_button = Gtk.Button.new_from_icon_name("go-bottom-symbolic", Gtk.IconSize.BUTTON)
|
|
self._download_button.set_tooltip_text(translate("Download"))
|
|
self._download_button.set_always_show_image(True)
|
|
self._download_button.connect("clicked", self.on_download)
|
|
remove_button = Gtk.Button.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.BUTTON)
|
|
remove_button.set_tooltip_text(translate("Remove"))
|
|
remove_button.set_always_show_image(True)
|
|
remove_button.connect("clicked", self.on_remove)
|
|
|
|
if app.app_settings.use_header_bar:
|
|
header = HeaderBar()
|
|
header.pack_start(self._download_button)
|
|
header.pack_start(remove_button)
|
|
|
|
self.set_titlebar(header)
|
|
header.show_all()
|
|
else:
|
|
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
toolbar.get_style_context().add_class("primary-toolbar")
|
|
margin["margin_start"] = 15
|
|
margin["margin_top"] = 10
|
|
button_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, **margin)
|
|
button_box.pack_start(self._download_button, False, False, 0)
|
|
button_box.pack_start(remove_button, False, False, 0)
|
|
toolbar.pack_start(button_box, True, True, 0)
|
|
main_box.pack_start(toolbar, False, False, 0)
|
|
|
|
main_box.pack_start(frame, True, True, 0)
|
|
main_box.show_all()
|
|
# Connection status.
|
|
self._connection_status_image = Gtk.Image.new_from_icon_name("network-offline-symbolic", Gtk.IconSize.BUTTON)
|
|
status_box.pack_end(self._connection_status_image, False, False, 0)
|
|
self._download_button.bind_property("visible", self._connection_status_image, "visible", 4)
|
|
self._download_button.bind_property("visible", download_menu_item, "visible")
|
|
|
|
ws_property = "extension_manager_window_size"
|
|
window_size = self._app.app_settings.get(ws_property, None)
|
|
if window_size:
|
|
self.resize(*window_size)
|
|
|
|
self.connect("delete-event", lambda w, e: self._app.app_settings.add(ws_property, w.get_size()))
|
|
self.connect("realize", self.init)
|
|
|
|
def init(self, widget):
|
|
self._load_spinner.start()
|
|
scf = f"{os.path.dirname(__file__)}{os.sep}{EXT_CONFIG_FILE}"
|
|
if os.path.isfile(scf):
|
|
with (open(scf, "r", encoding="utf-8", errors="ignore") as cf):
|
|
config = json.load(cf)
|
|
global EXT_URL, EXT_LIST_FILE
|
|
EXT_URL = config.get("EXT_URL", EXT_URL)
|
|
EXT_LIST_FILE = config.get("EXT_LIST_FILE", EXT_LIST_FILE)
|
|
|
|
self.update()
|
|
|
|
def get_installed(self):
|
|
import pkgutil
|
|
from importlib.util import module_from_spec
|
|
|
|
ext_paths = [f"{os.path.dirname(__file__)}{os.sep}", self._ext_path, "extensions"]
|
|
installed = {}
|
|
|
|
for importer, name, is_package in pkgutil.iter_modules(ext_paths):
|
|
if is_package:
|
|
spec = importer.find_spec(name)
|
|
if spec is None:
|
|
log(f"{self.__class__.__name__} [get installed]: Module {name} not found.")
|
|
continue
|
|
|
|
m = module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
cls_name = name.capitalize()
|
|
if hasattr(m, cls_name):
|
|
cls = getattr(m, cls_name)
|
|
path = Path(spec.origin).parent
|
|
installed[name] = (cls, path)
|
|
|
|
return installed
|
|
|
|
@run_task
|
|
def update(self):
|
|
error_msg = None
|
|
try:
|
|
with requests.get(url=EXT_LIST_FILE, stream=True) as resp:
|
|
if resp.status_code == 200:
|
|
try:
|
|
self.update_data(resp.json())
|
|
except ValueError as e:
|
|
error_msg = f"{self.__class__.__name__} [update] error: {e}"
|
|
else:
|
|
error_msg = f"{self.__class__.__name__} [update] error: {resp.reason}"
|
|
GLib.idle_add(self._app.show_error_message, "Data loading error!")
|
|
except OSError as e:
|
|
error_msg = f"{self.__class__.__name__} [update] error: Connection error. {e}"
|
|
|
|
if error_msg:
|
|
log(error_msg)
|
|
self.update_local_data()
|
|
|
|
@run_idle
|
|
def update_data(self, data):
|
|
self._model.clear()
|
|
gen = self.append_data(data)
|
|
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
|
|
|
|
@run_idle
|
|
def update_local_data(self):
|
|
self._download_button.set_visible(False)
|
|
self._load_spinner.stop()
|
|
self._model.clear()
|
|
|
|
for ext, d in self.get_installed().items():
|
|
e, path = d
|
|
self._model.append((e.LABEL, None, e.VERSION, None, path, ext, None, path))
|
|
|
|
def append_data(self, data):
|
|
installed = self.get_installed()
|
|
for e, d in data.items():
|
|
url = f"{EXT_URL}{d.get('ref', '')}"
|
|
desc = d.get("description", "")
|
|
ver = d.get("version", "1.0")
|
|
info = self.ICON_UPDATE
|
|
path = None
|
|
|
|
ext = installed.get(e)
|
|
if ext:
|
|
info = None
|
|
ext_ver = ext[0].VERSION
|
|
path = ext[1]
|
|
if ext_ver < ver:
|
|
ver = ext_ver
|
|
info = self.ICON_INFO
|
|
|
|
yield self._model.append((d.get('label'), desc, ver, info, path, e, url, path))
|
|
self._load_spinner.stop()
|
|
|
|
def on_remove(self, item=None):
|
|
model, paths = self._view.get_selection().get_selected_rows()
|
|
if not paths:
|
|
return
|
|
|
|
itr = model.get_iter(paths)
|
|
path = model[itr][self.Column.PATH]
|
|
if path:
|
|
try:
|
|
shutil.rmtree(path)
|
|
except OSError as e:
|
|
log(f"{self.__class__.__name__} [remove] error: {e}")
|
|
else:
|
|
model.set(itr, {self.Column.INFO: self.ICON_UPDATE, self.Column.STATUS: None, self.Column.PATH: None})
|
|
msg = translate('Restart the program to apply all changes.')
|
|
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
|
|
|
|
@run_task
|
|
def on_download(self, item=None):
|
|
model, paths = self._view.get_selection().get_selected_rows()
|
|
if not paths:
|
|
return
|
|
|
|
itr = model.get_iter(paths)
|
|
url = model[itr][self.Column.URL]
|
|
ver = model[itr][self.Column.VER]
|
|
if not url:
|
|
return
|
|
|
|
GLib.idle_add(self._load_spinner.start)
|
|
urls = {}
|
|
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
|
|
if resp.status_code == 200:
|
|
try:
|
|
for f in resp.json():
|
|
url = f.get("download_url", None)
|
|
ver = f.get("version", ver)
|
|
if url:
|
|
urls[url] = f.get("name", None)
|
|
except ValueError as e:
|
|
log(f"{self.__class__.__name__} [download] error: {e}")
|
|
else:
|
|
log(f"{self.__class__.__name__} [download] error: {resp.reason}")
|
|
|
|
if urls:
|
|
path = f"{self._ext_path}{os.sep}{model[paths][self.Column.NAME]}{os.sep}"
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
if all((self.download_file(u, f"{path}{n}") for u, n in urls.items())):
|
|
data = {self.Column.VER: ver, self.Column.INFO: None, self.Column.STATUS: True, self.Column.PATH: path}
|
|
GLib.idle_add(model.set, itr, data)
|
|
msg = translate('Restart the program to apply all changes.')
|
|
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
|
|
|
|
GLib.idle_add(self._load_spinner.stop)
|
|
|
|
def download_file(self, url, path):
|
|
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
|
|
if resp.status_code == 200:
|
|
with open(path, mode="bw") as f:
|
|
for data in resp.iter_content(chunk_size=1024):
|
|
f.write(data)
|
|
return True
|
|
|
|
def on_model_changed(self, model, path, itr=None):
|
|
self._count_label.set_text(str(len(model)))
|
|
|
|
def on_row_activated(self, view, path, column):
|
|
if column is self._status_column:
|
|
self.on_remove() if view.get_model()[path][self.Column.STATUS] else self.on_download()
|
|
|
|
def on_view_popup_menu(self, view, event, menu):
|
|
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY:
|
|
menu.popup(None, None, None, None, event.button, event.time)
|
|
return True
|
|
return False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pass
|