diff --git a/AppImageBuilder.yml b/AppImageBuilder.yml index 3ef9289..1a58863 100644 --- a/AppImageBuilder.yml +++ b/AppImageBuilder.yml @@ -36,18 +36,21 @@ AppDir: # for /usr/bin/env - coreutils - dash - - tango-icon-theme + - zsync + - hicolor-icon-theme - libatlas3-base - python3.9-minimal - - python3-pyqt5 - python3-numpy - #- python3-matplotlib - #- python-matplotlib-data - python3-scipy + # - python3-matplotlib + # - python-matplotlib-data - python3-bsddb3 - python3-h5py + - python3-pyqt5 - python3-pyqtgraph - - python3-tk + - python3-requests + - python3-urllib3 + # - python3-tk exclude: - libavahi-client3 - libavahi-common-data diff --git a/bin/evaluate.py b/bin/evaluate.py index 9ccfedc..a9692dd 100755 --- a/bin/evaluate.py +++ b/bin/evaluate.py @@ -16,9 +16,10 @@ from nmreval.lib.logger import handle_exception sys.excepthook = handle_exception from gui_qt import App -from gui_qt.main.mainwindow import NMRMainWindow -app = App([]) +app = App(['Team Rocket FTW!']) + +from gui_qt.main.mainwindow import NMRMainWindow mplQt = NMRMainWindow() mplQt.show() diff --git a/requirements.txt b/requirements.txt index 5f1ccc6..3b0d8c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,4 @@ PyQt5 h5py pyqtgraph bsddb3 - +requests diff --git a/src/gui_qt/_py/basewindow.py b/src/gui_qt/_py/basewindow.py index beaee0b..42d4e34 100644 --- a/src/gui_qt/_py/basewindow.py +++ b/src/gui_qt/_py/basewindow.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -# Form implementation generated from reading ui file '/autohome/dominik/nmreval/src/resources/_ui/basewindow.ui' +# Form implementation generated from reading ui file 'src/resources/_ui/basewindow.ui' # -# Created by: PyQt5 UI code generator 5.15.4 +# Created by: PyQt5 UI code generator 5.15.7 # # WARNING: Any manual changes made to this file will be lost when pyuic5 is # run again. Do not edit this file unless you know what you are doing. @@ -75,7 +75,7 @@ class Ui_BaseWindow(object): self.horizontalLayout.addWidget(self.splitter) BaseWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(BaseWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1386, 30)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1386, 20)) self.menubar.setObjectName("menubar") self.menuFile = QtWidgets.QMenu(self.menubar) self.menuFile.setObjectName("menuFile") @@ -179,8 +179,6 @@ class Ui_BaseWindow(object): self.toolBar_data.setObjectName("toolBar_data") BaseWindow.addToolBar(QtCore.Qt.TopToolBarArea, self.toolBar_data) self.action_close = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("window-close") - self.action_close.setIcon(icon) self.action_close.setObjectName("action_close") self.actionExportGraphic = QtWidgets.QAction(BaseWindow) self.actionExportGraphic.setObjectName("actionExportGraphic") @@ -191,20 +189,14 @@ class Ui_BaseWindow(object): self.action_calc = QtWidgets.QAction(BaseWindow) self.action_calc.setObjectName("action_calc") self.action_delete_sets = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("edit-delete") - self.action_delete_sets.setIcon(icon) self.action_delete_sets.setObjectName("action_delete_sets") self.action_save_fit_parameter = QtWidgets.QAction(BaseWindow) self.action_save_fit_parameter.setObjectName("action_save_fit_parameter") self.action_sort_pts = QtWidgets.QAction(BaseWindow) self.action_sort_pts.setObjectName("action_sort_pts") self.action_reset = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("edit-clear") - self.action_reset.setIcon(icon) self.action_reset.setObjectName("action_reset") self.actionDocumentation = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("help-about") - self.actionDocumentation.setIcon(icon) self.actionDocumentation.setObjectName("actionDocumentation") self.action_FitWidget = QtWidgets.QAction(BaseWindow) self.action_FitWidget.setObjectName("action_FitWidget") @@ -250,8 +242,6 @@ class Ui_BaseWindow(object): self.actionConfiguration = QtWidgets.QAction(BaseWindow) self.actionConfiguration.setObjectName("actionConfiguration") self.actionRefresh = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("view-refresh") - self.actionRefresh.setIcon(icon) self.actionRefresh.setObjectName("actionRefresh") self.actionInterpolation = QtWidgets.QAction(BaseWindow) self.actionInterpolation.setObjectName("actionInterpolation") @@ -278,8 +268,6 @@ class Ui_BaseWindow(object): self.actionNew_window = QtWidgets.QAction(BaseWindow) self.actionNew_window.setObjectName("actionNew_window") self.actionDelete_window = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("edit-delete") - self.actionDelete_window.setIcon(icon) self.actionDelete_window.setObjectName("actionDelete_window") self.actionCascade_windows = QtWidgets.QAction(BaseWindow) self.actionCascade_windows.setObjectName("actionCascade_windows") @@ -330,8 +318,6 @@ class Ui_BaseWindow(object): self.actionChange_datatypes = QtWidgets.QAction(BaseWindow) self.actionChange_datatypes.setObjectName("actionChange_datatypes") self.actionPrint = QtWidgets.QAction(BaseWindow) - icon = QtGui.QIcon.fromTheme("document-print") - self.actionPrint.setIcon(icon) self.actionPrint.setObjectName("actionPrint") self.action_lm_fit = QtWidgets.QAction(BaseWindow) self.action_lm_fit.setCheckable(True) @@ -494,7 +480,7 @@ class Ui_BaseWindow(object): self.retranslateUi(BaseWindow) self.tabWidget.setCurrentIndex(0) - self.action_close.triggered.connect(BaseWindow.close) + self.action_close.triggered.connect(BaseWindow.close) # type: ignore QtCore.QMetaObject.connectSlotsByName(BaseWindow) def retranslateUi(self, BaseWindow): diff --git a/src/gui_qt/io/filedialog.py b/src/gui_qt/io/filedialog.py index c9b9bf8..e968e44 100644 --- a/src/gui_qt/io/filedialog.py +++ b/src/gui_qt/io/filedialog.py @@ -8,15 +8,18 @@ from ..Qt import QtWidgets, QtCore class FileDialog(QtWidgets.QFileDialog): last_path = None - def __init__(self, directory=None, caption=None, filters='', parent=None): + def __init__(self, directory=None, caption=None, filter='', parent=None): super().__init__(parent=parent) self.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) self.setWindowTitle(caption) - if directory is not None: + if directory: self.setDirectory(str(directory)) - self.setNameFilters(filters.split(';;')) + elif self.last_path is not None: + self.setDirectory(str(FileDialog.last_path)) + + self.setNameFilters(filter.split(';;')) file_tree = self.findChild(QtWidgets.QTreeView, 'treeView') file_tree.setSortingEnabled(True) @@ -35,13 +38,30 @@ class FileDialog(QtWidgets.QFileDialog): def save_file(self) -> pathlib.Path | None: outfile = self.selectedFiles() if outfile: - return pathlib.Path(outfile[0]) + if self.is_valid(outfile[0]): + return pathlib.Path(outfile[0]) + else: + _ = QtWidgets.QMessageBox.warning(self, 'Save file', + 'Filename contains one or more invalid character: / * < > \\ | : "') return + @staticmethod + def is_valid(filename: str): + bad_character = r'/*<>\|:"' + for c in bad_character: + if c in filename: + return False + + return True + + def close(self): + FileDialog.last_path = self.directory() + super().close() + class OpenFileDialog(FileDialog): - def __init__(self, directory=None, caption=None, filters='', parent=None): - super().__init__(directory=directory, caption=caption, filters=filters, parent=parent) + def __init__(self, **kwargs): + super().__init__(**kwargs) self.setFileMode(QtWidgets.QFileDialog.ExistingFiles) @@ -81,8 +101,8 @@ class OpenFileDialog(FileDialog): class SaveDirectoryDialog(FileDialog): - def __init__(self, directory=None, filters='', parent=None): - super().__init__(directory=directory, filters=filters, parent=parent) + def __init__(self, **kwargs): + super().__init__(**kwargs) self.setOption(QtWidgets.QFileDialog.DontConfirmOverwrite, False) self.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) @@ -123,4 +143,3 @@ class SaveDirectoryDialog(FileDialog): self.setWindowTitle('Save') self.setNameFilters(['All files (*.*)', 'Session file (*.nmr)', 'Text file (*.dat)', 'HDF file (*.h5)', 'Grace files (*.agr)']) - diff --git a/src/gui_qt/lib/configurations.py b/src/gui_qt/lib/configurations.py index c92f065..092193e 100644 --- a/src/gui_qt/lib/configurations.py +++ b/src/gui_qt/lib/configurations.py @@ -37,7 +37,6 @@ class GraceMsgBox(QtWidgets.QDialog): agr.parse(fname) layout = QtWidgets.QGridLayout() - layout.setContentsMargins(13, 13, 13, 13) self.setLayout(layout) label = QtWidgets.QLabel('%s already exists. Select one of the options or cancel:' % os.path.split(fname)[1]) @@ -100,9 +99,9 @@ class GeneralConfiguration(QtWidgets.QDialog): for key, value in parser.items(sec): label = QtWidgets.QLabel(key.capitalize(), self) layout2.addWidget(label, row, 0) - if (sec, key) in allowed_values: + if (sec, key) in ALLOWED_VALUE: edit = QtWidgets.QComboBox(self) - edit.addItems(allowed_values[(sec, key)]) + edit.addItems(ALLOWED_VALUE[(sec, key)]) edit.setCurrentIndex(edit.findText(value)) else: edit = QtWidgets.QLineEdit(self) diff --git a/src/gui_qt/lib/utils.py b/src/gui_qt/lib/utils.py index 32b292b..45f79c9 100644 --- a/src/gui_qt/lib/utils.py +++ b/src/gui_qt/lib/utils.py @@ -1,8 +1,15 @@ +import sys +from os import getenv, stat +import hashlib +import subprocess +from datetime import datetime from contextlib import contextmanager + +import requests from numpy import linspace from scipy.interpolate import interp1d -from ..Qt import QtGui, QtWidgets, QtCore +from gui_qt.Qt import QtGui, QtWidgets, QtCore @contextmanager @@ -48,3 +55,162 @@ class RdBuCMap: col = QtGui.QColor.fromRgb(*(float(self.spline[i](val)) for i in range(3))) return col + + +class UpdateDialog(QtWidgets.QDialog): + host = 'mirror.infra.pkm' + bucket = 'nmreval' + version = 'NMReval-latest-x86_64' + + def __init__(self, filename: str = None, parent=None): + super().__init__(parent=parent) + self._init_ui() + + if filename is None: + filename = getenv('APPIMAGE') + self._appfile = filename + self._url_zsync = f'http://{self.host}/{self.bucket}/{self.version}.AppImage.zsync' + + self.process = QtCore.QProcess(self) + + self.look_for_updates() + + def _init_ui(self): + self.setWindowTitle('Updates') + + layout = QtWidgets.QVBoxLayout() + + self.label = QtWidgets.QLabel() + layout.addWidget(self.label) + + layout.addSpacing(10) + + self.dialog_button = QtWidgets.QDialogButtonBox() + self.dialog_button.accepted.connect(self.accept) + self.dialog_button.rejected.connect(self.reject) + layout.addWidget(self.dialog_button) + + self.setLayout(layout) + + def look_for_updates(self): + # Download zsync file of latest Appimage, look for SHA-1 hash and compare with hash of AppImage + m_time_zsync, checksum_zsync = self.get_zsync() + m_time_file, checksum_file = self.get_appimage_info() + + if m_time_zsync is None: + label_text = '
Retrieval of version information failed.
' \ + 'Please try later (or complain to people that it does not work).
' + dialog_bttns = QtWidgets.QDialogButtonBox.Close + else: + label_text = f'Found most recent update: {m_time_zsync.strftime("%d %B %Y %H:%M")}
' + + if m_time_file is None: + label_text += 'No AppImage file found, press Ok to downlaod latest version.' + dialog_bttns = QtWidgets.QDialogButtonBox.Ok|QtWidgets.QDialogButtonBox.Close + else: + label_text += f'Date of used AppImage: {m_time_file.strftime("%d %B %Y %H:%M")}
' + + if not ((checksum_file is not None) and (checksum_zsync is not None)): + label_text += 'Could not determine if this version is newer, please update manually (if necessary).' + dialog_bttns = QtWidgets.QDialogButtonBox.Close + elif checksum_file != checksum_zsync: + label_text += f'Newer version available. Update?
' + dialog_bttns = QtWidgets.QDialogButtonBox.Ok|QtWidgets.QDialogButtonBox.Cancel + else: + label_text += f'Version is already the newest
' + dialog_bttns = QtWidgets.QDialogButtonBox.Close + + self.label.setText(label_text) + self.dialog_button.setStandardButtons(dialog_bttns) + + def get_zsync(self): + m_time_zsync = None + checksum_zsync = None + zsync_file = None + try: + response = requests.get(self._url_zsync) + if response.status_code == requests.codes['\o/']: + zsync_file = response.content + except Exception as e: + pass + + if zsync_file is not None: + for line in zsync_file.split(b'\n'): + try: + kw, val = line.split(b': ') + if kw == b'MTime': + m_time_zsync = datetime.strptime(str(val, encoding='utf-8'), '%a, %d %b %Y %H:%M:%S %z').astimezone(None) + elif kw == b'SHA-1': + checksum_zsync = str(val, encoding='utf-8') + + except ValueError: + # stop when empty line is reached + break + + return m_time_zsync, checksum_zsync + + def get_appimage_info(self): + if self._appfile is None: + return None, None + + stat_mtime = stat(self._appfile).st_mtime + m_time_file = datetime.fromtimestamp(stat_mtime).replace(microsecond=0) + with open(self._appfile, 'rb') as f: + checksum_file = hashlib.sha1(f.read()).hexdigest() + + return m_time_file, checksum_file + + def update_appimage(self): + if self._appfile is None: + args = [self._url_zsync] + else: + args = ['-i', self._appfile, self._url_zsync] + + self.process.readyReadStandardOutput.connect(self.onReadyReadStandardOutput) + self.process.readyReadStandardError.connect(self.onReadyReadStandardOutput) + self.process.start('zsync', args) + + if not self.process.waitForFinished(): + return False + + return True + + def onReadyReadStandardOutput(self): + result = self.process.readAllStandardOutput().data().decode() + self.label.setText(result) + + + def accept(self): + if self.update_appimage(): + _ = QtWidgets.QMessageBox.information(self, 'Updates', 'Download finished. Execute AppImage for new version.') + super().accept() + + + +def open_bug_report(): + form_entries = { + 'description': 'Please state the nature of the medical emergency.', + 'title': 'Everything is awesome?', + 'assign[0]': 'dominik', + 'subscribers[0]': 'dominik', + 'tag': 'nmreval', + 'priority': 'normal', + 'status': 'open', + } + full_url = 'https://chaos3.fkp.physik.tu-darmstadt.de/maniphest/task/edit/?' + + for k, v in form_entries.items(): + full_url += f'{k}={v}&' + full_url.replace(' ', '+') + + import webbrowser + webbrowser.open(full_url) + + + +if __name__ == '__main__': + app = QtWidgets.QApplication([]) + w = UpdateDialog() + w.show() + + sys.exit(app.exec()) diff --git a/src/gui_qt/main/mainwindow.py b/src/gui_qt/main/mainwindow.py index 8f6334e..9c091cf 100644 --- a/src/gui_qt/main/mainwindow.py +++ b/src/gui_qt/main/mainwindow.py @@ -5,10 +5,9 @@ import re from pathlib import Path from numpy import geomspace, linspace -from pyqtgraph import ViewBox, PlotDataItem +from pyqtgraph import ViewBox from nmreval.configs import * -from nmreval.lib.utils import open_bug_report from .management import UpperManagement from ..Qt import QtCore, QtGui, QtPrintSupport, QtWidgets @@ -28,6 +27,7 @@ from ..math.smooth import QSmooth from ..nmr.coupling_calc import QCoupCalcDialog from ..nmr.t1_from_tau import QRelaxCalc from .._py.basewindow import Ui_BaseWindow +from ..lib.utils import UpdateDialog, open_bug_report class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @@ -218,13 +218,13 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @QtCore.pyqtSlot(name='on_action_open_triggered') def open(self): filedialog = OpenFileDialog(directory=self.path, caption='Open files', - filters='All files (*.*);;' - 'Program session (*.nmr);;' - 'HDF files (*.h5);;' - 'Text files (*.txt *.dat);;' - 'Novocontrol Alpha (*.EPS);;' - 'TecMag files (*.tnt);;' - 'Grace files (*.agr)') + filter='All files (*.*);;' + 'Program session (*.nmr);;' + 'HDF files (*.h5);;' + 'Text files (*.txt *.dat);;' + 'Novocontrol Alpha (*.EPS);;' + 'TecMag files (*.tnt);;' + 'Grace files (*.agr)') filedialog.set_graphs(self.management.graphs.list()) @@ -250,9 +250,7 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @QtCore.pyqtSlot(name='on_actionExportData_triggered') @QtCore.pyqtSlot(name='on_actionSave_triggered') def save(self): - save_dialog = SaveDirectoryDialog( - directory=str(self.path), parent=self, - ) + save_dialog = SaveDirectoryDialog(directory=str(self.path), parent=self) mode = save_dialog.exec() if mode == QtWidgets.QDialog.Accepted: @@ -274,12 +272,14 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): @QtCore.pyqtSlot() @QtCore.pyqtSlot(list) def save_fit_parameter(self, fit_sets: list[str] = None): - fname, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save fit parameter', directory=str(self.path), - filter='All files(*, *);;Text files(*.dat *.txt)', - options=QtWidgets.QFileDialog.DontConfirmOverwrite) + save_dialog = SaveDirectoryDialog(parent=self, caption='Save fit parameter', directory=str(self.path), + filter='All files(*, *);;Text files(*.dat *.txt)') - if fname: - self.management.save_fit_parameter(fname, fit_sets=fit_sets) + mode = save_dialog.exec() + if mode == QtWidgets.QDialog.Accepted: + savefile = save_dialog.save_file() + if savefile: + self.management.save_fit_parameter(savefile, fit_sets=fit_sets) @QtCore.pyqtSlot(name='on_actionExportGraphic_triggered') def export_graphic(self): @@ -988,14 +988,19 @@ class NMRMainWindow(QtWidgets.QMainWindow, Ui_BaseWindow): dialog.show() def close(self): - write_state({'recent_path': str(self.path)}) + write_state({'History': {'recent path': str(self.path)}}) super().close() def read_state(self): opts = read_state() - self.path = pathlib.Path(opts.get('recent_path', Path.home())) + self.path = pathlib.Path(opts['History'].get('recent path', Path.home())) @QtCore.pyqtSlot(name='on_actionBugs_triggered') def report_bug(self): open_bug_report() + + @QtCore.pyqtSlot(name='on_actionUpdate_triggered') + def look_for_update(self): + w = UpdateDialog(parent=self) + w.show() diff --git a/src/nmreval/configs.py b/src/nmreval/configs.py index 408f171..51b674e 100644 --- a/src/nmreval/configs.py +++ b/src/nmreval/configs.py @@ -5,7 +5,7 @@ from shutil import copyfile from importlib.resources import path as resource_path -__all__ = ['config_paths', 'check_for_config', 'read_configuration', 'write_configuration', 'allowed_values', 'write_state', 'read_state'] +__all__ = ['config_paths', 'check_for_config', 'read_configuration', 'write_configuration', 'ALLOWED_VALUE', 'write_state', 'read_state'] def check_for_config(make=True): @@ -18,14 +18,13 @@ def check_for_config(make=True): cwd = pathlib.Path(__file__).parent copyfile(cwd / 'models' / 'usermodels.py', conf_path / 'usermodels.py') with resource_path('resources', 'Default.agr') as fp: - copyfile(fp, conf_path / 'Default.agr') + copyfile(fp, conf_path / 'Default.agr') else: raise e def config_paths() -> pathlib.Path: - # TODO adjust for different OS searchpaths = ['~/.config/nmreval', '~/.auswerten', '/usr/share/nmreval'] conf_path = None @@ -46,8 +45,6 @@ def read_configuration() -> configparser.ConfigParser: config_file = config_paths() / 'nmreval.cfg' if not config_file.exists(): write_configuration({'GUI': {'theme': 'normal', 'color': 'light'}}) - # raise FileNotFoundError('Configuration file not found') - # except FileNotFoundError as e: raise e @@ -67,24 +64,38 @@ def write_configuration(opts: dict): parser.write(f) -allowed_values = { +ALLOWED_VALUE = { ('GUI', 'theme'): ['normal', 'pokemon'], ('GUI', 'color'): ['light', 'dark'], } -def write_state(opts: dict): +def write_state(new_opts: dict): config_file = config_paths() / 'guistate.ini' - old_opts = read_state() - old_opts.update(opts) - with config_file.open('wb') as f: - pickle.dump(old_opts, f) + opts = read_state() + opts.update(new_opts) + + parser = configparser.ConfigParser() + parser.read_dict(opts) + + with config_file.open('w') as f: + parser.write(f) def read_state() -> dict: config_file = config_paths() / 'guistate.ini' if not config_file.exists(): - return {} + return {'History': {'recent path': pathlib.Path.home()}} with config_file.open('rb') as f: - return pickle.load(f) + try: + opts = pickle.load(f) + opts['recent path'] = opts.get('recent_path', pathlib.Path.home()) + + return {'History': opts} + except pickle.UnpicklingError: + parser = configparser.ConfigParser() + parser.read(config_file) + + return parser + diff --git a/src/nmreval/lib/utils.py b/src/nmreval/lib/utils.py index 38c941e..a44351f 100644 --- a/src/nmreval/lib/utils.py +++ b/src/nmreval/lib/utils.py @@ -5,34 +5,14 @@ from ..math.mittagleffler import mlf ArrayLike = TypeVar('ArrayLike') - -def valid_function(expr: str, extra_namespace: dict = None): - - local = {'mlf': mlf} - if extra_namespace is not None: - local.update(extra_namespace) - - try: - return ne.evaluate(expr, {}, local), True - except: - return None, False - - -def open_bug_report(): - form_entries = { - 'description': 'Please state the nature of the medical emergency.', - 'title': 'Everything is awesome?', - 'assign[0]': 'dominik', - 'subscribers[0]': 'dominik', - 'tag': 'nmreval', - 'priority': 'normal', - 'status': 'open', - } - full_url = 'https://chaos3.fkp.physik.tu-darmstadt.de/maniphest/task/edit/?' - - for k, v in form_entries.items(): - full_url += f'{k}={v}&' - full_url.replace(' ', '+') - - import webbrowser - webbrowser.open(full_url) +# +# def valid_function(expr: str, extra_namespace: dict = None): +# +# local = {'mlf': mlf} +# if extra_namespace is not None: +# local.update(extra_namespace) +# +# try: +# return ne.evaluate(expr, {}, local), True +# except: +# return None, False diff --git a/src/resources/_ui/basewindow.ui b/src/resources/_ui/basewindow.ui index 39a4059..d80e9f0 100644 --- a/src/resources/_ui/basewindow.ui +++ b/src/resources/_ui/basewindow.ui @@ -136,7 +136,7 @@