From 5729cddaac971af63eb3b4f02501ebd2b42cb900 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Wed, 22 May 2024 20:03:36 +0200 Subject: [PATCH 01/20] Move all of UI definitions out of MainWindow --- checknmr.py | 18 -- main_window.py | 612 +++++++++++++++---------------------------------- ui/__init__.py | 0 ui/display.py | 18 ++ ui/layout.py | 55 +++++ ui/options.py | 187 +++++++++++++++ worker.py | 29 +++ 7 files changed, 472 insertions(+), 447 deletions(-) create mode 100644 ui/__init__.py create mode 100644 ui/display.py create mode 100644 ui/layout.py create mode 100644 ui/options.py create mode 100644 worker.py diff --git a/checknmr.py b/checknmr.py index 97d9163..caacc5c 100644 --- a/checknmr.py +++ b/checknmr.py @@ -1,21 +1,3 @@ -""" -Mora the Explorer checks for new NMR spectra at the Organic Chemistry department at the University of Münster. -Copyright (C) 2023 Matthew J. Milner - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - import filecmp import logging import shutil diff --git a/main_window.py b/main_window.py index aa2e0e8..9177488 100644 --- a/main_window.py +++ b/main_window.py @@ -1,22 +1,3 @@ -""" -Mora the Explorer checks for new NMR spectra at the Organic Chemistry department at the University of Münster. -Copyright (C) 2023 Matthew J. Milner - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . -""" - -import json import logging import os import platform @@ -27,57 +8,18 @@ import plyer -from PySide6.QtCore import QSize, QTimer, Qt, QRunnable, Signal, Slot, QThreadPool, QObject +from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject from PySide6.QtWidgets import ( QMainWindow, - QPushButton, - QRadioButton, - QButtonGroup, - QComboBox, QLabel, - QLineEdit, - QDateEdit, - QCheckBox, - QSpinBox, - QProgressBar, - QScrollArea, - QHBoxLayout, - QVBoxLayout, - QGridLayout, QWidget, QMessageBox, ) +from worker import Worker from checknmr import check_nmr from config import Config - - -class WorkerSignals(QObject): - progress = Signal(int) - result = Signal(object) - completed = Signal() - - -class Worker(QRunnable): - def __init__(self, fn, *args, **kwargs): - super(Worker, self).__init__() - - # Pass function itself, along with provided arguments, to new function within the Checker instance - self.fn = fn - self.args = args - self.kwargs = kwargs - # Give the Checker signals - self.signals = WorkerSignals() - # Add the callback to kwargs - self.kwargs["progress_callback"] = self.signals.progress - - @Slot() - def run(self): - # Run the Worker function with passed args, kwargs, including progress_callback - output = self.fn(*self.args, **self.kwargs) - # Emit the output of the function as the result signal so that it can be picked up - self.signals.result.emit(output) - self.signals.completed.emit() +from ui.layout import Layout class MainWindow(QMainWindow): @@ -86,7 +28,6 @@ def __init__(self, resource_directory: Path, config: Config): self.rsrc_dir = resource_directory self.config = config - self.options = config.options # Set up multithreading; MaxThreadCount limited to 1 as checks don't run properly if multiple run concurrently self.threadpool = QThreadPool() @@ -107,279 +48,22 @@ def __init__(self, resource_directory: Path, config: Config): # Check for updates self.update_check(Path(config.paths["update"])) - # Title and version info header - self.setWindowTitle("Mora the Explorer") - with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: - version_info = "".join(f.readlines()[:5]) - version_box = QLabel(version_info) - version_box.setAlignment(Qt.AlignHCenter) - - # Setup layouts - layout = QVBoxLayout() - layout.addWidget(version_box) - options_layout = QGridLayout() - groups_layout = QHBoxLayout() - groups_overflow = QHBoxLayout() - spec_layout = QVBoxLayout() - options_layout.addLayout(groups_layout, 1, 1) - options_layout.addLayout(groups_overflow, 2, 1) - options_layout.addLayout(spec_layout, 6, 1, 1, 2) - layout.addLayout(options_layout) - # Add central widget and give it parent layout - layout_widget = QWidget() - layout_widget.setLayout(layout) - self.setCentralWidget(layout_widget) - - # Initials entry box - initials_label = QLabel("initials:") - initials_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(initials_label, 0, 0) - - self.initials_entry = QLineEdit() - self.initials_entry.setMaxLength(3) - self.initials_entry.setText(self.options["initials"]) - # Initialize wild option for later (see initials_changed function) + # Initialize some other variables for later self.wild_group = False - self.initials_entry.textChanged.connect(self.initials_changed) - options_layout.addWidget(self.initials_entry, 0, 1) - - initials_hint = QLabel("(lowercase!)") - initials_hint.setAlignment(Qt.AlignCenter) - options_layout.addWidget(initials_hint, 0, 2) - - # Research group selection buttons - group_label = QLabel("group:") - group_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(group_label, 1, 0) - - # Add radio button for each group in config.groups (loaded earlier) - self.AKlist = list(self.config.groups.keys()) - self.AK_button_group = QButtonGroup(layout_widget) - self.button_list = [] - for AK in self.AKlist: - AKbutton = QRadioButton(AK) - self.button_list.append(AKbutton) - if (AK == self.options["group"]) or ( - AK == "other" and self.AK_button_group.checkedButton() is None - ): - AKbutton.setChecked(True) - self.AK_button_group.addButton(AKbutton) - if len(self.AKlist) <= 4 or self.AKlist.index(AK) < (len(self.AKlist) / 2): - groups_layout.addWidget(AKbutton) - elif len(self.AKlist) > 4 and self.AKlist.index(AK) >= (len(self.AKlist) / 2): - groups_overflow.addWidget(AKbutton) - self.AK_button_group.buttonClicked.connect(self.group_changed) - - # Drop down list for further options that appears only when "other" radio button is clicked - self.AKlist_other = list(self.config.groups["other"].values()) - self.other_box = QComboBox() - self.other_box.addItems(self.AKlist_other) - if self.options["group"] in self.AKlist_other: - self.other_box.setCurrentText(self.options["group"]) - else: - self.other_box.hide() - self.other_box.currentTextChanged.connect(self.group_changed) - options_layout.addWidget(self.other_box, 2, 2) - - # Destination path entry box - dest_path_label = QLabel("save in:") - dest_path_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(dest_path_label, 3, 0) - - dest_path_input = QLineEdit() - dest_path_input.setText(self.options["dest_path"]) - dest_path_input.textChanged.connect(self.dest_path_changed) - options_layout.addWidget(dest_path_input, 3, 1) - - self.open_button = QPushButton("go to") - self.open_button.clicked.connect(self.open_path) - self.open_button.setShortcut("Ctrl+G") - options_layout.addWidget(self.open_button, 3, 2) - # Disable button if path hasn't yet been specified to stop new users thinking it should be used to select a folder - if self.options["dest_path"] == "copy full path here": - self.open_button.hide() - - # File naming options - file_naming_layout = QHBoxLayout() - - include_label = QLabel("include:") - include_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(include_label, 4, 0) - - self.inc_init_checkbox = QCheckBox("initials") - self.inc_init_checkbox.setChecked(self.options["inc_init"]) - self.inc_init_checkbox.stateChanged.connect(self.inc_init_switched) - if self.options["nmrcheck_style"] is True: - self.inc_init_checkbox.setEnabled(False) - file_naming_layout.addWidget(self.inc_init_checkbox) - - self.inc_solv_checkbox = QCheckBox("solvent") - self.inc_solv_checkbox.setChecked(self.options["inc_solv"]) - self.inc_solv_checkbox.stateChanged.connect(self.inc_solv_switched) - if self.options["spec"] == "hf": - self.inc_solv_checkbox.setEnabled(False) - file_naming_layout.addWidget(self.inc_solv_checkbox) - - options_layout.addLayout(file_naming_layout, 4, 1) - - in_filename_label = QLabel("...in filename") - in_filename_label.setAlignment(Qt.AlignCenter) - options_layout.addWidget(in_filename_label, 4, 2) - - # Option to use NMRCheck-style formatting of folder names - self.nmrcheck_style_checkbox = QCheckBox("use comprehensive (NMRCheck) style") - self.nmrcheck_style_checkbox.setChecked(self.options["nmrcheck_style"]) - self.nmrcheck_style_checkbox.stateChanged.connect(self.nmrcheck_style_switched) - options_layout.addWidget(self.nmrcheck_style_checkbox, 5, 1, 1, 2) - - # Spectrometer selection buttons - spec_label = QLabel("search:") - spec_label.setAlignment(Qt.AlignRight | Qt.AlignTop) - options_layout.addWidget(spec_label, 6, 0) - - self.spectrometer_text = { - "300er": "Studer group NMR only (300 MHz)", - "400er": "routine NMR (300 && 400 MHz)", - "hf": "high-field spectrometers (500 && 600 MHz)", - } - self.spec_list = list(self.spectrometer_text.keys()) - self.spec_button_group = QButtonGroup(layout_widget) - self.spec_button_dict = {} - self.spec_button_list = [] - for spec in self.spec_list: - spec_button = QRadioButton(self.spectrometer_text[spec]) - if self.options["spec"] == spec: - spec_button.setChecked(True) - self.spec_button_group.addButton(spec_button) - self.spec_button_dict[spec_button] = spec - self.spec_button_list.append(spec_button) - spec_button.toggled.connect(self.spec_changed) - spec_layout.addWidget(spec_button) - - # Checkbox to instruct to repeat after chosen interval - repeat_layout = QHBoxLayout() - - repeat_label = QLabel("repeat:") - repeat_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(repeat_label, 7, 0) - - self.repeat_check_checkbox = QCheckBox("check every") - self.repeat_check_checkbox.setChecked(self.options["repeat_switch"]) - self.repeat_check_checkbox.stateChanged.connect(self.repeat_switched) - repeat_layout.addWidget(self.repeat_check_checkbox) - - repeat_interval = QSpinBox() - repeat_interval.setMinimum(1) - repeat_interval.setValue(self.options["repeat_delay"]) - repeat_interval.valueChanged.connect(self.repeat_delay_changed) - repeat_layout.addWidget(repeat_interval) - - repeat_mins = QLabel("mins") - repeat_layout.addWidget(repeat_mins) - - options_layout.addLayout(repeat_layout, 7, 1) - - # Button to save all options for future - self.save_button = QPushButton("save options as defaults for next time") - self.save_button.clicked.connect(self.save) - options_layout.addWidget(self.save_button, 8, 0, 1, 3) - self.save_button.setEnabled(False) - - # Initialize date variable - self.date_selected = date.today() - - # Date selection tool for 300er and 400er - date_label = QLabel("when?") - date_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - options_layout.addWidget(date_label, 9, 0) - - date_layout = QHBoxLayout() - options_layout.addLayout(date_layout, 9, 1) - self.only_button = QRadioButton("only") - self.since_button = QRadioButton("since") - date_layout.addWidget(self.only_button, 0) - date_layout.addWidget(self.since_button, 1) - self.date_button_group = QButtonGroup(layout_widget) - self.date_button_group.addButton(self.only_button) - self.date_button_group.addButton(self.since_button) - self.date_button_list = [self.only_button, self.since_button] - self.since_button.toggled.connect(self.since_function_activated) - self.only_button.setChecked(True) - - self.date_selector = QDateEdit() - self.date_selector.setDisplayFormat("dd MMM yyyy") - self.date_selector.setDate(date.today()) - self.date_selector.dateChanged.connect(self.date_changed) - date_layout.addWidget(self.date_selector, 2) - - self.today_button = QPushButton("today") - options_layout.addWidget(self.today_button, 9, 2) - self.today_button.clicked.connect(self.set_date_as_today) - - # Date selection tool for hf (only needs year) - self.hf_date_selector = QDateEdit() - self.hf_date_selector.setDisplayFormat("yyyy") - self.hf_date_selector.setDate(date.today()) - self.hf_date_selector.dateChanged.connect(self.hf_date_changed) - options_layout.addWidget(self.hf_date_selector, 9, 1) - - # Button to begin check - self.start_check_button = QPushButton("start check now") - self.start_check_button.setStyleSheet("background-color : #b88cce") - layout.addWidget(self.start_check_button) - self.start_check_button.clicked.connect(self.started) - - # Button to cancel pending repeat check - self.interrupt_button = QPushButton("cancel repeat check") - self.interrupt_button.setStyleSheet("background-color : #cc0010; color : white") - layout.addWidget(self.interrupt_button) - self.interrupt_button.clicked.connect(self.interrupted) - self.interrupt_button.hide() + self.copied_list = [] # Timer for repeat check, starts checking function when timer runs out - self.timer = QTimer() - self.timer.setSingleShot(True) + self.timer = QTimer().setSingleShot(True) self.timer.timeout.connect(self.started) - # Progress bar for check - self.prog_bar = QProgressBar() - self.prog_bar.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) - if platform.system() == "Windows" and platform.release() == "11": - # Looks bad (with initial Qt Win11 theme at least) so disable text - self.prog_bar.setTextVisible(False) - layout.addWidget(self.prog_bar) + # Setup UI + self.setup_ui() + self.connect_signals() - # Box to display output of check function (list of copied spectra) - self.copied_list = [] - self.display_layout = QVBoxLayout() - self.display = QWidget() - self.display.setLayout(self.display_layout) - self.display_scroll = QScrollArea() - self.display_scroll.setWidgetResizable(True) - self.display_scroll.setWidget(self.display) - layout.addWidget(self.display_scroll) - - # Extra notification that spectra have been found, dismissable - self.notification = QPushButton() - layout.addWidget(self.notification) - self.notification.clicked.connect(self.notification_clicked) - self.notification.hide() - # Trigger function to adapt available options and spectrometers to the user's group - self.adapt_to_group() - # Trigger functions to adapt date selector and naming options to the selected spectrometer - self.adapt_to_spec() - - # Set up window. macos spaces things out more than windows so give it a bigger window - if platform.system() == "Windows": - self.setMinimumSize(QSize(420, 680)) - else: - self.setMinimumSize(QSize(450, 780)) - - # Now come all the other functions - - # Define function to check for updates at location specified def update_check(self, update_path): + """Check for updates at location specified.""" + logging.info(f"Checking for updates at: {update_path}") update_path_version_file = update_path / "version.txt" with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: @@ -393,11 +77,6 @@ def update_check(self, update_path): changelog = "".join(version_file_info[5:]).rstrip() if version_no != newest_version_no: self.notify_update(version_no, newest_version_no, changelog) - if version_no == "v1.6.0" and not (self.rsrc_dir / "notified.txt").exists(): - self.notify_changelog(changelog) - with open((self.rsrc_dir / "notified.txt"), "w") as f: - # Save empty file so that the user is not notified next time - pass except PermissionError: logging.info("Permission to access server denied") failed_permission_dialog = QMessageBox(self) @@ -412,8 +91,10 @@ def update_check(self, update_path): failed_permission_dialog.exec() sys.exit() - # Popup to notify user that an update is available, with version info + def notify_update(self, current, available, changelog): + """Spawn popup to notify user that an update is available, with version info.""" + update_dialog = QMessageBox(self) update_dialog.setWindowTitle("Update available") update_dialog.setText(f"There appears to be a new update available at:\n{self.update_path}") @@ -428,76 +109,126 @@ def notify_update(self, current, available, changelog): # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise os.system(f'start "" "{self.update_path}"') - # Popup to show changelog for current version upon upgrade to v1.6.0 - def notify_changelog(self, changelog): - QMessageBox.information(self, "Changes in v1.6.0", changelog) - # Spawn popup dialog that dissuades user from using the "since" function regularly, unless the nmr group has been selected + def setup_ui(self): + """Setup main layout, which is a simple vertical stack.""" + + self.ui = Layout() + # Add central widget and give it main layout + layout_widget = QWidget() + layout_widget.setLayout(self.ui) + self.setCentralWidget(layout_widget) + + # Trigger function to adapt available options and spectrometers to the user's group + self.adapt_to_group() + # Trigger functions to adapt date selector and naming options to the selected spectrometer + self.adapt_to_spec() + + # Set up window. macos spaces things out more than Windows so give it a bigger window + if platform.system() == "Windows": + self.setMinimumSize(QSize(420, 680)) + else: + self.setMinimumSize(QSize(450, 780)) + + # Make options easily accessible, as they are frequently accessed + self.opts = self.ui.opts + + + def connect_signals(self): + """Connect all the signals from the UI elements to the various handlers.""" + + self.ui.opts.initials_entry.textChanged.connect(self.initials_changed) + self.ui.opts.AK_buttons.buttonClicked.connect(self.group_changed) + self.ui.opts.other_box.currentTextChanged.connect(self.group_changed) + self.ui.opts.dest_path_input.textChanged.connect(self.dest_path_changed) + self.ui.opts.open_button.clicked.connect(self.open_path) + self.ui.opts.inc_init_checkbox.stateChanged.connect(self.inc_init_switched) + self.ui.opts.inc_solv_checkbox.stateChanged.connect(self.inc_solv_switched) + self.ui.opts.nmrcheck_style_checkbox.stateChanged.connect(self.nmrcheck_style_switched) + self.ui.opts.spec_buttons.buttonClicked.connect(self.spec_changed) + self.ui.opts.repeat_check_checkbox.stateChanged.connect(self.repeat_switched) + self.ui.opts.repeat_interval.valueChanged.connect(self.repeat_delay_changed) + self.ui.opts.save_button.clicked.connect(self.save) + self.ui.opts.since_button.toggled.connect(self.since_function_activated) + self.ui.opts.date_selector.dateChanged.connect(self.date_changed) + self.ui.opts.today_button.clicked.connect(self.set_date_as_today) + self.ui.opts.hf_date_selector.dateChanged.connect(self.hf_date_changed) + self.ui.start_check_button.clicked.connect(self.started) + self.ui.interrupt_button.clicked.connect(self.interrupted) + self.ui.notification.clicked.connect(self.notification_clicked) + + def since_function_activated(self): + """Spawn popup dialog that dissuades user from using the "since" function regularly.""" + since_message = """ The function to check multiple days at a time should not be used on a regular basis. Please switch back to a single-day check once your search is finished. The repeat function is also disabled as long as this option is selected. """ - if self.since_button.isChecked() is True and self.options["group"] != "nmr": + if self.opts.since_button.isChecked() is True and self.config.options["group"] != "nmr": QMessageBox.warning(self, "Warning", since_message) - self.repeat_check_checkbox.setEnabled(False) - self.options["repeat_switch"] = False + self.opts.repeat_check_checkbox.setEnabled(False) + self.config.options["repeat_switch"] = False else: - self.repeat_check_checkbox.setEnabled(True) - self.options["repeat_switch"] = self.repeat_check_checkbox.isChecked() + self.opts.repeat_check_checkbox.setEnabled(True) + self.config.options["repeat_switch"] = self.repeat_check_checkbox.isChecked() + def initials_changed(self, new_initials): # Allow initials entry to take five characters total if the nmr group is chosen and the wild group option is invoked if len(new_initials) > 0: - if (self.options["group"] == "nmr") and (new_initials.split()[0] == "*"): - self.initials_entry.setMaxLength(5) + if (self.config.options["group"] == "nmr") and (new_initials.split()[0] == "*"): + self.opts.initials_entry.setMaxLength(5) if len(new_initials.split()) > 1: self.wild_group = True - self.options["initials"] = new_initials.split()[1] + self.config.options["initials"] = new_initials.split()[1] else: - self.options["initials"] = "" + self.config.options["initials"] = "" else: - self.options["initials"] = new_initials + self.config.options["initials"] = new_initials else: - self.initials_entry.setMaxLength(3) - self.options["initials"] = new_initials - self.save_button.setEnabled(True) + self.opts.initials_entry.setMaxLength(3) + self.config.options["initials"] = new_initials + self.opts.save_button.setEnabled(True) + def group_changed(self): - if self.AK_button_group.checkedButton().text() == "other": - self.options["group"] = self.other_box.currentText() + if self.opts.AK_buttons.checkedButton().text() == "other": + self.config.options["group"] = self.opts.other_box.currentText() else: - self.options["group"] = self.AK_button_group.checkedButton().text() + self.config.options["group"] = self.opts.AK_buttons.checkedButton().text() self.adapt_to_group() - self.save_button.setEnabled(True) + self.opts.save_button.setEnabled(True) + def adapt_to_group(self): - if self.options["group"] in self.config.groups["other"]: - self.other_box.show() + if self.config.options["group"] in self.config.groups["other"]: + self.opts.other_box.show() path_hf = ( - self.mora_path / "500-600er" / self.config.groups["other"][self.options["group"]] + self.mora_path / "500-600er" / self.config.groups["other"][self.config.options["group"]] ) else: - self.other_box.hide() - path_hf = self.mora_path / "500-600er" / self.config.groups[self.options["group"]] + self.opts.other_box.hide() + path_hf = self.mora_path / "500-600er" / self.config.groups[self.config.options["group"]] self.spectrometer_paths["hf"] = path_hf # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway - if self.options["group"] == "nmr": - self.inc_init_checkbox.setEnabled(False) - self.nmrcheck_style_checkbox.setEnabled(False) + if self.config.options["group"] == "nmr": + self.opts.inc_init_checkbox.setEnabled(False) + self.opts.nmrcheck_style_checkbox.setEnabled(False) else: # Only enable initials checkbox if nmrcheck_style option is not selected, disable otherwise - self.inc_init_checkbox.setEnabled(not self.options["nmrcheck_style"]) - self.nmrcheck_style_checkbox.setEnabled(True) + self.opts.inc_init_checkbox.setEnabled(not self.config.options["nmrcheck_style"]) + self.opts.nmrcheck_style_checkbox.setEnabled(True) # Make sure wild option is turned off for normal users self.wild_group = False - if self.options["group"] == "nmr" or self.options["spec"] == "hf": - self.inc_solv_checkbox.setEnabled(False) + if self.config.options["group"] == "nmr" or self.config.options["spec"] == "hf": + self.opts.inc_solv_checkbox.setEnabled(False) else: - self.inc_solv_checkbox.setEnabled(True) + self.opts.inc_solv_checkbox.setEnabled(True) self.refresh_visible_specs() + def dest_path_changed(self, new_path): formatted_path = new_path # Best way to ensure cross-platform compatibility is to avoid use of backslashes and then let pathlib.Path take care of formatting @@ -506,104 +237,120 @@ def dest_path_changed(self, new_path): # If the option "copy path" is used in Windows Explorer and then pasted into the box, the path will be surrounded by quotes, so remove them if there if formatted_path[0] == '"': formatted_path = formatted_path.replace('"', "") - self.options["dest_path"] = formatted_path - self.open_button.show() - self.save_button.setEnabled(True) + self.config.options["dest_path"] = formatted_path + self.opts.open_button.show() + self.opts.save_button.setEnabled(True) + def open_path(self): - if Path(self.options["dest_path"]).exists() is True: + if Path(self.config.options["dest_path"]).exists() is True: if platform.system() == "Windows": # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise - os.system(f'start "" "{self.options["dest_path"]}"') + os.system(f'start "" "{self.config.options["dest_path"]}"') elif platform.system() == "Darwin": - subprocess.Popen(["open", self.options["dest_path"]]) + subprocess.Popen(["open", self.config.options["dest_path"]]) elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", self.options["dest_path"]]) + subprocess.Popen(["xdg-open", self.config.options["dest_path"]]) + def inc_init_switched(self): - self.options["inc_init"] = self.inc_init_checkbox.isChecked() - self.save_button.setEnabled(True) + self.config.options["inc_init"] = self.opts.inc_init_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + def inc_solv_switched(self): - self.options["inc_solv"] = self.inc_solv_checkbox.isChecked() - self.save_button.setEnabled(True) + self.config.options["inc_solv"] = self.opts.inc_solv_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + def nmrcheck_style_switched(self): - self.options["nmrcheck_style"] = self.nmrcheck_style_checkbox.isChecked() - self.save_button.setEnabled(True) - self.inc_init_checkbox.setEnabled(not self.nmrcheck_style_checkbox.isChecked()) + self.config.options["nmrcheck_style"] = self.opts.nmrcheck_style_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + self.opts.inc_init_checkbox.setEnabled(not self.opts.nmrcheck_style_checkbox.isChecked()) self.adapt_to_spec() + def refresh_visible_specs(self): - if self.options["group"] in ["stu", "nae", "nmr"]: - self.spec_button_list[0].show() + if self.config.options["group"] in ["stu", "nae", "nmr"]: + self.opts.spec_buttons.buttons["300er"].show() else: - self.spec_button_list[0].hide() + self.opts.spec_buttons.buttons["300er"].hide() + def spec_changed(self): - self.options["spec"] = self.spec_button_dict[self.spec_button_group.checkedButton()] + self.config.options["spec"] = self.opts.spec_buttons.checkedButton().name self.adapt_to_spec() - self.save_button.setEnabled(True) + self.opts.save_button.setEnabled(True) + def adapt_to_spec(self): - if self.options["spec"] == "hf": + if self.config.options["spec"] == "hf": # Including the solvent in the title is not supported for high-field measurements so disable option - self.inc_solv_checkbox.setEnabled(False) - self.repeat_check_checkbox.setEnabled(False) - self.date_selector.hide() - self.today_button.setEnabled(False) - self.hf_date_selector.show() + self.opts.inc_solv_checkbox.setEnabled(False) + self.opts.repeat_check_checkbox.setEnabled(False) + self.opts.date_selector.hide() + self.opts.today_button.setEnabled(False) + self.opts.hf_date_selector.show() else: - if self.options["group"] != "nmr" and self.options["nmrcheck_style"] is False: - self.inc_solv_checkbox.setEnabled(True) - self.repeat_check_checkbox.setEnabled(True) - self.hf_date_selector.hide() - self.date_selector.show() - self.today_button.setEnabled(True) + if self.config.options["group"] != "nmr" and self.config.options["nmrcheck_style"] is False: + self.opts.inc_solv_checkbox.setEnabled(True) + self.opts.repeat_check_checkbox.setEnabled(True) + self.opts.hf_date_selector.hide() + self.opts.date_selector.show() + self.opts.today_button.setEnabled(True) + def repeat_switched(self): - self.options["repeat_switch"] = self.repeat_check_checkbox.isChecked() - self.save_button.setEnabled(True) + self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + def repeat_delay_changed(self, new_delay): - self.options["repeat_delay"] = new_delay - self.save_button.setEnabled(True) + self.config.options["repeat_delay"] = new_delay + self.opts.save_button.setEnabled(True) + def save(self): self.config.save() - self.save_button.setEnabled(False) + self.opts.save_button.setEnabled(False) + def date_changed(self): - self.date_selected = self.date_selector.date().toPython() + self.date_selected = self.opts.date_selector.date().toPython() + def hf_date_changed(self): - self.date_selected = self.hf_date_selector.date().toPython() + self.date_selected = self.opts.hf_date_selector.date().toPython() + def set_date_as_today(self): - self.date_selector.setDate(date.today()) + self.opts.date_selector.setDate(date.today()) + - # Converts Python datetime.date object to the same format used in the folder names on Mora def format_date(self, input_date): - if self.options["spec"] == "hf": + """Convert Python datetime.date object to the same format used in the folder names on Mora.""" + if self.config.options["spec"] == "hf": formatted_date = input_date.strftime("%Y") else: formatted_date = input_date.strftime("%b%d-%Y") return formatted_date + def started(self): self.queued_checks = 0 - if self.only_button.isChecked() is True or self.options["spec"] == "hf": + if self.opts.only_button.isChecked() is True or self.config.options["spec"] == "hf": self.single_check(self.date_selected) - elif self.since_button.isChecked() is True: + elif self.opts.since_button.isChecked() is True: self.multiday_check(self.date_selected) + def single_check(self, date): - self.start_check_button.setEnabled(False) + self.opts.start_check_button.setEnabled(False) formatted_date = self.format_date(date) # Start main checking function in worker thread worker = Worker( check_nmr, - self.options, + self.config.options, formatted_date, self.mora_path, self.spectrometer_paths, @@ -616,6 +363,7 @@ def single_check(self, date): self.threadpool.start(worker) self.queued_checks += 1 + def multiday_check(self, initial_date): end_date = date.today() + timedelta(days=1) date_to_check = initial_date @@ -623,16 +371,19 @@ def multiday_check(self, initial_date): self.single_check(date_to_check) date_to_check += timedelta(days=1) + def update_progress(self, prog_state): - self.prog_bar.setValue(prog_state) + self.ui.prog_bar.setValue(prog_state) + def handle_output(self, final_output): self.copied_list = final_output + def check_ended(self): # Set progress to 100% just in case it didn't reach it for whatever reason - self.prog_bar.setMaximum(1) - self.prog_bar.setValue(1) + self.ui.prog_bar.setMaximum(1) + self.ui.prog_bar.setValue(1) # Will only not be true if an unknown error occurred, in all cases len will be at least 2 if len(self.copied_list) > 1: # At least one spectrum was found @@ -653,41 +404,43 @@ def check_ended(self): # Display output for entry in self.copied_list: entry_label = QLabel(entry) - self.display_layout.addWidget(entry_label) + self.ui.display.add_entry(entry_label) # Move scroll area so that the user sees immediately which spectra were found or what the error was - but only the first time this happens (haven't been able to make this work) # if entry == self.copied_list[0] and self.copied_list[0][:5] != "check": # QApplication.processEvents() # self.display_scroll.ensureWidgetVisible(entry_label, ymargin=50) # Behaviour for repeat check function. Deactivate for hf spectrometer. See also self.timer in init function - if (self.options["repeat_switch"] is True) and (self.options["spec"] != "hf"): - self.start_check_button.hide() - self.interrupt_button.show() - self.timer.start(int(self.options["repeat_delay"]) * 60 * 1000) + if (self.config.options["repeat_switch"] is True) and (self.config.options["spec"] != "hf"): + self.ui.start_check_button.hide() + self.ui.interrupt_button.show() + self.timer.start(int(self.config.options["repeat_delay"]) * 60 * 1000) # Enable start check button again, but only if all queued checks have finished self.queued_checks -= 1 if self.queued_checks == 0: - self.start_check_button.setEnabled(True) + self.ui.start_check_button.setEnabled(True) logging.info("Task complete") + def interrupted(self): self.timer.stop() - self.start_check_button.show() - self.interrupt_button.hide() + self.ui.start_check_button.show() + self.ui.interrupt_button.hide() + def notify(self, copied_list): # If spectra were found, the list will have len > 1, if a known error occurred, the list will have len 1, if an unknown error occurred, the list will be empty if len(copied_list) > 1: notification_text = "Spectra have been found!" - self.notification.setText(notification_text + " Ctrl+G to go to. Click to dismiss") - self.notification.setStyleSheet("background-color : limegreen") + self.ui.notification.setText(notification_text + " Ctrl+G to go to. Click to dismiss") + self.ui.notification.setStyleSheet("background-color : limegreen") else: - self.notification.setStyleSheet("background-color : #cc0010; color : white") + self.ui.notification.setStyleSheet("background-color : #cc0010; color : white") try: notification_text = "Error: " + copied_list[0] except: notification_text = "Unknown error occurred." - self.notification.setText(notification_text + " Click to dismiss") - self.notification.show() + self.ui.notification.setText(notification_text + " Click to dismiss") + self.ui.notification.show() if self.since_button.isChecked() is False and platform.system() != "Darwin": # Display system notification - doesn't seem to be implemented for macOS currently # Only if a single date is checked, because with the since function the system notifications get annoying @@ -701,5 +454,6 @@ def notify(self, copied_list): except: pass + def notification_clicked(self): - self.notification.hide() + self.ui.notification.hide() diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/display.py b/ui/display.py new file mode 100644 index 0000000..cf55154 --- /dev/null +++ b/ui/display.py @@ -0,0 +1,18 @@ +from PySide6.QtWidgets import ( + QScrollArea, + QVBoxLayout, + QWidget, +) + +class Display(QScrollArea): + """Box to display output of check function (list of copied spectra)""" + + def __init__(self): + super().__init__() + self.setWidgetResizable(True) + self.layout = QVBoxLayout() + self.display = QWidget().setLayout(self.layout) + self.setWidget(self.display) + + def add_entry(self, entry): + self.layout.addWidget(entry) diff --git a/ui/layout.py b/ui/layout.py new file mode 100644 index 0000000..42cb548 --- /dev/null +++ b/ui/layout.py @@ -0,0 +1,55 @@ +import platform + +from PySide6.QtCore import QTimer, Qt +from PySide6.QtWidgets import ( + QPushButton, + QLabel, + QProgressBar, + QVBoxLayout, +) + +from ui.options import OptionsLayout +from ui.display import Display + +class Layout(QVBoxLayout): + """Main layout, which is a simple vertical stack.""" + + def __init__(self, config): + super().__init__() + self.add_elements(config) + + + def add_elements(self, config): + # Title and version info header + self.setWindowTitle("Mora the Explorer") + with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: + version_info = "".join(f.readlines()[:5]) + version_box = QLabel(version_info).setAlignment(Qt.AlignHCenter) + self.addWidget(version_box) + + # All the user-configurable options + self.opts = OptionsLayout(config) + self.addLayout(self.opts) + + # Button to begin check + self.start_check_button = QPushButton("start check now").setStyleSheet("background-color : #b88cce") + self.addWidget(self.start_check_button) + + # Button to cancel pending repeat check + self.interrupt_button = QPushButton("cancel repeat check").setStyleSheet("background-color : #cc0010; color : white") + self.addWidget(self.interrupt_button).hide() + + # Progress bar for check + self.prog_bar = QProgressBar().setAlignment(Qt.AlignCenter | Qt.AlignVCenter) + if platform.system() == "Windows" and platform.release() == "11": + # Looks bad (with initial Qt Win11 theme at least) so disable text + self.prog_bar.setTextVisible(False) + self.addWidget(self.prog_bar) + + # Box to display output of check function (list of copied spectra) + self.display = Display() + self.addWidget(self.display) + + # Extra notification that spectra have been found, dismissable + self.notification = QPushButton().hide() + self.addWidget(self.notification) diff --git a/ui/options.py b/ui/options.py new file mode 100644 index 0000000..25320e5 --- /dev/null +++ b/ui/options.py @@ -0,0 +1,187 @@ +from datetime import date + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QPushButton, + QRadioButton, + QButtonGroup, + QComboBox, + QLabel, + QLineEdit, + QDateEdit, + QCheckBox, + QSpinBox, + QHBoxLayout, + QVBoxLayout, + QGridLayout, +) + +class AKButtons(QButtonGroup): + def __init__(self, parent, ak_list, selected_ak): + super().__init__(parent) + + self.main_layout = QHBoxLayout() + self.overflow_layout = QHBoxLayout() + self.button_list = [] + for ak in ak_list: + ak_button = QRadioButton(ak) + self.button_list.append(ak_button) + if (ak == selected_ak) or ( + ak == "other" and self.checkedButton() is None + ): + ak_button.setChecked(True) + self.addButton(ak_button) + if len(ak_list) <= 4 or ak_list.index(ak) < (len(ak_list) / 2): + self.main_layout.addWidget(ak_button) + elif len(ak_list) > 4 and ak_list.index(ak) >= (len(ak_list) / 2): + self.overflow_layout.addWidget(ak_button) + + +class SpecButton(QRadioButton): + """Just like a normal QRadioButton except we can assign it a name.""" + + def __init__(self, text, name): + super().__init__(text) + self.name = name + + +class SpecButtons(QButtonGroup): + def __init__(self, parent, selected_spec): + super().__init__(parent) + + self.layout = QVBoxLayout() + + self.spec_text = { + "300er": "Studer group NMR only (300 MHz)", + "400er": "routine NMR (300 && 400 MHz)", + "hf": "high-field spectrometers (500 && 600 MHz)", + } + + self.buttons = {} + + for spec in self.specs.keys(): + button = SpecButton(self.spec_text[spec], spec) + self.buttons[spec] = button + if spec == selected_spec: + button.setChecked(True) + self.addButton(button) + self.layout.addWidget(button) + + +class OptionsLayout(QGridLayout): + """Layout containing all user-configurable options. + + Widgets with more complicated code are defined in custom classes, while simple ones + are defined in-line. + """ + def __init__(self, config): + super().__init__() + + # Row 0, initials entry box + self.initials_label = QLabel("initials:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.initials_entry = QLineEdit().setMaxLength(3).setText(self.options["initials"]) + self.initials_hint = QLabel("(lowercase!)").setAlignment(Qt.AlignCenter) + + self.addWidget(self.initials_label, 0, 0) + self.addWidget(self.initials_entry, 0, 1) + self.addWidget(self.initials_hint, 0, 2) + + + # Row 1, research group selection buttons + self.group_label = QLabel("group:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.AK_buttons = AKButtons(self, list(config.groups.keys()), config.options["group"]) + + self.addWidget(self.group_label, 1, 0) + self.addLayout(self.AK_buttons.main_layout, 1, 1) + self.addLayout(self.AK_buttons.overflow_layout, 2, 1) + + + # Row 2, drop down list for further options that appears only when "other" + # radio button is clicked + self.other_box = QComboBox().addItems(config.groups["other"].values()) + if config.options["group"] in config.groups["other"].values(): + self.other_box.setCurrentText(config.options["group"]) + else: + self.other_box.hide() + + self.addWidget(self.other_box, 2, 2) + + + # Row 3, destination path entry box + self.dest_path_label = QLabel("save in:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.dest_path_input = QLineEdit().setText(config.options["dest_path"]) + self.open_button = QPushButton("go to").setShortcut("Ctrl+G") + # Disable button if path hasn't yet been specified to stop new users thinking it should be used to select a folder + if config.options["dest_path"] == "copy full path here": + self.open_button.hide() + + self.addWidget(self.dest_path_label, 3, 0) + self.addWidget(self.dest_path_input, 3, 1) + self.addWidget(self.open_button, 3, 2) + + + # Row 4, file naming options + self.include_label = QLabel("include:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.inc_init_checkbox = QCheckBox("initials").setChecked(config.options["inc_init"]) + if config.options["nmrcheck_style"] is True: + self.inc_init_checkbox.setEnabled(False) + self.inc_solv_checkbox = QCheckBox("solvent").setChecked(config.options["inc_solv"]) + if config.options["spec"] == "hf": + self.inc_solv_checkbox.setEnabled(False) + self.in_filename_label = QLabel("...in filename").setAlignment(Qt.AlignCenter) + + self.addWidget(self.include_label, 4, 0) + self.addLayout(QHBoxLayout().addWidget(self.inc_init_checkbox).addWidget(self.inc_solv_checkbox), 4, 1) + self.addWidget(self.in_filename_label, 4, 2) + + + # Row 5, option to use NMRCheck-style formatting of folder names + self.nmrcheck_style_checkbox = QCheckBox("use comprehensive (NMRCheck) style").setChecked(config.options["nmrcheck_style"]) + + self.addWidget(self.nmrcheck_style_checkbox, 5, 1, 1, 2) + + + # Row 6, spectrometer selection buttons + self.spec_label = QLabel("search:").setAlignment(Qt.AlignRight | Qt.AlignTop) + self.spec_buttons = SpecButtons(self, config.options["spec"]) + + self.addWidget(self.spec_label, 6, 0) + self.addLayout(self.spec_buttons.layout, 6, 1, 1, 2) + + + # Row 7, checkbox to instruct to repeat after chosen interval + self.repeat_label = QLabel("repeat:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.repeat_check_checkbox = QCheckBox("check every").setChecked(self.options["repeat_switch"]) + self.repeat_interval = QSpinBox().setMinimum(1).setValue(self.options["repeat_delay"]) + + self.addWidget(self.repeat_label, 7, 0) + self.addLayout(QHBoxLayout().addWidget(self.repeat_check_checkbox).addWidget(self.repeat_interval).addWidget(QLabel("mins")), 7, 1) + + + # Row 8, button to save all options for future + self.save_button = QPushButton("save options as defaults for next time").setEnabled(False) + + self.addWidget(self.save_button, 8, 0, 1, 3) + + + # Row 9, date selection tool + self.date_label = QLabel("when?").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.today_button = QPushButton("today") + + self.addWidget(self.date_label, 9, 0) + self.addWidget(self.today_button, 9, 2) + + # Date selection tool for 300er and 400er + self.only_button = QRadioButton("only").setChecked(True) + self.since_button = QRadioButton("since") + self.date_button_group = QButtonGroup(self).addButton(self.only_button).addButton(self. since_button) + self.date_selector = QDateEdit().setDisplayFormat("dd MMM yyyy").setDate(date.today()) + + self.addLayout(QHBoxLayout().addWidget(self.only_button, 0).addWidget(self.since_button, 1).addWidget(self.date_selector, 2), 9, 1) + + # Date selection tool for hf (only needs year) + self.hf_date_selector = QDateEdit().setDisplayFormat("yyyy").setDate(date.today()) + + # Add to same part of layout as the normal date selector - + # only one is shown at a time + self.addWidget(self.hf_date_selector, 9, 1) diff --git a/worker.py b/worker.py new file mode 100644 index 0000000..485692c --- /dev/null +++ b/worker.py @@ -0,0 +1,29 @@ +from PySide6.QtCore import QRunnable, Signal, Slot, QObject + + +class WorkerSignals(QObject): + progress = Signal(int) + result = Signal(object) + completed = Signal() + + +class Worker(QRunnable): + def __init__(self, fn, *args, **kwargs): + super(Worker, self).__init__() + + # Pass function itself, along with provided arguments, to new function within the Checker instance + self.fn = fn + self.args = args + self.kwargs = kwargs + # Give the Checker signals + self.signals = WorkerSignals() + # Add the callback to kwargs + self.kwargs["progress_callback"] = self.signals.progress + + @Slot() + def run(self): + # Run the Worker function with passed args, kwargs, including progress_callback + output = self.fn(*self.args, **self.kwargs) + # Emit the output of the function as the result signal so that it can be picked up + self.signals.result.emit(output) + self.signals.completed.emit() \ No newline at end of file From 3cb485a80aa24aad0a3890b4367ad43c27d4a48a Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Fri, 24 May 2024 19:17:04 +0200 Subject: [PATCH 02/20] Take logic out of MainWindow. Almost works, but attribute errors galore when start search is clicked --- explorer.py | 275 ++++++++++++++++++++++++++ main_window.py | 459 ------------------------------------------- mora_the_explorer.py | 19 +- ui/layout.py | 35 ++-- ui/main_window.py | 273 +++++++++++++++++++++++++ ui/options.py | 114 ++++++++--- 6 files changed, 674 insertions(+), 501 deletions(-) create mode 100644 explorer.py delete mode 100644 main_window.py create mode 100644 ui/main_window.py diff --git a/explorer.py b/explorer.py new file mode 100644 index 0000000..7576a93 --- /dev/null +++ b/explorer.py @@ -0,0 +1,275 @@ +import logging +import os +import platform +import subprocess +import sys +from datetime import date, timedelta +from pathlib import Path + +import plyer + +from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject +from PySide6.QtWidgets import ( + QMainWindow, + QLabel, + QWidget, + QMessageBox, +) + +from worker import Worker +from checknmr import check_nmr +from config import Config +from ui.main_window import MainWindow + + +class Explorer: + def __init__(self, main_window: MainWindow, resource_directory: Path, config: Config): + + self.main_window = main_window + self.rsrc_dir = resource_directory + self.config = config + + # Make it easier to access elements of the UI + self.ui = self.main_window.ui + self.opts = self.main_window.ui.opts + + # Set up multithreading; MaxThreadCount limited to 1 as checks don't run properly if multiple run concurrently + self.threadpool = QThreadPool() + self.threadpool.setMaxThreadCount(1) + + # Set path to mora + self.mora_path = Path(config.paths[platform.system()]) + self.update_path = Path(config.paths["update"]) + + # Define paths to spectrometers based on loaded mora_path + self.path_300er = self.mora_path / "300er" + self.path_400er = self.mora_path / "400er" + self.spectrometer_paths = { + "300er": self.path_300er, + "400er": self.path_400er, + } + + # Check for updates + self.update_check(Path(config.paths["update"])) + + # Initialize some other variables for later + self.wild_group = False + self.copied_list = [] + self.date_selected = date.today() + + # Timer for repeat check, starts checking function when timer runs out + self.timer = QTimer() + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.started) + + self.connect_signals() + + + def update_check(self, update_path): + """Check for updates at location specified.""" + + logging.info(f"Checking for updates at: {update_path}") + update_path_version_file = update_path / "version.txt" + with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: + version_no = f.readlines()[2].rstrip() + logging.info(f"Current version: {version_no}") + try: + if update_path_version_file.exists() is True: + with open(update_path_version_file, encoding="utf-8") as f: + version_file_info = f.readlines() + newest_version_no = version_file_info[2].rstrip() + changelog = "".join(version_file_info[5:]).rstrip() + if version_no != newest_version_no: + self.notify_update(version_no, newest_version_no, changelog) + except PermissionError: + self.main_window.notify_failed_permissions() + + + def connect_signals(self): + """Connect all the signals from the UI elements to the various handlers. + + As much as possible, when the effects are only relevant for the UI, the handlers + are defined as methods of MainWindow, while those that are relevant for the + backend logic and searching are defined here as methods of Explorer. + + To allow a reasonable overview, however, all signals are connected here. + """ + # Remember that self.ui = self.main_window.ui + # and self.opts = self.main_window.ui.opts + self.opts.initials_entry.textChanged.connect(self.initials_changed) + self.opts.AK_buttons.buttonClicked.connect(self.group_changed) + self.opts.other_box.currentTextChanged.connect(self.group_changed) + self.opts.dest_path_input.textChanged.connect(self.main_window.dest_path_changed) + self.opts.open_button.clicked.connect(self.open_path) + self.opts.inc_init_checkbox.stateChanged.connect(self.main_window.inc_init_switched) + self.opts.inc_solv_checkbox.stateChanged.connect(self.main_window.inc_solv_switched) + self.opts.nmrcheck_style_checkbox.stateChanged.connect(self.main_window.nmrcheck_style_switched) + self.opts.spec_buttons.buttonClicked.connect(self.main_window.spec_changed) + self.opts.repeat_check_checkbox.stateChanged.connect(self.main_window.repeat_switched) + self.opts.repeat_interval.valueChanged.connect(self.main_window.repeat_delay_changed) + self.opts.save_button.clicked.connect(self.main_window.save) + self.opts.since_button.toggled.connect(self.main_window.since_function_activated) + self.opts.date_selector.dateChanged.connect(self.date_changed) + self.opts.today_button.clicked.connect(self.main_window.set_date_as_today) + self.opts.hf_date_selector.dateChanged.connect(self.hf_date_changed) + self.ui.start_check_button.clicked.connect(self.started) + self.ui.interrupt_button.clicked.connect(self.interrupted) + self.ui.notification.clicked.connect(self.main_window.notification_clicked) + + + def initials_changed(self, new_initials): + # Allow initials entry to take five characters total if the nmr group is chosen + # and the wild group option is invoked + if len(new_initials) > 0: + if (new_initials.split()[0] == "*") and (self.config.options["group"] == "nmr"): + self.opts.initials_entry.setMaxLength(5) + if len(new_initials.split()) > 1: + self.wild_group = True + self.config.options["initials"] = new_initials.split()[1] + else: + self.config.options["initials"] = "" + else: + self.config.options["initials"] = new_initials + else: + self.opts.initials_entry.setMaxLength(3) + self.config.options["initials"] = new_initials + self.opts.save_button.setEnabled(True) + + + def group_changed(self): + self.main_window.group_changed() + self.adapt_paths_to_group(self.config.options["group"]) + + + def adapt_paths_to_group(self, group): + if group in self.config.groups["other"]: + path_hf = ( + self.mora_path / "500-600er" / self.config.groups["other"][group] + ) + else: + path_hf = self.mora_path / "500-600er" / self.config.groups[group] + self.spectrometer_paths["hf"] = path_hf + # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway + if group != "nmr": + # Make sure wild option is turned off for normal users + self.wild_group = False + + + def open_path(self): + if Path(self.config.options["dest_path"]).exists() is True: + if platform.system() == "Windows": + # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise + os.system(f'start "" "{self.config.options["dest_path"]}"') + elif platform.system() == "Darwin": + subprocess.Popen(["open", self.config.options["dest_path"]]) + elif platform.system() == "Linux": + subprocess.Popen(["xdg-open", self.config.options["dest_path"]]) + + + def date_changed(self): + self.date_selected = self.opts.date_selector.date().toPython() + + + def hf_date_changed(self): + self.date_selected = self.opts.hf_date_selector.date().toPython() + + + def format_date(self, input_date): + """Convert Python datetime.date object to the same format used in the folder names on Mora.""" + if self.config.options["spec"] == "hf": + formatted_date = input_date.strftime("%Y") + else: + formatted_date = input_date.strftime("%b%d-%Y") + return formatted_date + + + def started(self): + self.queued_checks = 0 + if self.opts.only_button.isChecked() is True or self.config.options["spec"] == "hf": + self.single_check(self.date_selected) + elif self.opts.since_button.isChecked() is True: + self.multiday_check(self.date_selected) + + + def single_check(self, date): + self.ui.start_check_button.setEnabled(False) + formatted_date = self.format_date(date) + # Start main checking function in worker thread + worker = Worker( + check_nmr, + self.config.options, + formatted_date, + self.mora_path, + self.spectrometer_paths, + self.wild_group, + self.prog_bar, + ) + worker.signals.progress.connect(self.update_progress) + worker.signals.result.connect(self.handle_output) + worker.signals.completed.connect(self.check_ended) + self.threadpool.start(worker) + self.queued_checks += 1 + + + def multiday_check(self, initial_date): + end_date = date.today() + timedelta(days=1) + date_to_check = initial_date + while date_to_check != end_date: + self.single_check(date_to_check) + date_to_check += timedelta(days=1) + + + def update_progress(self, prog_state): + self.ui.prog_bar.setValue(prog_state) + + + def handle_output(self, final_output): + self.copied_list = final_output + + + def check_ended(self): + # Set progress to 100% just in case it didn't reach it for whatever reason + self.ui.prog_bar.setMaximum(1) + self.ui.prog_bar.setValue(1) + # Will only not be true if an unknown error occurred, in all cases len will be at least 2 + if len(self.copied_list) > 1: + # At least one spectrum was found + if self.copied_list[1][:5] == "spect": + self.copied_list.pop(0) + self.notify(self.copied_list) + # No spectra were found but check completed successfully + elif self.copied_list[1][:5] == "check": + pass + # Known error occurred + else: + self.copied_list.pop(0) + self.notify(self.copied_list) + else: + # Unknown error occurred, output of check function was returned without appending anything to copied_list + self.copied_list.pop(0) + self.notify(self.copied_list) + # Display output + for entry in self.copied_list: + entry_label = QLabel(entry) + self.ui.display.add_entry(entry_label) + # Move scroll area so that the user sees immediately which spectra were found or what the error was - but only the first time this happens (haven't been able to make this work) + # if entry == self.copied_list[0] and self.copied_list[0][:5] != "check": + # QApplication.processEvents() + # self.display_scroll.ensureWidgetVisible(entry_label, ymargin=50) + # Behaviour for repeat check function. Deactivate for hf spectrometer. See also self.timer in init function + if (self.config.options["repeat_switch"] is True) and (self.config.options["spec"] != "hf"): + self.ui.start_check_button.hide() + self.ui.interrupt_button.show() + self.timer.start(int(self.config.options["repeat_delay"]) * 60 * 1000) + # Enable start check button again, but only if all queued checks have finished + self.queued_checks -= 1 + if self.queued_checks == 0: + self.ui.start_check_button.setEnabled(True) + logging.info("Task complete") + + + def interrupted(self): + self.timer.stop() + self.ui.start_check_button.show() + self.ui.interrupt_button.hide() + diff --git a/main_window.py b/main_window.py deleted file mode 100644 index 9177488..0000000 --- a/main_window.py +++ /dev/null @@ -1,459 +0,0 @@ -import logging -import os -import platform -import subprocess -import sys -from datetime import date, timedelta -from pathlib import Path - -import plyer - -from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject -from PySide6.QtWidgets import ( - QMainWindow, - QLabel, - QWidget, - QMessageBox, -) - -from worker import Worker -from checknmr import check_nmr -from config import Config -from ui.layout import Layout - - -class MainWindow(QMainWindow): - def __init__(self, resource_directory: Path, config: Config): - super().__init__() - - self.rsrc_dir = resource_directory - self.config = config - - # Set up multithreading; MaxThreadCount limited to 1 as checks don't run properly if multiple run concurrently - self.threadpool = QThreadPool() - self.threadpool.setMaxThreadCount(1) - - # Set path to mora - self.mora_path = Path(config.paths[platform.system()]) - self.update_path = Path(config.paths["update"]) - - # Define paths to spectrometers based on loaded mora_path - self.path_300er = self.mora_path / "300er" - self.path_400er = self.mora_path / "400er" - self.spectrometer_paths = { - "300er": self.path_300er, - "400er": self.path_400er, - } - - # Check for updates - self.update_check(Path(config.paths["update"])) - - # Initialize some other variables for later - self.wild_group = False - self.copied_list = [] - - # Timer for repeat check, starts checking function when timer runs out - self.timer = QTimer().setSingleShot(True) - self.timer.timeout.connect(self.started) - - # Setup UI - self.setup_ui() - self.connect_signals() - - - def update_check(self, update_path): - """Check for updates at location specified.""" - - logging.info(f"Checking for updates at: {update_path}") - update_path_version_file = update_path / "version.txt" - with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: - version_no = f.readlines()[2].rstrip() - logging.info(f"Current version: {version_no}") - try: - if update_path_version_file.exists() is True: - with open(update_path_version_file, encoding="utf-8") as f: - version_file_info = f.readlines() - newest_version_no = version_file_info[2].rstrip() - changelog = "".join(version_file_info[5:]).rstrip() - if version_no != newest_version_no: - self.notify_update(version_no, newest_version_no, changelog) - except PermissionError: - logging.info("Permission to access server denied") - failed_permission_dialog = QMessageBox(self) - failed_permission_dialog.setWindowTitle("Access to mora server denied") - failed_permission_dialog.setText( - """ -You have been denied permission to access the mora server. -Check the connection and your authentication details and try again. -The program will now close. - """ - ) - failed_permission_dialog.exec() - sys.exit() - - - def notify_update(self, current, available, changelog): - """Spawn popup to notify user that an update is available, with version info.""" - - update_dialog = QMessageBox(self) - update_dialog.setWindowTitle("Update available") - update_dialog.setText(f"There appears to be a new update available at:\n{self.update_path}") - update_dialog.setInformativeText( - f"Your version is {current}\nThe version on the server is {available}\n{changelog}" - ) - update_dialog.setStandardButtons(QMessageBox.Ignore | QMessageBox.Open) - update_dialog.setDefaultButton(QMessageBox.Ignore) - choice = update_dialog.exec() - if choice == QMessageBox.Open: - if self.update_path.exists() is True: - # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise - os.system(f'start "" "{self.update_path}"') - - - def setup_ui(self): - """Setup main layout, which is a simple vertical stack.""" - - self.ui = Layout() - # Add central widget and give it main layout - layout_widget = QWidget() - layout_widget.setLayout(self.ui) - self.setCentralWidget(layout_widget) - - # Trigger function to adapt available options and spectrometers to the user's group - self.adapt_to_group() - # Trigger functions to adapt date selector and naming options to the selected spectrometer - self.adapt_to_spec() - - # Set up window. macos spaces things out more than Windows so give it a bigger window - if platform.system() == "Windows": - self.setMinimumSize(QSize(420, 680)) - else: - self.setMinimumSize(QSize(450, 780)) - - # Make options easily accessible, as they are frequently accessed - self.opts = self.ui.opts - - - def connect_signals(self): - """Connect all the signals from the UI elements to the various handlers.""" - - self.ui.opts.initials_entry.textChanged.connect(self.initials_changed) - self.ui.opts.AK_buttons.buttonClicked.connect(self.group_changed) - self.ui.opts.other_box.currentTextChanged.connect(self.group_changed) - self.ui.opts.dest_path_input.textChanged.connect(self.dest_path_changed) - self.ui.opts.open_button.clicked.connect(self.open_path) - self.ui.opts.inc_init_checkbox.stateChanged.connect(self.inc_init_switched) - self.ui.opts.inc_solv_checkbox.stateChanged.connect(self.inc_solv_switched) - self.ui.opts.nmrcheck_style_checkbox.stateChanged.connect(self.nmrcheck_style_switched) - self.ui.opts.spec_buttons.buttonClicked.connect(self.spec_changed) - self.ui.opts.repeat_check_checkbox.stateChanged.connect(self.repeat_switched) - self.ui.opts.repeat_interval.valueChanged.connect(self.repeat_delay_changed) - self.ui.opts.save_button.clicked.connect(self.save) - self.ui.opts.since_button.toggled.connect(self.since_function_activated) - self.ui.opts.date_selector.dateChanged.connect(self.date_changed) - self.ui.opts.today_button.clicked.connect(self.set_date_as_today) - self.ui.opts.hf_date_selector.dateChanged.connect(self.hf_date_changed) - self.ui.start_check_button.clicked.connect(self.started) - self.ui.interrupt_button.clicked.connect(self.interrupted) - self.ui.notification.clicked.connect(self.notification_clicked) - - - def since_function_activated(self): - """Spawn popup dialog that dissuades user from using the "since" function regularly.""" - - since_message = """ -The function to check multiple days at a time should not be used on a regular basis. -Please switch back to a single-day check once your search is finished. -The repeat function is also disabled as long as this option is selected. - """ - if self.opts.since_button.isChecked() is True and self.config.options["group"] != "nmr": - QMessageBox.warning(self, "Warning", since_message) - self.opts.repeat_check_checkbox.setEnabled(False) - self.config.options["repeat_switch"] = False - else: - self.opts.repeat_check_checkbox.setEnabled(True) - self.config.options["repeat_switch"] = self.repeat_check_checkbox.isChecked() - - - def initials_changed(self, new_initials): - # Allow initials entry to take five characters total if the nmr group is chosen and the wild group option is invoked - if len(new_initials) > 0: - if (self.config.options["group"] == "nmr") and (new_initials.split()[0] == "*"): - self.opts.initials_entry.setMaxLength(5) - if len(new_initials.split()) > 1: - self.wild_group = True - self.config.options["initials"] = new_initials.split()[1] - else: - self.config.options["initials"] = "" - else: - self.config.options["initials"] = new_initials - else: - self.opts.initials_entry.setMaxLength(3) - self.config.options["initials"] = new_initials - self.opts.save_button.setEnabled(True) - - - def group_changed(self): - if self.opts.AK_buttons.checkedButton().text() == "other": - self.config.options["group"] = self.opts.other_box.currentText() - else: - self.config.options["group"] = self.opts.AK_buttons.checkedButton().text() - self.adapt_to_group() - self.opts.save_button.setEnabled(True) - - - def adapt_to_group(self): - if self.config.options["group"] in self.config.groups["other"]: - self.opts.other_box.show() - path_hf = ( - self.mora_path / "500-600er" / self.config.groups["other"][self.config.options["group"]] - ) - else: - self.opts.other_box.hide() - path_hf = self.mora_path / "500-600er" / self.config.groups[self.config.options["group"]] - self.spectrometer_paths["hf"] = path_hf - # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway - if self.config.options["group"] == "nmr": - self.opts.inc_init_checkbox.setEnabled(False) - self.opts.nmrcheck_style_checkbox.setEnabled(False) - else: - # Only enable initials checkbox if nmrcheck_style option is not selected, disable otherwise - self.opts.inc_init_checkbox.setEnabled(not self.config.options["nmrcheck_style"]) - self.opts.nmrcheck_style_checkbox.setEnabled(True) - # Make sure wild option is turned off for normal users - self.wild_group = False - if self.config.options["group"] == "nmr" or self.config.options["spec"] == "hf": - self.opts.inc_solv_checkbox.setEnabled(False) - else: - self.opts.inc_solv_checkbox.setEnabled(True) - self.refresh_visible_specs() - - - def dest_path_changed(self, new_path): - formatted_path = new_path - # Best way to ensure cross-platform compatibility is to avoid use of backslashes and then let pathlib.Path take care of formatting - if "\\" in formatted_path: - formatted_path = formatted_path.replace("\\", "/") - # If the option "copy path" is used in Windows Explorer and then pasted into the box, the path will be surrounded by quotes, so remove them if there - if formatted_path[0] == '"': - formatted_path = formatted_path.replace('"', "") - self.config.options["dest_path"] = formatted_path - self.opts.open_button.show() - self.opts.save_button.setEnabled(True) - - - def open_path(self): - if Path(self.config.options["dest_path"]).exists() is True: - if platform.system() == "Windows": - # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise - os.system(f'start "" "{self.config.options["dest_path"]}"') - elif platform.system() == "Darwin": - subprocess.Popen(["open", self.config.options["dest_path"]]) - elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", self.config.options["dest_path"]]) - - - def inc_init_switched(self): - self.config.options["inc_init"] = self.opts.inc_init_checkbox.isChecked() - self.opts.save_button.setEnabled(True) - - - def inc_solv_switched(self): - self.config.options["inc_solv"] = self.opts.inc_solv_checkbox.isChecked() - self.opts.save_button.setEnabled(True) - - - def nmrcheck_style_switched(self): - self.config.options["nmrcheck_style"] = self.opts.nmrcheck_style_checkbox.isChecked() - self.opts.save_button.setEnabled(True) - self.opts.inc_init_checkbox.setEnabled(not self.opts.nmrcheck_style_checkbox.isChecked()) - self.adapt_to_spec() - - - def refresh_visible_specs(self): - if self.config.options["group"] in ["stu", "nae", "nmr"]: - self.opts.spec_buttons.buttons["300er"].show() - else: - self.opts.spec_buttons.buttons["300er"].hide() - - - def spec_changed(self): - self.config.options["spec"] = self.opts.spec_buttons.checkedButton().name - self.adapt_to_spec() - self.opts.save_button.setEnabled(True) - - - def adapt_to_spec(self): - if self.config.options["spec"] == "hf": - # Including the solvent in the title is not supported for high-field measurements so disable option - self.opts.inc_solv_checkbox.setEnabled(False) - self.opts.repeat_check_checkbox.setEnabled(False) - self.opts.date_selector.hide() - self.opts.today_button.setEnabled(False) - self.opts.hf_date_selector.show() - else: - if self.config.options["group"] != "nmr" and self.config.options["nmrcheck_style"] is False: - self.opts.inc_solv_checkbox.setEnabled(True) - self.opts.repeat_check_checkbox.setEnabled(True) - self.opts.hf_date_selector.hide() - self.opts.date_selector.show() - self.opts.today_button.setEnabled(True) - - - def repeat_switched(self): - self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() - self.opts.save_button.setEnabled(True) - - - def repeat_delay_changed(self, new_delay): - self.config.options["repeat_delay"] = new_delay - self.opts.save_button.setEnabled(True) - - - def save(self): - self.config.save() - self.opts.save_button.setEnabled(False) - - - def date_changed(self): - self.date_selected = self.opts.date_selector.date().toPython() - - - def hf_date_changed(self): - self.date_selected = self.opts.hf_date_selector.date().toPython() - - - def set_date_as_today(self): - self.opts.date_selector.setDate(date.today()) - - - def format_date(self, input_date): - """Convert Python datetime.date object to the same format used in the folder names on Mora.""" - if self.config.options["spec"] == "hf": - formatted_date = input_date.strftime("%Y") - else: - formatted_date = input_date.strftime("%b%d-%Y") - return formatted_date - - - def started(self): - self.queued_checks = 0 - if self.opts.only_button.isChecked() is True or self.config.options["spec"] == "hf": - self.single_check(self.date_selected) - elif self.opts.since_button.isChecked() is True: - self.multiday_check(self.date_selected) - - - def single_check(self, date): - self.opts.start_check_button.setEnabled(False) - formatted_date = self.format_date(date) - # Start main checking function in worker thread - worker = Worker( - check_nmr, - self.config.options, - formatted_date, - self.mora_path, - self.spectrometer_paths, - self.wild_group, - self.prog_bar, - ) - worker.signals.progress.connect(self.update_progress) - worker.signals.result.connect(self.handle_output) - worker.signals.completed.connect(self.check_ended) - self.threadpool.start(worker) - self.queued_checks += 1 - - - def multiday_check(self, initial_date): - end_date = date.today() + timedelta(days=1) - date_to_check = initial_date - while date_to_check != end_date: - self.single_check(date_to_check) - date_to_check += timedelta(days=1) - - - def update_progress(self, prog_state): - self.ui.prog_bar.setValue(prog_state) - - - def handle_output(self, final_output): - self.copied_list = final_output - - - def check_ended(self): - # Set progress to 100% just in case it didn't reach it for whatever reason - self.ui.prog_bar.setMaximum(1) - self.ui.prog_bar.setValue(1) - # Will only not be true if an unknown error occurred, in all cases len will be at least 2 - if len(self.copied_list) > 1: - # At least one spectrum was found - if self.copied_list[1][:5] == "spect": - self.copied_list.pop(0) - self.notify(self.copied_list) - # No spectra were found but check completed successfully - elif self.copied_list[1][:5] == "check": - pass - # Known error occurred - else: - self.copied_list.pop(0) - self.notify(self.copied_list) - else: - # Unknown error occurred, output of check function was returned without appending anything to copied_list - self.copied_list.pop(0) - self.notify(self.copied_list) - # Display output - for entry in self.copied_list: - entry_label = QLabel(entry) - self.ui.display.add_entry(entry_label) - # Move scroll area so that the user sees immediately which spectra were found or what the error was - but only the first time this happens (haven't been able to make this work) - # if entry == self.copied_list[0] and self.copied_list[0][:5] != "check": - # QApplication.processEvents() - # self.display_scroll.ensureWidgetVisible(entry_label, ymargin=50) - # Behaviour for repeat check function. Deactivate for hf spectrometer. See also self.timer in init function - if (self.config.options["repeat_switch"] is True) and (self.config.options["spec"] != "hf"): - self.ui.start_check_button.hide() - self.ui.interrupt_button.show() - self.timer.start(int(self.config.options["repeat_delay"]) * 60 * 1000) - # Enable start check button again, but only if all queued checks have finished - self.queued_checks -= 1 - if self.queued_checks == 0: - self.ui.start_check_button.setEnabled(True) - logging.info("Task complete") - - - def interrupted(self): - self.timer.stop() - self.ui.start_check_button.show() - self.ui.interrupt_button.hide() - - - def notify(self, copied_list): - # If spectra were found, the list will have len > 1, if a known error occurred, the list will have len 1, if an unknown error occurred, the list will be empty - if len(copied_list) > 1: - notification_text = "Spectra have been found!" - self.ui.notification.setText(notification_text + " Ctrl+G to go to. Click to dismiss") - self.ui.notification.setStyleSheet("background-color : limegreen") - else: - self.ui.notification.setStyleSheet("background-color : #cc0010; color : white") - try: - notification_text = "Error: " + copied_list[0] - except: - notification_text = "Unknown error occurred." - self.ui.notification.setText(notification_text + " Click to dismiss") - self.ui.notification.show() - if self.since_button.isChecked() is False and platform.system() != "Darwin": - # Display system notification - doesn't seem to be implemented for macOS currently - # Only if a single date is checked, because with the since function the system notifications get annoying - try: - plyer.notification.notify( - title="Hola!", - message=notification_text, - app_name="Mora the Explorer", - timeout=2, - ) - except: - pass - - - def notification_clicked(self): - self.ui.notification.hide() diff --git a/mora_the_explorer.py b/mora_the_explorer.py index 1d1f8ea..9e27705 100644 --- a/mora_the_explorer.py +++ b/mora_the_explorer.py @@ -28,7 +28,8 @@ from PySide6.QtWidgets import QApplication from config import Config -from main_window import MainWindow +from explorer import Explorer +from ui.main_window import MainWindow def get_rsrc_dir(): @@ -79,18 +80,30 @@ def set_dark_mode(app): ) # Load configuration + logging.info(f"Program resources located at {rsrc_dir}") + logging.info("Loading program settings...") config = Config(rsrc_dir) + logging.info("...complete") - logging.info("Initializing program") + logging.info("Initializing program...") app = QApplication(sys.argv) if darkdetect.isDark() is True and platform.system() == "Windows": set_dark_mode(app) - # Create instance of MainWindow, then show it + # Create instance of MainWindow (front-end), then show it + logging.info("Initializing user interface...") window = MainWindow(rsrc_dir, config) window.show() + logging.info("...complete") + # Create instance of Explorer (back-end) + # Give it our MainWindow so it can read things directly from the UI + logging.info("Initializing explorer...") + explorer = Explorer(window, rsrc_dir, config) + logging.info("...complete") app.setWindowIcon(QIcon(str(rsrc_dir / "explorer.ico"))) + + logging.info("Initialization complete") app.exec() diff --git a/ui/layout.py b/ui/layout.py index 42cb548..8d86d31 100644 --- a/ui/layout.py +++ b/ui/layout.py @@ -11,20 +11,26 @@ from ui.options import OptionsLayout from ui.display import Display + class Layout(QVBoxLayout): - """Main layout, which is a simple vertical stack.""" + """Main layout, which is a simple vertical stack. + + Only the layout, content, and appearance are defined here and in the various custom + objects the layout contains, but not the behaviour (e.g. what happens when something + is clicked or changed.) + """ - def __init__(self, config): + def __init__(self, resource_directory, config): super().__init__() - self.add_elements(config) + self.add_elements(resource_directory, config) - def add_elements(self, config): + def add_elements(self, resource_directory, config): # Title and version info header - self.setWindowTitle("Mora the Explorer") - with open(self.rsrc_dir / "version.txt", encoding="utf-8") as f: + with open(resource_directory / "version.txt", encoding="utf-8") as f: version_info = "".join(f.readlines()[:5]) - version_box = QLabel(version_info).setAlignment(Qt.AlignHCenter) + version_box = QLabel(version_info) + version_box.setAlignment(Qt.AlignHCenter) self.addWidget(version_box) # All the user-configurable options @@ -32,15 +38,19 @@ def add_elements(self, config): self.addLayout(self.opts) # Button to begin check - self.start_check_button = QPushButton("start check now").setStyleSheet("background-color : #b88cce") + self.start_check_button = QPushButton("start check now") + self.start_check_button.setStyleSheet("background-color : #b88cce") self.addWidget(self.start_check_button) # Button to cancel pending repeat check - self.interrupt_button = QPushButton("cancel repeat check").setStyleSheet("background-color : #cc0010; color : white") - self.addWidget(self.interrupt_button).hide() + self.interrupt_button = QPushButton("cancel repeat check") + self.interrupt_button.setStyleSheet("background-color : #cc0010; color : white") + self.interrupt_button.hide() + self.addWidget(self.interrupt_button) # Progress bar for check - self.prog_bar = QProgressBar().setAlignment(Qt.AlignCenter | Qt.AlignVCenter) + self.prog_bar = QProgressBar() + self.prog_bar.setAlignment(Qt.AlignCenter | Qt.AlignVCenter) if platform.system() == "Windows" and platform.release() == "11": # Looks bad (with initial Qt Win11 theme at least) so disable text self.prog_bar.setTextVisible(False) @@ -51,5 +61,6 @@ def add_elements(self, config): self.addWidget(self.display) # Extra notification that spectra have been found, dismissable - self.notification = QPushButton().hide() + self.notification = QPushButton() + self.notification.hide() self.addWidget(self.notification) diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..54cca79 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,273 @@ +import logging +import os +import platform +import subprocess +import sys +from datetime import date, timedelta +from pathlib import Path + +import plyer + +from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject +from PySide6.QtWidgets import ( + QMainWindow, + QLabel, + QWidget, + QMessageBox, +) + +from worker import Worker +from checknmr import check_nmr +from config import Config +from ui.layout import Layout + + +class MainWindow(QMainWindow): + def __init__(self, resource_directory: Path, config: Config): + super().__init__() + + self.rsrc_dir = resource_directory + self.config = config + + #self.mora_path = Path(config.paths[platform.system()]) + #self.update_path = Path(config.paths["update"]) + + # Setup UI + self.setWindowTitle("Mora the Explorer") + self.setup_ui() + + + def setup_ui(self): + """Setup main layout, which is a simple vertical stack.""" + + self.ui = Layout(self.rsrc_dir, self.config) + + # Make options easily accessible, as they are frequently accessed + self.opts = self.ui.opts + + # Add central widget and give it main layout + layout_widget = QWidget() + layout_widget.setLayout(self.ui) + self.setCentralWidget(layout_widget) + + # Trigger function to adapt available options and spectrometers to the user's group + self.adapt_to_group() + # Trigger functions to adapt date selector and naming options to the selected spectrometer + self.adapt_to_spec() + + # Set up window. macos spaces things out more than Windows so give it a bigger window + if platform.system() == "Windows": + self.setMinimumSize(QSize(420, 680)) + else: + self.setMinimumSize(QSize(450, 780)) + + + def notify_spectra(self, copied_list): + """Tell the user that spectra were found, both in the app and with a system toast.""" + # If spectra were found, the list will have len > 1, if a known error occurred, the list will have len 1, if an unknown error occurred, the list will be empty + if len(copied_list) > 1: + notification_text = "Spectra have been found!" + self.ui.notification.setText(notification_text + " Ctrl+G to go to. Click to dismiss") + self.ui.notification.setStyleSheet("background-color : limegreen") + else: + self.ui.notification.setStyleSheet("background-color : #cc0010; color : white") + try: + notification_text = "Error: " + copied_list[0] + except: + notification_text = "Unknown error occurred." + self.ui.notification.setText(notification_text + " Click to dismiss") + self.ui.notification.show() + if self.since_button.isChecked() is False and platform.system() != "Darwin": + # Display system notification - doesn't seem to be implemented for macOS currently + # Only if a single date is checked, because with the since function the system notifications get annoying + try: + plyer.notification.notify( + title="Hola!", + message=notification_text, + app_name="Mora the Explorer", + timeout=2, + ) + except: + pass + + + def notification_clicked(self): + self.ui.notification.hide() + + + def notify_update(self, current, available, changelog): + """Spawn popup to notify user that an update is available, with version info.""" + + update_dialog = QMessageBox(self) + update_dialog.setWindowTitle("Update available") + update_dialog.setText(f"There appears to be a new update available at:\n{self.update_path}") + update_dialog.setInformativeText( + f"Your version is {current}\nThe version on the server is {available}\n{changelog}" + ) + update_dialog.setStandardButtons(QMessageBox.Ignore | QMessageBox.Open) + update_dialog.setDefaultButton(QMessageBox.Ignore) + choice = update_dialog.exec() + if choice == QMessageBox.Open: + if self.update_path.exists() is True: + # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise + os.system(f'start "" "{self.update_path}"') + + + def notify_failed_permissions(self): + """Spawn popup to notify user that accessing the mora server failed.""" + + logging.info("Permission to access server denied") + failed_permission_dialog = QMessageBox(self) + failed_permission_dialog.setWindowTitle("Access to mora server denied") + failed_permission_dialog.setText(""" +You have been denied permission to access the mora server. +Check the connection and your authentication details and try again. +The program will now close.""" + ) + failed_permission_dialog.exec() + sys.exit() + + + def warn_since_function(self): + """Spawn popup dialog that dissuades user from using the "since" function regularly.""" + + since_message = """ +The function to check multiple days at a time should not be used on a regular basis. +Please switch back to a single-day check once your search is finished. +The repeat function is also disabled as long as this option is selected. + """ + QMessageBox.warning(self, "Warning", since_message) + + + def group_changed(self): + """Find out what the new group is, save it to config, make necessary adjustments.""" + if self.opts.AK_buttons.checkedButton().text() == "other": + new_group = self.opts.other_box.currentText() + else: + new_group = self.opts.AK_buttons.checkedButton().text() + self.config.options["group"] = new_group + self.adapt_to_group(new_group) + self.opts.save_button.setEnabled(True) + + + def adapt_to_group(self, group=None): + if group is None: + group = self.config.options["group"] + if group in self.config.groups["other"]: + self.opts.other_box.show() + else: + self.opts.other_box.hide() + # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway + if group == "nmr": + self.opts.inc_init_checkbox.setEnabled(False) + self.opts.nmrcheck_style_checkbox.setEnabled(False) + else: + # Only enable initials checkbox if nmrcheck_style option is not selected, disable otherwise + self.opts.inc_init_checkbox.setEnabled(not self.config.options["nmrcheck_style"]) + self.opts.nmrcheck_style_checkbox.setEnabled(True) + if group == "nmr" or self.config.options["spec"] == "hf": + self.opts.inc_solv_checkbox.setEnabled(False) + else: + self.opts.inc_solv_checkbox.setEnabled(True) + self.refresh_visible_specs() + + + def dest_path_changed(self, new_path): + formatted_path = new_path + # Best way to ensure cross-platform compatibility is to avoid use of backslashes and then let pathlib.Path take care of formatting + if "\\" in formatted_path: + formatted_path = formatted_path.replace("\\", "/") + # If the option "copy path" is used in Windows Explorer and then pasted into the box, the path will be surrounded by quotes, so remove them if there + if formatted_path[0] == '"': + formatted_path = formatted_path.replace('"', "") + self.config.options["dest_path"] = formatted_path + self.opts.open_button.show() + self.opts.save_button.setEnabled(True) + + + def inc_init_switched(self): + self.config.options["inc_init"] = self.opts.inc_init_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + + + def inc_solv_switched(self): + self.config.options["inc_solv"] = self.opts.inc_solv_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + + + def nmrcheck_style_switched(self): + self.config.options["nmrcheck_style"] = self.opts.nmrcheck_style_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + self.opts.inc_init_checkbox.setEnabled(not self.opts.nmrcheck_style_checkbox.isChecked()) + self.adapt_to_spec() + + + def refresh_visible_specs(self): + if self.config.options["group"] in ["stu", "nae", "nmr"]: + self.opts.spec_buttons.buttons["300er"].show() + else: + self.opts.spec_buttons.buttons["300er"].hide() + + + def spec_changed(self): + self.config.options["spec"] = self.opts.spec_buttons.checkedButton().name + self.adapt_to_spec() + self.opts.save_button.setEnabled(True) + + + def adapt_to_spec(self): + if self.config.options["spec"] == "hf": + # Including the solvent in the title is not supported for high-field measurements so disable option + self.opts.inc_solv_checkbox.setEnabled(False) + self.opts.repeat_check_checkbox.setEnabled(False) + self.opts.date_selector.hide() + self.opts.today_button.setEnabled(False) + self.opts.hf_date_selector.show() + else: + if self.config.options["group"] != "nmr" and self.config.options["nmrcheck_style"] is False: + self.opts.inc_solv_checkbox.setEnabled(True) + self.opts.repeat_check_checkbox.setEnabled(True) + self.opts.hf_date_selector.hide() + self.opts.date_selector.show() + self.opts.today_button.setEnabled(True) + + + def repeat_switched(self): + self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() + self.opts.save_button.setEnabled(True) + + + def repeat_delay_changed(self, new_delay): + self.config.options["repeat_delay"] = new_delay + self.opts.save_button.setEnabled(True) + + + def save(self): + self.config.save() + self.opts.save_button.setEnabled(False) + + + def since_function_activated(self): + if self.opts.since_button.isChecked() is True and self.config.options["group"] != "nmr": + self.warn_since_function() + self.opts.repeat_check_checkbox.setEnabled(False) + self.config.options["repeat_switch"] = False + else: + self.opts.repeat_check_checkbox.setEnabled(True) + self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() + + + def set_date_as_today(self): + self.opts.date_selector.setDate(date.today()) + + + def format_date(self, input_date): + """Convert Python datetime.date object to the same format used in the folder names on Mora.""" + if self.config.options["spec"] == "hf": + formatted_date = input_date.strftime("%Y") + else: + formatted_date = input_date.strftime("%b%d-%Y") + return formatted_date + + + diff --git a/ui/options.py b/ui/options.py index 25320e5..03bb8ab 100644 --- a/ui/options.py +++ b/ui/options.py @@ -59,7 +59,7 @@ def __init__(self, parent, selected_spec): self.buttons = {} - for spec in self.specs.keys(): + for spec in self.spec_text.keys(): button = SpecButton(self.spec_text[spec], spec) self.buttons[spec] = button if spec == selected_spec: @@ -73,14 +73,23 @@ class OptionsLayout(QGridLayout): Widgets with more complicated code are defined in custom classes, while simple ones are defined in-line. + + We define the layout, content, and appearance here, but not the behaviour (e.g. what + happens when something is clicked or changed.) """ def __init__(self, config): super().__init__() # Row 0, initials entry box - self.initials_label = QLabel("initials:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.initials_entry = QLineEdit().setMaxLength(3).setText(self.options["initials"]) - self.initials_hint = QLabel("(lowercase!)").setAlignment(Qt.AlignCenter) + self.initials_label = QLabel("initials:") + self.initials_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.initials_entry = QLineEdit() + self.initials_entry.setMaxLength(3) + self.initials_entry.setText(config.options["initials"]) + + self.initials_hint = QLabel("(lowercase!)") + self.initials_hint.setAlignment(Qt.AlignCenter) self.addWidget(self.initials_label, 0, 0) self.addWidget(self.initials_entry, 0, 1) @@ -88,7 +97,9 @@ def __init__(self, config): # Row 1, research group selection buttons - self.group_label = QLabel("group:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.group_label = QLabel("group:") + self.group_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.AK_buttons = AKButtons(self, list(config.groups.keys()), config.options["group"]) self.addWidget(self.group_label, 1, 0) @@ -98,7 +109,8 @@ def __init__(self, config): # Row 2, drop down list for further options that appears only when "other" # radio button is clicked - self.other_box = QComboBox().addItems(config.groups["other"].values()) + self.other_box = QComboBox() + self.other_box.addItems(config.groups["other"].values()) if config.options["group"] in config.groups["other"].values(): self.other_box.setCurrentText(config.options["group"]) else: @@ -108,9 +120,14 @@ def __init__(self, config): # Row 3, destination path entry box - self.dest_path_label = QLabel("save in:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.dest_path_input = QLineEdit().setText(config.options["dest_path"]) - self.open_button = QPushButton("go to").setShortcut("Ctrl+G") + self.dest_path_label = QLabel("save in:") + self.dest_path_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.dest_path_input = QLineEdit() + self.dest_path_input.setText(config.options["dest_path"]) + + self.open_button = QPushButton("go to") + self.open_button.setShortcut("Ctrl+G") # Disable button if path hasn't yet been specified to stop new users thinking it should be used to select a folder if config.options["dest_path"] == "copy full path here": self.open_button.hide() @@ -121,28 +138,42 @@ def __init__(self, config): # Row 4, file naming options - self.include_label = QLabel("include:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.inc_init_checkbox = QCheckBox("initials").setChecked(config.options["inc_init"]) + self.include_label = QLabel("include:") + self.include_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.inc_init_checkbox = QCheckBox("initials") + self.inc_init_checkbox.setChecked(config.options["inc_init"]) if config.options["nmrcheck_style"] is True: self.inc_init_checkbox.setEnabled(False) - self.inc_solv_checkbox = QCheckBox("solvent").setChecked(config.options["inc_solv"]) + + self.inc_solv_checkbox = QCheckBox("solvent") + self.inc_solv_checkbox.setChecked(config.options["inc_solv"]) if config.options["spec"] == "hf": self.inc_solv_checkbox.setEnabled(False) - self.in_filename_label = QLabel("...in filename").setAlignment(Qt.AlignCenter) + + init_solv_layout = QHBoxLayout() + init_solv_layout.addWidget(self.inc_init_checkbox) + init_solv_layout.addWidget(self.inc_solv_checkbox) + + self.in_filename_label = QLabel("...in filename") + self.in_filename_label.setAlignment(Qt.AlignCenter) self.addWidget(self.include_label, 4, 0) - self.addLayout(QHBoxLayout().addWidget(self.inc_init_checkbox).addWidget(self.inc_solv_checkbox), 4, 1) + self.addLayout(init_solv_layout, 4, 1) self.addWidget(self.in_filename_label, 4, 2) # Row 5, option to use NMRCheck-style formatting of folder names - self.nmrcheck_style_checkbox = QCheckBox("use comprehensive (NMRCheck) style").setChecked(config.options["nmrcheck_style"]) + self.nmrcheck_style_checkbox = QCheckBox("use comprehensive (NMRCheck) style") + self.nmrcheck_style_checkbox.setChecked(config.options["nmrcheck_style"]) self.addWidget(self.nmrcheck_style_checkbox, 5, 1, 1, 2) # Row 6, spectrometer selection buttons - self.spec_label = QLabel("search:").setAlignment(Qt.AlignRight | Qt.AlignTop) + self.spec_label = QLabel("search:") + self.spec_label.setAlignment(Qt.AlignRight | Qt.AlignTop) + self.spec_buttons = SpecButtons(self, config.options["spec"]) self.addWidget(self.spec_label, 6, 0) @@ -150,37 +181,66 @@ def __init__(self, config): # Row 7, checkbox to instruct to repeat after chosen interval - self.repeat_label = QLabel("repeat:").setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.repeat_check_checkbox = QCheckBox("check every").setChecked(self.options["repeat_switch"]) - self.repeat_interval = QSpinBox().setMinimum(1).setValue(self.options["repeat_delay"]) + self.repeat_label = QLabel("repeat:") + self.repeat_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + + self.repeat_check_checkbox = QCheckBox("check every") + self.repeat_check_checkbox.setChecked(config.options["repeat_switch"]) + + self.repeat_interval = QSpinBox() + self.repeat_interval.setMinimum(1) + self.repeat_interval.setValue(config.options["repeat_delay"]) + + repeat_layout = QHBoxLayout() + repeat_layout.addWidget(self.repeat_check_checkbox) + repeat_layout.addWidget(self.repeat_interval) + repeat_layout.addWidget(QLabel("mins")) self.addWidget(self.repeat_label, 7, 0) - self.addLayout(QHBoxLayout().addWidget(self.repeat_check_checkbox).addWidget(self.repeat_interval).addWidget(QLabel("mins")), 7, 1) + self.addLayout(repeat_layout, 7, 1) # Row 8, button to save all options for future - self.save_button = QPushButton("save options as defaults for next time").setEnabled(False) + self.save_button = QPushButton("save options as defaults for next time") + self.save_button.setEnabled(False) self.addWidget(self.save_button, 8, 0, 1, 3) # Row 9, date selection tool - self.date_label = QLabel("when?").setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.date_label = QLabel("when?") + self.date_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self.today_button = QPushButton("today") self.addWidget(self.date_label, 9, 0) self.addWidget(self.today_button, 9, 2) # Date selection tool for 300er and 400er - self.only_button = QRadioButton("only").setChecked(True) + self.only_button = QRadioButton("only") + self.only_button.setChecked(True) + self.since_button = QRadioButton("since") - self.date_button_group = QButtonGroup(self).addButton(self.only_button).addButton(self. since_button) - self.date_selector = QDateEdit().setDisplayFormat("dd MMM yyyy").setDate(date.today()) - self.addLayout(QHBoxLayout().addWidget(self.only_button, 0).addWidget(self.since_button, 1).addWidget(self.date_selector, 2), 9, 1) + self.date_button_group = QButtonGroup(self) + self.date_button_group.addButton(self.only_button) + self.date_button_group.addButton(self.since_button) + + self.date_selector = QDateEdit() + self.date_selector.setDisplayFormat("dd MMM yyyy") + self.date_selector.setDate(date.today()) + + date_layout = QHBoxLayout() + date_layout.addWidget(self.only_button, 0) + date_layout.addWidget(self.since_button, 1) + date_layout.addWidget(self.date_selector, 2) + + self.addLayout(date_layout, 9, 1) # Date selection tool for hf (only needs year) - self.hf_date_selector = QDateEdit().setDisplayFormat("yyyy").setDate(date.today()) + self.hf_date_selector = QDateEdit() + self.hf_date_selector.setDisplayFormat("yyyy") + self.hf_date_selector.setDate(date.today()) # Add to same part of layout as the normal date selector - # only one is shown at a time From 4095d9f4ba4c3656fab19430467f97c377d527ce Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Fri, 24 May 2024 23:58:19 +0200 Subject: [PATCH 03/20] Fix attribute bugs, change scroll area so that it automatically scrolls down --- explorer.py | 12 ++++-------- ui/display.py | 27 +++++++++++++++++++++++++-- ui/main_window.py | 2 +- 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/explorer.py b/explorer.py index 7576a93..e6e97e2 100644 --- a/explorer.py +++ b/explorer.py @@ -202,7 +202,7 @@ def single_check(self, date): self.mora_path, self.spectrometer_paths, self.wild_group, - self.prog_bar, + self.ui.prog_bar, ) worker.signals.progress.connect(self.update_progress) worker.signals.result.connect(self.handle_output) @@ -236,26 +236,22 @@ def check_ended(self): # At least one spectrum was found if self.copied_list[1][:5] == "spect": self.copied_list.pop(0) - self.notify(self.copied_list) + self.main_window.notify_spectra(self.copied_list) # No spectra were found but check completed successfully elif self.copied_list[1][:5] == "check": pass # Known error occurred else: self.copied_list.pop(0) - self.notify(self.copied_list) + self.main_window.notify_spectra(self.copied_list) else: # Unknown error occurred, output of check function was returned without appending anything to copied_list self.copied_list.pop(0) - self.notify(self.copied_list) + self.main_window.notify_spectra(self.copied_list) # Display output for entry in self.copied_list: entry_label = QLabel(entry) self.ui.display.add_entry(entry_label) - # Move scroll area so that the user sees immediately which spectra were found or what the error was - but only the first time this happens (haven't been able to make this work) - # if entry == self.copied_list[0] and self.copied_list[0][:5] != "check": - # QApplication.processEvents() - # self.display_scroll.ensureWidgetVisible(entry_label, ymargin=50) # Behaviour for repeat check function. Deactivate for hf spectrometer. See also self.timer in init function if (self.config.options["repeat_switch"] is True) and (self.config.options["spec"] != "hf"): self.ui.start_check_button.hide() diff --git a/ui/display.py b/ui/display.py index cf55154..47a7157 100644 --- a/ui/display.py +++ b/ui/display.py @@ -1,7 +1,10 @@ +from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QScrollArea, QVBoxLayout, QWidget, + QLabel, + QSizePolicy, ) class Display(QScrollArea): @@ -9,10 +12,30 @@ class Display(QScrollArea): def __init__(self): super().__init__() + self.setWidgetResizable(True) self.layout = QVBoxLayout() - self.display = QWidget().setLayout(self.layout) + self.display = QWidget() + self.display.setLayout(self.layout) self.setWidget(self.display) + # Make each label only take up a single line of space rather than spreading + # across the box, so that they stack nicely + self.display.setSizePolicy(QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)) + + # Connect scrollbar so that it scrolls down whenever the list gets longer + self.scrollbar = self.verticalScrollBar() + self.scrollbar.rangeChanged.connect(self.scroll_down) + + # Find out how tall a single line of text is and set step sizes accordingly + # TODO get a better estimate somehow - currently gives about 1.3 times the + # height of a "given destination not found!" label + line_height = QLabel("ABCabcdefghijklmnopJKP!").sizeHint().height() + self.scrollbar.setSingleStep(line_height) + self.scrollbar.setPageStep(3 * line_height) + def add_entry(self, entry): - self.layout.addWidget(entry) + self.layout.addWidget(entry, alignment=Qt.AlignTop) + + def scroll_down(self): + self.scrollbar.setSliderPosition(self.scrollbar.maximum()) diff --git a/ui/main_window.py b/ui/main_window.py index 54cca79..e12a16d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -77,7 +77,7 @@ def notify_spectra(self, copied_list): notification_text = "Unknown error occurred." self.ui.notification.setText(notification_text + " Click to dismiss") self.ui.notification.show() - if self.since_button.isChecked() is False and platform.system() != "Darwin": + if self.opts.since_button.isChecked() is False and platform.system() != "Darwin": # Display system notification - doesn't seem to be implemented for macOS currently # Only if a single date is checked, because with the since function the system notifications get annoying try: From eaa94102c1ca3181d7c69364ce210dae8220c877 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:25:59 +0200 Subject: [PATCH 04/20] Fix AttributeErrors and reformat check_nmr --- checknmr.py | 776 ++++++++++++++++++++++--------------------- config.py | 24 +- explorer.py | 135 ++++---- mora_the_explorer.py | 2 +- ui/display.py | 9 +- ui/layout.py | 13 +- ui/main_window.py | 106 +++--- ui/options.py | 31 +- worker.py | 2 +- 9 files changed, 551 insertions(+), 547 deletions(-) diff --git a/checknmr.py b/checknmr.py index caacc5c..cf3d181 100644 --- a/checknmr.py +++ b/checknmr.py @@ -5,45 +5,324 @@ from pathlib import Path +def get_300er_paths(spec_paths, check_day): + # Start with default, normal folder path + check_path_list = [spec_paths["300er"] / check_day] + # Add archives for previous years other than the current if requested + year = int(check_day[-4:]) + if year != date.today().year: + check_path_list.append( + spec_paths["300er"] / f"{str(year)[-2:]}-av300_{year}" / check_day + ) + # Account for different structure in 2019/start of 2020 + if year <= 2020: + check_path_list.append( + spec_paths["300er"] / f"{str(year)[-2:]}-dpx300_{year}" / check_day + ) + return check_path_list -def identical_spectra(mora_folder, dest_folder): - """Check that two spectra with the same name are actually identical and not e.g. different proton measurements""" - # Read original folder path (i.e. the experiment no) of spectrum - audit_path_mora = mora_folder / "audita.txt" - try: - with open(audit_path_mora, encoding="utf-8") as audit_file_mora: - audit_mora = audit_file_mora.readlines() - exp_mora = audit_mora[4] - except FileNotFoundError: - print( - "no audita.txt file found in " - + mora_folder - + " - presumably measurement was unsuccessful. Spectrum skipped." + +def get_400er_paths(spec_paths, check_day): + check_day_a = "neo400a_" + check_day + check_day_b = "neo400b_" + check_day + check_day_c = "neo400c_" + check_day + # Start with default, normal folder paths + check_path_list = [ + spec_paths["400er"] / check_day_a, + spec_paths["400er"] / check_day_b, + spec_paths["400er"] / check_day_c, + spec_paths["300er"] / check_day, + ] + # Add archives for previous years other than the current if requested + year = int(check_day[-4:]) + if year != date.today().year: + check_path_list.extend( + [ + spec_paths["400er"] / f"{str(year)[-2:]}-neo400a_{year}" / check_day_a, + spec_paths["400er"] / f"{str(year)[-2:]}-neo400b_{year}" / check_day_b, + spec_paths["400er"] / f"{str(year)[-2:]}-neo400c_{year}" / check_day_c, + spec_paths["300er"] / f"{str(year)[-2:]}-av300_{year}" / check_day, + ] + ) + # Account for different structure in 2019/start of 2020 + if year <= 2020: + check_path_list.extend( + [ + spec_paths["400er"] / f"{str(year)[-2:]}-av400_{year}" / check_day, + spec_paths["300er"] / f"{str(year)[-2:]}-dpx300_{year}" / check_day, + ] + ) + return check_path_list + + +def get_hf_paths(spec_paths, check_year, wild_group): + # At the moment there is just one folder per group + # Check folders of all groups when group `nmr` uses the group wildcard + if wild_group is True: + group_folders = [ + x + for x in spec_paths["hf"].parent.iterdir() + if x.is_dir() and (x.name[0] != ".") + ] + check_path_list = [group_folder / check_year for group_folder in group_folders] + else: + check_path_list = spec_paths["hf"] / check_year + return check_path_list + + +def get_check_paths(spec_paths, spectrometer, check_date, wild_group): + """Get list of folders that may contain spectra, appropriate for the spectrometer.""" + + if spectrometer == "300er" or spectrometer == "400er": + if spectrometer == "300er": + check_path_list = get_300er_paths(spec_paths, check_day=check_date) + elif spectrometer == "400er": + check_path_list = get_400er_paths(spec_paths, check_day=check_date) + # Add potential overflow folders for same day (these are generated on mora when two samples + # are submitted with same exp. no.) + for entry in list(check_path_list): + for num in range(2, 20): + check_path_list.append(entry.with_name(entry.name + "_" + str(num))) + + elif spectrometer == "hf": + check_path_list = get_hf_paths( + spec_paths, + check_year=check_date, + wild_group=wild_group, + ) + + # Go over the list to make sure we only bother checking paths that exist + check_path_list = [path for path in check_path_list if path.exists()] + return check_path_list + + +def get_number_spectra(path: Path | None = None, paths: list[Path] | None = None): + """Get the total number of spectra folders in the given directory or directories. + + We can then use the length of it to measure progress. + """ + # Can't remember why it was done this way, I guess the hf check used to be done + # differently to how it is today + if paths is None: + n = sum(1 for x in path.iterdir() if x.is_dir()) + else: + n = 0 + for path in paths: + n += sum(1 for x in path.iterdir() if x.is_dir()) + return n + + +def get_metadata_bruker(folder: Path, mora_path) -> dict: + # Extract title and experiment details from title file in spectrum folder + title_file = folder / "pdata" / "1" / "title" + with open(title_file, encoding="utf-8") as f: + title_contents = f.readlines() + if len(title_contents) < 2: + logging.info("Title file is empty") + title = title_contents[0].split() + details = title_contents[1].split() + + if len(title) >= 3: + group = title[0] + if len(title[1]) <= 3: + initials = title[1] + sample_info = title[2:] + else: + initials = title[1][:3] + sample_info = [title[1][3:]].extend(title[2:]) + elif len(title) >= 2: + # Presumably the initials were not separated correctly from the sample number + group = title[0] + initials = title[1][:3] + try: + sample_info = [title[1][3:]] if title[1][3].isalnum() else [title[1][4:]] + except IndexError: + logging.info("No sample name was given when submitting") + raise IndexError + else: + # Title is not even long enough + logging.info("Title doesn't have enough parts") + raise IndexError + + metadata = { + "server_location": str(folder.relative_to(mora_path)), + "group": group, + "initials": initials, + "sample_info": sample_info, # All remaining parts of title + "experiment": details[0], + "solvent": details[1], + "frequency": None, + } + return metadata + + +def get_metadata_agilent(folder: Path, mora_path) -> dict: + # Find out magnet strength, set to initial false value as flag + magnet_freq = "x" + while magnet_freq == "x": + for subfolder in folder.iterdir(): + text_file = subfolder / "text" + with open(text_file, encoding="utf-8") as f: + spectrum_info = f.readlines() + line_with_freq_split = spectrum_info[3].split(",") + magnet_freq = line_with_freq_split[0] + + metadata = { + "server_location": str(folder.relative_to(mora_path)), + "group": None, + "initials": folder.name[:3], + "sample_info": [folder.name[3:]], # A list so as to match the Bruker version + "experiment": None, + "solvent": None, + "frequency": magnet_freq, + } + return metadata + + +def format_name( + folder, + metadata, + inc_group=False, + inc_init=False, + inc_solv=False, + nmrcheck_style=False, +) -> str: + """Format folder name according to the user's choices.""" + # Format in the style of NMRCheck if requested i.e. using underscores, + # including initials and spectrometer and date and (spectrometer's) exp no + # Note that this is legacy + if nmrcheck_style is True: + name = "_".join( + [ + x + for x in [ + metadata["initials"], + *metadata["sample_info"], + folder.parent.name, + folder.name, + ] + if x is not None + ] ) - # Return True so that the spectrum on mora is treated as identical and not copied - return True - # Do same for existing spectrum in destination - audit_path_dest = dest_folder / "audita.txt" - try: - with open(audit_path_dest, encoding="utf-8") as audit_file_dest: - audit_dest = audit_file_dest.readlines() - exp_dest = audit_dest[4] - # The first spectrum with a given title is always copied, so it is possible that it doesn't - # have an audit file - except FileNotFoundError: - exp_dest = None - # Compare experiment nos - if exp_mora == exp_dest: - return True else: - return False + # Include experiment type e.g. proton + name = "-".join( + [ + x + for x in [ + *metadata["sample_info"], + metadata["experiment"], + ] + if x is not None + ] + ) + # Apply user choices + if inc_init is True and metadata["initials"] is not None: + name = metadata["initials"] + "-" + name + if inc_group is True and metadata["group"] is not None: + name = metadata["group"] + "-" + name + if inc_solv is True and metadata["solvent"] is not None: + name = name + "-" + metadata["solvent"] + # Add frequency info if available + if metadata["frequency"] is not None: + name = name + "_" + metadata["frequency"] + return name + + +def format_name_klaus(folder, metadata) -> str: + """Format folder name in Klaus' desired fashion.""" + # First do normally but with everything included + name = format_name(folder, metadata, inc_group=True, inc_init=True, inc_solv=True) + # Add location details in front + name = metadata["server_location"].replace("/", "_").replace("\\", "_") + "_" + name + return name + + +def compare_spectra(mora_folder, dest_folder) -> int: + """Check that two spectra with the same name are actually identical and not e.g. different proton measurements. + + In the event that the spectra are the same, a check is made to see if everything has + been copied; if not, `incomplete` is returned as `True`. + Result is a tuple with the result in the form `(identical, incomplete)`. + """ + + comparison = filecmp.dircmp(mora_folder, dest_folder) + if len(comparison.right_only) > 0: + identical, incomplete = False, False + elif len(comparison.left_only) == 0: + identical, incomplete = True, False + elif len(comparison.left_only) > 0: + identical, incomplete = True, True + + return identical, incomplete + + +def copy_folder(src: Path, target: Path): + """Copy a spectra folder over to the target if it isn't already there. + + Note that `target` should be the target path of the copied folder, not a directory + to copy it into. + + Should the target already exist, it is assessed whether the folder at the target is + indeed the same spectrum/spectra or if it just has the same name. + + If the latter is the case, it is copied with a number appended to the name. + + Partial copies are also checked for and recopied if they are incomplete. + """ + + output = [] + + # Check that spectrum hasn't been copied before + identical_spectrum_found = False + incomplete_copy = False + if target.exists() is True: + logging.info("Spectrum with this name exists in destination") + # Check that the spectra are actually identical and not e.g. different + # proton measurements + # If confirmed to be unique spectra, need to extend spectrum name with + # -2, -3 etc. to avoid conflict with spectra already in dest + identical_spectrum_found, incomplete_copy = compare_spectra(src, target) + num = 1 + while target.exists() is True and identical_spectrum_found is False: + num += 1 + target = target.with_name(target.name + "-" + str(num)) + identical_spectrum_found, incomplete_copy = compare_spectra(src, target) + + # Try and fix only partially copied spectra + if identical_spectrum_found is True and incomplete_copy is True: + logging.info("The existing copy is only partial") + for subfolder in src.iterdir(): + if not (target / subfolder.name).exists(): + try: + shutil.copytree(subfolder, target / subfolder.name) + except PermissionError: + output.append( + "you do not have permission to write to the given folder" + ) + return output + text_to_add = "new files found for: " + target.name + output.append(text_to_add) + + elif identical_spectrum_found is False: + try: + shutil.copytree(src, target) + except PermissionError: + output.append("you do not have permission to write to the given folder") + logging.info("No write permission for destination") + return output + text_to_add = "spectrum found: " + target.name + logging.info(f"Spectrum saved to {target.name}") + output.append(text_to_add) + + return output def check_nmr( fed_options, - check_day, mora_path, spec_paths, + check_date, wild_group, prog_bar, progress_callback, @@ -51,377 +330,122 @@ def check_nmr( """Main checking function for Mora the Explorer.""" # Some initial setup that is the same for all spectrometers - logging.info(f"Beginning check of {check_day} with the options:") + logging.info(f"Beginning check of {check_date} with the options:") logging.info(fed_options) # Initialize list that will be returned as output output_list = ["no new spectra"] # Confirm destination directory exists if Path(fed_options["dest_path"]).exists() is False: + logging.info("Given destination folder not found!") output_list.append("given destination folder not found!") - logging.info("given destination folder not found!") return output_list # Confirm mora can be reached if mora_path.exists() is False: + logging.info("The mora server could not be reached!") output_list.append("the mora server could not be reached!") - logging.info("the mora server could not be reached!") return output_list spectrometer = fed_options["spec"] - # Format paths of spectrometer folders, different for each spectrometer - if spectrometer == "300er" or spectrometer == "400er": - if spectrometer == "300er": - # Start with default, normal folder path - check_path_list = [spec_paths[spectrometer] / check_day] - # Add archives for previous years other than the current if requested - year = int(check_day[-4:]) - if year != date.today().year: - check_path_list.append( - spec_paths[spectrometer] / f"{str(year)[-2:]}-av300_{year}" / check_day - ) - # Account for different structure in 2019/start of 2020 - if year <= 2020: - check_path_list.append( - spec_paths[spectrometer] / f"{str(year)[-2:]}-dpx300_{year}" / check_day - ) + # Directory discovery + check_path_list = get_check_paths(spec_paths, spectrometer, check_date, wild_group) - elif spectrometer == "400er": - check_day_a = "neo400a_" + check_day - check_day_b = "neo400b_" + check_day - check_day_c = "neo400c_" + check_day - # Start with default, normal folder paths - check_path_list = [ - spec_paths[spectrometer] / check_day_a, - spec_paths[spectrometer] / check_day_b, - spec_paths[spectrometer] / check_day_c, - spec_paths["300er"] / check_day, - ] - # Add archives for previous years other than the current if requested - year = int(check_day[-4:]) - if year != date.today().year: - check_path_list.extend([ - spec_paths[spectrometer] / f"{str(year)[-2:]}-neo400a_{year}" / check_day_a, - spec_paths[spectrometer] / f"{str(year)[-2:]}-neo400b_{year}" / check_day_b, - spec_paths[spectrometer] / f"{str(year)[-2:]}-neo400c_{year}" / check_day_c, - spec_paths["300er"] / f"{str(year)[-2:]}-av300_{year}" / check_day, - ]) - # Account for different structure in 2019/start of 2020 - if year <= 2020: - check_path_list.extend([ - spec_paths[spectrometer] / f"{str(year)[-2:]}-av400_{year}" / check_day, - spec_paths["300er"] / f"{str(year)[-2:]}-dpx300_{year}" / check_day, - ]) - - # This stuff applies to paths on both the 300er and 400er - # Add potential overflow folders for same day (these are generated on mora when two samples - # are submitted with same exp. no.) - for entry in list(check_path_list): - for num in range(2, 20): - check_path_list.append(entry.with_name(entry.name + "_" + str(num))) - # Go over the list to make sure we only bother checking paths that exist - check_path_list = [path for path in check_path_list if path.exists()] - # Give message if no folders for the given date exist yet - if len(check_path_list) == 0: - output_list.append("no folders exist for this date!") - return output_list - logging.info("The following paths will be checked:") + # Give message if no directories for the given date exist yet + if len(check_path_list) == 0: + logging.info("No folders exist for this date!") + output_list.append("no folders exist for this date!") + return output_list + else: + logging.info("The following paths will be checked for spectra:") logging.info(check_path_list) - elif spectrometer == "hf": - # Code to check folders of all groups when the nmr group is chosen and the wild group - # option is invoked - if wild_group is True: - check_list = [] - # Slightly complicated bit of code here but it just goes through all folders in - # 500-600er folder - logging.info("The following paths will be checked:") - logging.info(spec_paths[spectrometer].parent.iterdir()) - for group_folder in spec_paths[spectrometer].parent.iterdir(): - if group_folder.is_dir() and (group_folder.name[0] != "."): - try: - for spectrum_folder in list((group_folder / check_day).iterdir()): - check_list.append(spectrum_folder) - except FileNotFoundError: - logging.info(f"No spectra in {group_folder}") - continue - # Normal behaviour for all other users + # Initialize progress bar + prog_state = 0 + n_spectra = get_number_spectra(paths=check_path_list) + logging.info(f"Total spectra in these paths: {n_spectra}") + try: + prog_bar.setMaximum(n_spectra) + if progress_callback is not None: + progress_callback.emit(0) # Reset bar to 0 else: - logging.info("The following paths will be checked:") - logging.info(spec_paths[spectrometer] / check_day) - # Try to get list of spectrum folders in folder for requested spectrometer, group - # and date - # Display message to user if it doesn't exist yet - try: - check_list = list((spec_paths[spectrometer] / check_day).iterdir()) - except FileNotFoundError: - output_list.append("no folder exists for this date!") - logging.info("no folder exists for this date!") - return output_list - - # Now we have a list of directories to check, start the actual checking process - # Needs to be slightly different depending on the spectrometer, as the directory - # structures are different - if spectrometer == "300er" or spectrometer == "400er": - # Initialize progress bar - try: - if spectrometer == "300er": - prog_bar.setMaximum(100) - elif spectrometer == "400er": - prog_bar.setMaximum(100 * len(check_path_list)) - except: - # This stops python from hanging when the program is closed - exit() - prog_state = 0 - progress_callback.emit(prog_state) - # Loop through each folder in check_path_list (usually only one for 300er, several for - # 400er as separate ones are generated for each spectrometer) - for check_path in check_path_list: + print(f"Total spectra to check: {n_spectra}") + except Exception: + # This stops Python from hanging when the program is closed, no idea why + exit() + + # Now we have a list of directories to check, start the actual search process + # Needs to be slightly different depending on the spectrometer, as the contents of + # the folder for a spectrum is manufacturer-dependent + + logging.info("The following spectra were checked for potential matches:") + # Loop through each folder in check_path_list + for check_path in check_path_list: + # Iterate through spectra + for folder in check_path.iterdir(): + logging.info(folder) + + # Extract title and experiment details from title file in spectrum folder try: - check_list = list(check_path.iterdir()) + if spectrometer == "300er" or spectrometer == "400er": + metadata = get_metadata_bruker(folder, mora_path) + # Save a step by not extracting metadata unless initials in folder name + # as folders are given the name of the sample on 500 and 600 MHz specs + elif fed_options["initials"] in folder.name: + metadata = get_metadata_agilent(folder, mora_path) + else: + continue except FileNotFoundError: + output_list.append(f"No metadata could be found for {folder}!") + logging.info("No metadata found") + continue + except IndexError: # Due to title not being long enough continue - # Loop through list of spectra in spectrometer folder - logging.info("The following spectra were checked for potential matches:") - for folder in check_list: - logging.info(folder) - # Extract title and experiment details from title file in spectrum folder - title_file_path = folder / "pdata" / "1" / "title" - try: - with open(title_file_path, encoding="utf-8") as title_file: - title_contents = title_file.readlines() - title = title_contents[0] - details = title_contents[1] - except FileNotFoundError: - output_list.append(f"{folder} had no title file!") - logging.info("No title file found") - continue - split_title = title.split() - split_details = details.split() - logging.info(" " + str(split_title)) - logging.info(" " + str(split_details)) - # Look for search string in extracted title, then copy matching spectra - # Confirm that the title is even long enough to avoid IndexErrors - if len(split_title) < 2: - logging.info("Title doesn't have enough parts") - continue - # Add nmr to front of spectrum title so that the group initials get matched to - # the "initials" provided by the user, allowing Klaus to download all spectra - # from a specific group - if (fed_options["group"] == "nmr") and (split_title[0] != "nmr"): - split_title.insert(0, "nmr") - # Check if spectrum is a match for search term, including the wild option for the - # nmr group - if split_title[1][0:3] == fed_options["initials"] or ( - (wild_group is True) and (split_title[2][0:3] == fed_options["initials"]) - ): - # Or alternatively, just check if any of the title components match the initials - # (normally to be avoided to prevent false positives) - # if fed_options["initials"] in split_title: - logging.info("Spectrum matches search query!") - # Formatting options specifically for nmr group, include everything - even - # date and spec via parent folder name - if fed_options["group"] == "nmr": - new_folder_name = ( - ("-".join(split_title[1:])) - + "-" - + ("-".join(split_details[:2])) - + "_" - + check_path.name - + "_" - + folder.name - ) - # Otherwise format spectrum name according to user's choices - else: - # Format in the style of NMRCheck if requested i.e. using underscores, - # including initials and spectrometer and date and exp no - if fed_options["nmrcheck_style"] is True: - if len(split_title) > 2: - hyphenated_title = ( - "_".join(split_title[1:]) - + "_" - + check_path.name - + "_" - + folder.name - ) - else: - hyphenated_title = ( - "_".join(split_title) - + "_" - + check_path.name - + "_" - + folder.name - ) - # Length checks above and below are to account for the possibility that - # the user might have forgotten to separate with spaces - # Principle applied is that if the information being dropped isn't 100% - # definitely what we think it is (i.e. the group name), play it safe and - # don't drop it - # Now the formatting for most cases (NMRCheck style is legacy) - elif len(split_title) > 2: - if fed_options["inc_init"] is True: - hyphenated_title = "-".join(split_title[1:]) - else: - hyphenated_title = "-".join(split_title[2:]) - else: - hyphenated_title = "-".join(split_title) - # Append experiment type e.g. proton to end of name, and solvent if - # requested - if fed_options["inc_solv"] is True: - new_folder_name = ( - hyphenated_title + "-" + split_details[0] + "-" + split_details[1] - ) - else: - new_folder_name = hyphenated_title + "-" + split_details[0] - new_folder_path = Path(fed_options["dest_path"]) / new_folder_name - # Check that spectrum hasn't been copied before - if new_folder_path.exists() is True: - logging.info("Spectrum with this name already exists in destination") - # Check that the spectra are actually identical and not e.g. different - # proton measurements - # If confirmed to be unique spectra, need to extend spectrum name with - # -2, -3 etc. to avoid conflict with spectra already in dest - identical_spectrum_found = False - if identical_spectra(folder, new_folder_path) is False: - new_folder_name = new_folder_path.name + "-2" - new_folder_path = new_folder_path.parent / new_folder_name - # While loop that will eventually settle on a new unique name - while new_folder_path.exists() is True: - # Do whole procedure again as long as name has a match in the - # destination - if identical_spectra(folder, new_folder_path) is True: - identical_spectrum_found = True - new_folder_name = ( - new_folder_path.name[:-2] - + "-" - + str(int(new_folder_path.name[-1]) + 1) - ) - new_folder_path = new_folder_path.parent / new_folder_name - if identical_spectrum_found is not True: - logging.info("but the spectrum itself has not been copied before.") - logging.info(f"Copying with the new name: {new_folder_path.stem}") - try: - shutil.copytree(folder, new_folder_path) - except PermissionError: - output_list.append( - "you do not have permission to write to the given folder" - ) - logging.info("No write permission for destination") - return output_list - text_to_add = "spectrum found: " + new_folder_name - output_list.append(text_to_add) - # Otherwise there is no existing spectrum in the destination so - # straightforward copy - else: - try: - shutil.copytree(folder, new_folder_path) - except PermissionError: - output_list.append( - "you do not have permission to write to the given folder" - ) - return output_list - text_to_add = "spectrum found: " + new_folder_name - logging.info(f"Spectrum saved to {new_folder_path}") - output_list.append(text_to_add) - # Update progress bar - prog_state += 100 / len(check_list) - progress_callback.emit(round(prog_state)) - elif spectrometer == "hf": - # Initialize progress bar - max_progress = len(check_list) - prog_bar.setMaximum(max_progress) - prog_state = 0 - progress_callback.emit(prog_state) - # Look for spectra - logging.info("The following spectra were checked for potential matches:") - for folder in check_list: - logging.info(folder) - # Check for initials at start of folder name, as folders are given the name of - # the sample on 500 and 600 MHz spectrometers - if folder.name[:3] == fed_options["initials"]: + # Look for search string + hit = False + if metadata["initials"] == fed_options["initials"]: + hit = True + # Klaus can give a group initialism as the initials and download all spectra + # from a group + elif ( + fed_options["group"] == "nmr" + and metadata["group"] == fed_options["initials"] + ): + hit = True + + if not hit: + continue + else: logging.info("Spectrum matches search query!") - # Find out magnet strength, set to initial false value as flag - magnet_freq = "x" - contents_list = list(folder.iterdir()) - while magnet_freq == "x": - for cont_folder in contents_list: - text_file_path = cont_folder / "text" - if text_file_path.exists() is True: - with open(text_file_path, encoding="utf-8") as spectrum_text: - spectrum_info = spectrum_text.readlines() - line_with_freq_split = spectrum_info[3].split(",") - magnet_freq = line_with_freq_split[0] - if fed_options["group"] == "nmr": - new_folder_name = ( - folder.parent.parent.name - + "_" - + folder.parent.name - + "_" - + folder.name - + "_" - + magnet_freq - ) - elif fed_options["nmrcheck_style"] is True: - new_folder_name = ( - fed_options["initials"] + "_" + folder.name[3:] + "_" + magnet_freq - ) - elif fed_options["inc_init"] is True: - new_folder_name = ( - fed_options["initials"] + "-" + folder.name[3:] + "_" + magnet_freq - ) - else: - new_folder_name = folder.name[3:] + "_" + magnet_freq - new_folder_path = Path(fed_options["dest_path"]) / new_folder_name - # Check that spectrum hasn't been copied before - # Begin by setting check number to >0 so that if nothing has ever been copied - # the spectrum gets copied - new_spectra = True - partial_copy = False - if new_folder_path.exists() is True: - logging.info("Spectrum already exists in destination") - comparison = filecmp.dircmp(folder, new_folder_path) - if len(comparison.left_only) == 0: - new_spectra = False - elif len(comparison.left_only) > 0: - partial_copy = True - logging.info("but only a partial copy") - # Only copy if new spectra in folder - if new_spectra is True: - if partial_copy is True: - for cont_folder in contents_list: - new_spectrum_path = new_folder_path / cont_folder.name - if new_spectrum_path.exists() is False: - try: - shutil.copytree(cont_folder, new_spectrum_path) - except PermissionError: - output_list.append( - "you do not have permission to write to the given folder" - ) - return output_list - text_to_add = "spectra found: " + new_folder_name - output_list.append(text_to_add) - else: - try: - shutil.copytree(folder, new_folder_path) - except PermissionError: - output_list.append( - "you do not have permission to write to the given folder" - ) - return output_list - text_to_add = "spectra found: " + new_folder_name - output_list.append(text_to_add) - logging.info(f"Spectrum saved to {new_folder_path}") - # Make progress bar move noticeably while checking/copying users' spectra - # so it doesn't look like it has crashed - max_progress += 20 - prog_bar.setMaximum(max_progress) - prog_state += 20 - progress_callback.emit(prog_state) - # Update progress bar + + # Formatting + if fed_options["group"] == "nmr": + new_folder_name = format_name_klaus( + folder, + metadata, + ) + else: + new_folder_name = format_name( + folder, + metadata, + inc_init=fed_options["inc_init"], + inc_solv=fed_options["inc_solv"], + nmrcheck_style=fed_options["nmrcheck_style"], + ) + + # Copy, add output messages to main output list + output_list.extend( + copy_folder(folder, fed_options["dest_path"] / new_folder_name) + ) + + # Update progress bar if a callback object has been given prog_state += 1 - progress_callback.emit(prog_state) + if progress_callback is not None: + progress_callback.emit(round(prog_state)) + else: + print(f"Spectra checked: {prog_state}") now = datetime.now().strftime("%H:%M:%S") - completed_statement = f"check of {check_day} completed at " + now + completed_statement = f"check of {check_date} completed at " + now output_list.append(completed_statement) logging.info(completed_statement) return output_list diff --git a/config.py b/config.py index 8d56c66..9384db3 100644 --- a/config.py +++ b/config.py @@ -10,20 +10,24 @@ class Config: """Returns a container for the combined app and user configuration data.""" + def __init__(self, resource_directory): self.rsrc_dir = resource_directory # Load app config from config.toml with open(self.rsrc_dir / "config.toml", "rb") as f: self.app_config = tomllib.load(f) - logging.info(f"App configuration loaded from: {self.rsrc_dir / "config.toml"}") + logging.info( + f"App configuration loaded from: {self.rsrc_dir / "config.toml"}" + ) # Load user config from config.toml in user's config directory # Make one if it doesn't exist yet # User options used to be stored in config.json pre v1.7, so also check for that # platformdirs automatically saves the config file in the place appropriate to the os self.user_config_file = ( - Path(platformdirs.user_config_dir("mora_the_explorer", roaming=True)) / "config.toml" + Path(platformdirs.user_config_dir("mora_the_explorer", roaming=True)) + / "config.toml" ) user_config_json = self.user_config_file.with_name("config.json") if self.user_config_file.exists() is True: @@ -38,7 +42,7 @@ def __init__(self, resource_directory): self.user_config = { "options": old_options, "paths": {"Linux": "overwrite with default mount point"}, - } + } with open(self.user_config_file, "wb") as f: tomli_w.dump(self.user_config, f) user_config_json.unlink() @@ -54,27 +58,31 @@ def __init__(self, resource_directory): with open(self.user_config_file, "wb") as f: tomli_w.dump(self.user_config, f) logging.info("New default user config created with default options") - + # Overwrite any app config settings that have been specified in the user config # Only works properly for a couple of nesting levels for table in self.user_config.keys(): if table in self.app_config: for k, v in self.user_config[table].items(): - logging.info(f"Updating default app_config option `[{table}] {k} = {repr(self.app_config[table][k])}` with value {repr(v)} from user's config.toml") + logging.info( + f"Updating default app_config option `[{table}] {k} = {repr(self.app_config[table][k])}` with value {repr(v)} from user's config.toml" + ) # Make sure tables within tables are only updated, not overwritten if isinstance(v, dict): self.app_config[table][k].update(v) else: self.app_config[table][k] = v - + # Expose some parts of user and app configs at top level self.options = self.user_config["options"] self.paths = self.app_config["paths"] self.groups = self.app_config["groups"] - + def save(self): # Save user config to file with open(self.user_config_file, "wb") as f: tomli_w.dump(self.user_config, f) - logging.info(f"The following user options were saved to {self.user_config_file}:") + logging.info( + f"The following user options were saved to {self.user_config_file}:" + ) logging.info(self.user_config) diff --git a/explorer.py b/explorer.py index e6e97e2..07800be 100644 --- a/explorer.py +++ b/explorer.py @@ -2,19 +2,11 @@ import os import platform import subprocess -import sys from datetime import date, timedelta from pathlib import Path -import plyer - -from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject -from PySide6.QtWidgets import ( - QMainWindow, - QLabel, - QWidget, - QMessageBox, -) +from PySide6.QtCore import QTimer, QThreadPool +from PySide6.QtWidgets import QLabel from worker import Worker from checknmr import check_nmr @@ -23,8 +15,9 @@ class Explorer: - def __init__(self, main_window: MainWindow, resource_directory: Path, config: Config): - + def __init__( + self, main_window: MainWindow, resource_directory: Path, config: Config + ): self.main_window = main_window self.rsrc_dir = resource_directory self.config = config @@ -32,7 +25,7 @@ def __init__(self, main_window: MainWindow, resource_directory: Path, config: Co # Make it easier to access elements of the UI self.ui = self.main_window.ui self.opts = self.main_window.ui.opts - + # Set up multithreading; MaxThreadCount limited to 1 as checks don't run properly if multiple run concurrently self.threadpool = QThreadPool() self.threadpool.setMaxThreadCount(1) @@ -64,7 +57,6 @@ def __init__(self, main_window: MainWindow, resource_directory: Path, config: Co self.connect_signals() - def update_check(self, update_path): """Check for updates at location specified.""" @@ -80,18 +72,19 @@ def update_check(self, update_path): newest_version_no = version_file_info[2].rstrip() changelog = "".join(version_file_info[5:]).rstrip() if version_no != newest_version_no: - self.notify_update(version_no, newest_version_no, changelog) + self.main_window.notify_update( + version_no, newest_version_no, changelog + ) except PermissionError: self.main_window.notify_failed_permissions() - def connect_signals(self): """Connect all the signals from the UI elements to the various handlers. - + As much as possible, when the effects are only relevant for the UI, the handlers are defined as methods of MainWindow, while those that are relevant for the backend logic and searching are defined here as methods of Explorer. - + To allow a reasonable overview, however, all signals are connected here. """ # Remember that self.ui = self.main_window.ui @@ -99,16 +92,30 @@ def connect_signals(self): self.opts.initials_entry.textChanged.connect(self.initials_changed) self.opts.AK_buttons.buttonClicked.connect(self.group_changed) self.opts.other_box.currentTextChanged.connect(self.group_changed) - self.opts.dest_path_input.textChanged.connect(self.main_window.dest_path_changed) + self.opts.dest_path_input.textChanged.connect( + self.main_window.dest_path_changed + ) self.opts.open_button.clicked.connect(self.open_path) - self.opts.inc_init_checkbox.stateChanged.connect(self.main_window.inc_init_switched) - self.opts.inc_solv_checkbox.stateChanged.connect(self.main_window.inc_solv_switched) - self.opts.nmrcheck_style_checkbox.stateChanged.connect(self.main_window.nmrcheck_style_switched) + self.opts.inc_init_checkbox.stateChanged.connect( + self.main_window.inc_init_switched + ) + self.opts.inc_solv_checkbox.stateChanged.connect( + self.main_window.inc_solv_switched + ) + self.opts.nmrcheck_style_checkbox.stateChanged.connect( + self.main_window.nmrcheck_style_switched + ) self.opts.spec_buttons.buttonClicked.connect(self.main_window.spec_changed) - self.opts.repeat_check_checkbox.stateChanged.connect(self.main_window.repeat_switched) - self.opts.repeat_interval.valueChanged.connect(self.main_window.repeat_delay_changed) + self.opts.repeat_check_checkbox.stateChanged.connect( + self.main_window.repeat_switched + ) + self.opts.repeat_interval.valueChanged.connect( + self.main_window.repeat_delay_changed + ) self.opts.save_button.clicked.connect(self.main_window.save) - self.opts.since_button.toggled.connect(self.main_window.since_function_activated) + self.opts.since_button.toggled.connect( + self.main_window.since_function_activated + ) self.opts.date_selector.dateChanged.connect(self.date_changed) self.opts.today_button.clicked.connect(self.main_window.set_date_as_today) self.opts.hf_date_selector.dateChanged.connect(self.hf_date_changed) @@ -116,45 +123,47 @@ def connect_signals(self): self.ui.interrupt_button.clicked.connect(self.interrupted) self.ui.notification.clicked.connect(self.main_window.notification_clicked) - def initials_changed(self, new_initials): - # Allow initials entry to take five characters total if the nmr group is chosen - # and the wild group option is invoked - if len(new_initials) > 0: - if (new_initials.split()[0] == "*") and (self.config.options["group"] == "nmr"): - self.opts.initials_entry.setMaxLength(5) - if len(new_initials.split()) > 1: - self.wild_group = True - self.config.options["initials"] = new_initials.split()[1] - else: - self.config.options["initials"] = "" - else: - self.config.options["initials"] = new_initials - else: + """Make necessary adjustments after the user types something in `initials`. + + The main effect is simply that the new initials should be saved in the config + and the save button should be activated. + + The `nmr` group has the ability to use a wildcard `*` followed by a space in the + initials box to indicate that all groups should be matched for the following + user's initials e.g. `* mjm` will search for spectra of MJM everywhere, not just + in the Studer group's folders. + As a result the maximum length of the initials entry needs to be increased when + the wildcard is used. + """ + if len(new_initials) == 0: + # Just reset the max length self.opts.initials_entry.setMaxLength(3) + else: + if (new_initials[0] == "*") and (self.config.options["group"] == "nmr"): + self.opts.initials_entry.setMaxLength(5) + self.wild_group = True + try: + new_initials = new_initials.split()[1] + except IndexError: + new_initials = "" self.config.options["initials"] = new_initials - self.opts.save_button.setEnabled(True) - + self.opts.save_button.setEnabled(True) def group_changed(self): self.main_window.group_changed() self.adapt_paths_to_group(self.config.options["group"]) - def adapt_paths_to_group(self, group): if group in self.config.groups["other"]: - path_hf = ( - self.mora_path / "500-600er" / self.config.groups["other"][group] - ) + path_hf = self.mora_path / "500-600er" / self.config.groups["other"][group] else: path_hf = self.mora_path / "500-600er" / self.config.groups[group] self.spectrometer_paths["hf"] = path_hf - # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway if group != "nmr": # Make sure wild option is turned off for normal users self.wild_group = False - def open_path(self): if Path(self.config.options["dest_path"]).exists() is True: if platform.system() == "Windows": @@ -165,15 +174,12 @@ def open_path(self): elif platform.system() == "Linux": subprocess.Popen(["xdg-open", self.config.options["dest_path"]]) - def date_changed(self): self.date_selected = self.opts.date_selector.date().toPython() - def hf_date_changed(self): self.date_selected = self.opts.hf_date_selector.date().toPython() - def format_date(self, input_date): """Convert Python datetime.date object to the same format used in the folder names on Mora.""" if self.config.options["spec"] == "hf": @@ -182,27 +188,28 @@ def format_date(self, input_date): formatted_date = input_date.strftime("%b%d-%Y") return formatted_date - def started(self): self.queued_checks = 0 - if self.opts.only_button.isChecked() is True or self.config.options["spec"] == "hf": + if ( + self.opts.only_button.isChecked() is True + or self.config.options["spec"] == "hf" + ): self.single_check(self.date_selected) elif self.opts.since_button.isChecked() is True: self.multiday_check(self.date_selected) - def single_check(self, date): self.ui.start_check_button.setEnabled(False) formatted_date = self.format_date(date) # Start main checking function in worker thread worker = Worker( check_nmr, - self.config.options, - formatted_date, - self.mora_path, - self.spectrometer_paths, - self.wild_group, - self.ui.prog_bar, + fed_options=self.config.options, + mora_path=self.mora_path, + spec_paths=self.spectrometer_paths, + check_date=formatted_date, + wild_group=self.wild_group, + prog_bar=self.ui.prog_bar, ) worker.signals.progress.connect(self.update_progress) worker.signals.result.connect(self.handle_output) @@ -210,7 +217,6 @@ def single_check(self, date): self.threadpool.start(worker) self.queued_checks += 1 - def multiday_check(self, initial_date): end_date = date.today() + timedelta(days=1) date_to_check = initial_date @@ -218,15 +224,12 @@ def multiday_check(self, initial_date): self.single_check(date_to_check) date_to_check += timedelta(days=1) - def update_progress(self, prog_state): self.ui.prog_bar.setValue(prog_state) - def handle_output(self, final_output): self.copied_list = final_output - def check_ended(self): # Set progress to 100% just in case it didn't reach it for whatever reason self.ui.prog_bar.setMaximum(1) @@ -253,7 +256,9 @@ def check_ended(self): entry_label = QLabel(entry) self.ui.display.add_entry(entry_label) # Behaviour for repeat check function. Deactivate for hf spectrometer. See also self.timer in init function - if (self.config.options["repeat_switch"] is True) and (self.config.options["spec"] != "hf"): + if (self.config.options["repeat_switch"] is True) and ( + self.config.options["spec"] != "hf" + ): self.ui.start_check_button.hide() self.ui.interrupt_button.show() self.timer.start(int(self.config.options["repeat_delay"]) * 60 * 1000) @@ -263,9 +268,7 @@ def check_ended(self): self.ui.start_check_button.setEnabled(True) logging.info("Task complete") - def interrupted(self): self.timer.stop() self.ui.start_check_button.show() self.ui.interrupt_button.hide() - diff --git a/mora_the_explorer.py b/mora_the_explorer.py index 9e27705..addf0e8 100644 --- a/mora_the_explorer.py +++ b/mora_the_explorer.py @@ -44,7 +44,7 @@ def get_rsrc_dir(): def set_dark_mode(app): """Manually set a dark mode in Windows. - + Make dark mode less black than default because Windows dark mode looks bad.""" dark_palette = QPalette() dark_palette.setColor(QPalette.Window, QColor(53, 53, 53)) diff --git a/ui/display.py b/ui/display.py index 47a7157..07be325 100644 --- a/ui/display.py +++ b/ui/display.py @@ -1,11 +1,6 @@ from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QScrollArea, - QVBoxLayout, - QWidget, - QLabel, - QSizePolicy, -) +from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget, QLabel, QSizePolicy + class Display(QScrollArea): """Box to display output of check function (list of copied spectra)""" diff --git a/ui/layout.py b/ui/layout.py index 8d86d31..54e2899 100644 --- a/ui/layout.py +++ b/ui/layout.py @@ -1,12 +1,8 @@ import platform -from PySide6.QtCore import QTimer, Qt -from PySide6.QtWidgets import ( - QPushButton, - QLabel, - QProgressBar, - QVBoxLayout, -) +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QLabel, QProgressBar, QVBoxLayout + from ui.options import OptionsLayout from ui.display import Display @@ -14,7 +10,7 @@ class Layout(QVBoxLayout): """Main layout, which is a simple vertical stack. - + Only the layout, content, and appearance are defined here and in the various custom objects the layout contains, but not the behaviour (e.g. what happens when something is clicked or changed.) @@ -24,7 +20,6 @@ def __init__(self, resource_directory, config): super().__init__() self.add_elements(resource_directory, config) - def add_elements(self, resource_directory, config): # Title and version info header with open(resource_directory / "version.txt", encoding="utf-8") as f: diff --git a/ui/main_window.py b/ui/main_window.py index e12a16d..1d74d22 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,23 +1,15 @@ import logging import os import platform -import subprocess import sys -from datetime import date, timedelta +from datetime import date from pathlib import Path import plyer -from PySide6.QtCore import QSize, QTimer, QRunnable, Signal, Slot, QThreadPool, QObject -from PySide6.QtWidgets import ( - QMainWindow, - QLabel, - QWidget, - QMessageBox, -) +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QMainWindow, QWidget, QMessageBox -from worker import Worker -from checknmr import check_nmr from config import Config from ui.layout import Layout @@ -29,14 +21,13 @@ def __init__(self, resource_directory: Path, config: Config): self.rsrc_dir = resource_directory self.config = config - #self.mora_path = Path(config.paths[platform.system()]) - #self.update_path = Path(config.paths["update"]) + # self.mora_path = Path(config.paths[platform.system()]) + # self.update_path = Path(config.paths["update"]) # Setup UI self.setWindowTitle("Mora the Explorer") self.setup_ui() - def setup_ui(self): """Setup main layout, which is a simple vertical stack.""" @@ -60,24 +51,30 @@ def setup_ui(self): self.setMinimumSize(QSize(420, 680)) else: self.setMinimumSize(QSize(450, 780)) - def notify_spectra(self, copied_list): """Tell the user that spectra were found, both in the app and with a system toast.""" # If spectra were found, the list will have len > 1, if a known error occurred, the list will have len 1, if an unknown error occurred, the list will be empty if len(copied_list) > 1: notification_text = "Spectra have been found!" - self.ui.notification.setText(notification_text + " Ctrl+G to go to. Click to dismiss") + self.ui.notification.setText( + notification_text + " Ctrl+G to go to. Click to dismiss" + ) self.ui.notification.setStyleSheet("background-color : limegreen") else: - self.ui.notification.setStyleSheet("background-color : #cc0010; color : white") + self.ui.notification.setStyleSheet( + "background-color : #cc0010; color : white" + ) try: notification_text = "Error: " + copied_list[0] except: notification_text = "Unknown error occurred." self.ui.notification.setText(notification_text + " Click to dismiss") self.ui.notification.show() - if self.opts.since_button.isChecked() is False and platform.system() != "Darwin": + if ( + self.opts.since_button.isChecked() is False + and platform.system() != "Darwin" + ): # Display system notification - doesn't seem to be implemented for macOS currently # Only if a single date is checked, because with the since function the system notifications get annoying try: @@ -90,17 +87,17 @@ def notify_spectra(self, copied_list): except: pass - def notification_clicked(self): self.ui.notification.hide() - def notify_update(self, current, available, changelog): """Spawn popup to notify user that an update is available, with version info.""" update_dialog = QMessageBox(self) update_dialog.setWindowTitle("Update available") - update_dialog.setText(f"There appears to be a new update available at:\n{self.update_path}") + update_dialog.setText( + f"There appears to be a new update available at:\n{self.update_path}" + ) update_dialog.setInformativeText( f"Your version is {current}\nThe version on the server is {available}\n{changelog}" ) @@ -111,7 +108,6 @@ def notify_update(self, current, available, changelog): if self.update_path.exists() is True: # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise os.system(f'start "" "{self.update_path}"') - def notify_failed_permissions(self): """Spawn popup to notify user that accessing the mora server failed.""" @@ -122,12 +118,10 @@ def notify_failed_permissions(self): failed_permission_dialog.setText(""" You have been denied permission to access the mora server. Check the connection and your authentication details and try again. -The program will now close.""" - ) +The program will now close.""") failed_permission_dialog.exec() sys.exit() - def warn_since_function(self): """Spawn popup dialog that dissuades user from using the "since" function regularly.""" @@ -138,7 +132,6 @@ def warn_since_function(self): """ QMessageBox.warning(self, "Warning", since_message) - def group_changed(self): """Find out what the new group is, save it to config, make necessary adjustments.""" if self.opts.AK_buttons.checkedButton().text() == "other": @@ -148,7 +141,6 @@ def group_changed(self): self.config.options["group"] = new_group self.adapt_to_group(new_group) self.opts.save_button.setEnabled(True) - def adapt_to_group(self, group=None): if group is None: @@ -157,13 +149,17 @@ def adapt_to_group(self, group=None): self.opts.other_box.show() else: self.opts.other_box.hide() - # If nmr group has been selected, disable the naming option checkboxes as they will be treated as selected anyway + # If nmr group has been selected, disable the naming option checkboxes + # as they will be treated as selected anyway if group == "nmr": self.opts.inc_init_checkbox.setEnabled(False) self.opts.nmrcheck_style_checkbox.setEnabled(False) else: - # Only enable initials checkbox if nmrcheck_style option is not selected, disable otherwise - self.opts.inc_init_checkbox.setEnabled(not self.config.options["nmrcheck_style"]) + # Only enable initials checkbox if nmrcheck_style option is not selected, + # disable otherwise + self.opts.inc_init_checkbox.setEnabled( + not self.config.options["nmrcheck_style"] + ) self.opts.nmrcheck_style_checkbox.setEnabled(True) if group == "nmr" or self.config.options["spec"] == "hf": self.opts.inc_solv_checkbox.setEnabled(False) @@ -171,7 +167,6 @@ def adapt_to_group(self, group=None): self.opts.inc_solv_checkbox.setEnabled(True) self.refresh_visible_specs() - def dest_path_changed(self, new_path): formatted_path = new_path # Best way to ensure cross-platform compatibility is to avoid use of backslashes and then let pathlib.Path take care of formatting @@ -184,37 +179,35 @@ def dest_path_changed(self, new_path): self.opts.open_button.show() self.opts.save_button.setEnabled(True) - def inc_init_switched(self): self.config.options["inc_init"] = self.opts.inc_init_checkbox.isChecked() self.opts.save_button.setEnabled(True) - def inc_solv_switched(self): self.config.options["inc_solv"] = self.opts.inc_solv_checkbox.isChecked() self.opts.save_button.setEnabled(True) - def nmrcheck_style_switched(self): - self.config.options["nmrcheck_style"] = self.opts.nmrcheck_style_checkbox.isChecked() + self.config.options["nmrcheck_style"] = ( + self.opts.nmrcheck_style_checkbox.isChecked() + ) self.opts.save_button.setEnabled(True) - self.opts.inc_init_checkbox.setEnabled(not self.opts.nmrcheck_style_checkbox.isChecked()) + self.opts.inc_init_checkbox.setEnabled( + not self.opts.nmrcheck_style_checkbox.isChecked() + ) self.adapt_to_spec() - def refresh_visible_specs(self): if self.config.options["group"] in ["stu", "nae", "nmr"]: self.opts.spec_buttons.buttons["300er"].show() else: self.opts.spec_buttons.buttons["300er"].hide() - def spec_changed(self): self.config.options["spec"] = self.opts.spec_buttons.checkedButton().name self.adapt_to_spec() self.opts.save_button.setEnabled(True) - def adapt_to_spec(self): if self.config.options["spec"] == "hf": # Including the solvent in the title is not supported for high-field measurements so disable option @@ -224,50 +217,43 @@ def adapt_to_spec(self): self.opts.today_button.setEnabled(False) self.opts.hf_date_selector.show() else: - if self.config.options["group"] != "nmr" and self.config.options["nmrcheck_style"] is False: + if ( + self.config.options["group"] != "nmr" + and self.config.options["nmrcheck_style"] is False + ): self.opts.inc_solv_checkbox.setEnabled(True) self.opts.repeat_check_checkbox.setEnabled(True) self.opts.hf_date_selector.hide() self.opts.date_selector.show() self.opts.today_button.setEnabled(True) - def repeat_switched(self): - self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() + self.config.options["repeat_switch"] = ( + self.opts.repeat_check_checkbox.isChecked() + ) self.opts.save_button.setEnabled(True) - def repeat_delay_changed(self, new_delay): self.config.options["repeat_delay"] = new_delay self.opts.save_button.setEnabled(True) - def save(self): self.config.save() self.opts.save_button.setEnabled(False) - def since_function_activated(self): - if self.opts.since_button.isChecked() is True and self.config.options["group"] != "nmr": + if ( + self.opts.since_button.isChecked() is True + and self.config.options["group"] != "nmr" + ): self.warn_since_function() self.opts.repeat_check_checkbox.setEnabled(False) self.config.options["repeat_switch"] = False else: self.opts.repeat_check_checkbox.setEnabled(True) - self.config.options["repeat_switch"] = self.opts.repeat_check_checkbox.isChecked() - + self.config.options["repeat_switch"] = ( + self.opts.repeat_check_checkbox.isChecked() + ) def set_date_as_today(self): self.opts.date_selector.setDate(date.today()) - - - def format_date(self, input_date): - """Convert Python datetime.date object to the same format used in the folder names on Mora.""" - if self.config.options["spec"] == "hf": - formatted_date = input_date.strftime("%Y") - else: - formatted_date = input_date.strftime("%b%d-%Y") - return formatted_date - - - diff --git a/ui/options.py b/ui/options.py index 03bb8ab..a3db443 100644 --- a/ui/options.py +++ b/ui/options.py @@ -16,6 +16,7 @@ QGridLayout, ) + class AKButtons(QButtonGroup): def __init__(self, parent, ak_list, selected_ak): super().__init__(parent) @@ -26,9 +27,7 @@ def __init__(self, parent, ak_list, selected_ak): for ak in ak_list: ak_button = QRadioButton(ak) self.button_list.append(ak_button) - if (ak == selected_ak) or ( - ak == "other" and self.checkedButton() is None - ): + if (ak == selected_ak) or (ak == "other" and self.checkedButton() is None): ak_button.setChecked(True) self.addButton(ak_button) if len(ak_list) <= 4 or ak_list.index(ak) < (len(ak_list) / 2): @@ -70,13 +69,14 @@ def __init__(self, parent, selected_spec): class OptionsLayout(QGridLayout): """Layout containing all user-configurable options. - + Widgets with more complicated code are defined in custom classes, while simple ones are defined in-line. We define the layout, content, and appearance here, but not the behaviour (e.g. what happens when something is clicked or changed.) """ + def __init__(self, config): super().__init__() @@ -94,19 +94,19 @@ def __init__(self, config): self.addWidget(self.initials_label, 0, 0) self.addWidget(self.initials_entry, 0, 1) self.addWidget(self.initials_hint, 0, 2) - - + # Row 1, research group selection buttons self.group_label = QLabel("group:") self.group_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self.AK_buttons = AKButtons(self, list(config.groups.keys()), config.options["group"]) + self.AK_buttons = AKButtons( + self, list(config.groups.keys()), config.options["group"] + ) self.addWidget(self.group_label, 1, 0) self.addLayout(self.AK_buttons.main_layout, 1, 1) self.addLayout(self.AK_buttons.overflow_layout, 2, 1) - # Row 2, drop down list for further options that appears only when "other" # radio button is clicked self.other_box = QComboBox() @@ -118,7 +118,6 @@ def __init__(self, config): self.addWidget(self.other_box, 2, 2) - # Row 3, destination path entry box self.dest_path_label = QLabel("save in:") self.dest_path_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -131,12 +130,11 @@ def __init__(self, config): # Disable button if path hasn't yet been specified to stop new users thinking it should be used to select a folder if config.options["dest_path"] == "copy full path here": self.open_button.hide() - + self.addWidget(self.dest_path_label, 3, 0) self.addWidget(self.dest_path_input, 3, 1) self.addWidget(self.open_button, 3, 2) - # Row 4, file naming options self.include_label = QLabel("include:") self.include_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -157,18 +155,16 @@ def __init__(self, config): self.in_filename_label = QLabel("...in filename") self.in_filename_label.setAlignment(Qt.AlignCenter) - + self.addWidget(self.include_label, 4, 0) self.addLayout(init_solv_layout, 4, 1) self.addWidget(self.in_filename_label, 4, 2) - # Row 5, option to use NMRCheck-style formatting of folder names self.nmrcheck_style_checkbox = QCheckBox("use comprehensive (NMRCheck) style") self.nmrcheck_style_checkbox.setChecked(config.options["nmrcheck_style"]) - - self.addWidget(self.nmrcheck_style_checkbox, 5, 1, 1, 2) + self.addWidget(self.nmrcheck_style_checkbox, 5, 1, 1, 2) # Row 6, spectrometer selection buttons self.spec_label = QLabel("search:") @@ -179,7 +175,6 @@ def __init__(self, config): self.addWidget(self.spec_label, 6, 0) self.addLayout(self.spec_buttons.layout, 6, 1, 1, 2) - # Row 7, checkbox to instruct to repeat after chosen interval self.repeat_label = QLabel("repeat:") self.repeat_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -199,14 +194,12 @@ def __init__(self, config): self.addWidget(self.repeat_label, 7, 0) self.addLayout(repeat_layout, 7, 1) - # Row 8, button to save all options for future self.save_button = QPushButton("save options as defaults for next time") self.save_button.setEnabled(False) self.addWidget(self.save_button, 8, 0, 1, 3) - # Row 9, date selection tool self.date_label = QLabel("when?") self.date_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -236,7 +229,7 @@ def __init__(self, config): date_layout.addWidget(self.date_selector, 2) self.addLayout(date_layout, 9, 1) - + # Date selection tool for hf (only needs year) self.hf_date_selector = QDateEdit() self.hf_date_selector.setDisplayFormat("yyyy") diff --git a/worker.py b/worker.py index 485692c..f6fe4ea 100644 --- a/worker.py +++ b/worker.py @@ -26,4 +26,4 @@ def run(self): output = self.fn(*self.args, **self.kwargs) # Emit the output of the function as the result signal so that it can be picked up self.signals.result.emit(output) - self.signals.completed.emit() \ No newline at end of file + self.signals.completed.emit() From 442196d0eb0a90c08fd948b03dce18066e99b1cc Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:30:40 +0200 Subject: [PATCH 05/20] Fix update path --- explorer.py | 2 +- ui/main_window.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/explorer.py b/explorer.py index 07800be..76f352e 100644 --- a/explorer.py +++ b/explorer.py @@ -73,7 +73,7 @@ def update_check(self, update_path): changelog = "".join(version_file_info[5:]).rstrip() if version_no != newest_version_no: self.main_window.notify_update( - version_no, newest_version_no, changelog + version_no, newest_version_no, changelog, self.update_path ) except PermissionError: self.main_window.notify_failed_permissions() diff --git a/ui/main_window.py b/ui/main_window.py index 1d74d22..f676fdb 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -90,13 +90,13 @@ def notify_spectra(self, copied_list): def notification_clicked(self): self.ui.notification.hide() - def notify_update(self, current, available, changelog): + def notify_update(self, current, available, changelog, path): """Spawn popup to notify user that an update is available, with version info.""" update_dialog = QMessageBox(self) update_dialog.setWindowTitle("Update available") update_dialog.setText( - f"There appears to be a new update available at:\n{self.update_path}" + f"There appears to be a new update available at:\n{path}" ) update_dialog.setInformativeText( f"Your version is {current}\nThe version on the server is {available}\n{changelog}" @@ -105,9 +105,9 @@ def notify_update(self, current, available, changelog): update_dialog.setDefaultButton(QMessageBox.Ignore) choice = update_dialog.exec() if choice == QMessageBox.Open: - if self.update_path.exists() is True: + if path.exists() is True: # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise - os.system(f'start "" "{self.update_path}"') + os.system(f'start "" "{path}"') def notify_failed_permissions(self): """Spawn popup to notify user that accessing the mora server failed.""" From 840e6a32be95e0d490d11f179d7464175012ae27 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:32:35 +0200 Subject: [PATCH 06/20] Fix destination path for copying --- checknmr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checknmr.py b/checknmr.py index cf3d181..250f4ef 100644 --- a/checknmr.py +++ b/checknmr.py @@ -434,7 +434,7 @@ def check_nmr( # Copy, add output messages to main output list output_list.extend( - copy_folder(folder, fed_options["dest_path"] / new_folder_name) + copy_folder(folder, Path(fed_options["dest_path"]) / new_folder_name) ) # Update progress bar if a callback object has been given From 9c3ea36d38d83315ee69ee2c2ead590fdaec856d Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:34:38 +0200 Subject: [PATCH 07/20] Fix for progress bar --- checknmr.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/checknmr.py b/checknmr.py index 250f4ef..42fb65a 100644 --- a/checknmr.py +++ b/checknmr.py @@ -361,6 +361,7 @@ def check_nmr( # Initialize progress bar prog_state = 0 n_spectra = get_number_spectra(paths=check_path_list) + print(n_spectra) logging.info(f"Total spectra in these paths: {n_spectra}") try: prog_bar.setMaximum(n_spectra) @@ -440,7 +441,8 @@ def check_nmr( # Update progress bar if a callback object has been given prog_state += 1 if progress_callback is not None: - progress_callback.emit(round(prog_state)) + print(prog_state) + progress_callback.emit(prog_state) else: print(f"Spectra checked: {prog_state}") From a5c037422233b3a087363df90946237974c1e8b9 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:36:56 +0200 Subject: [PATCH 08/20] Print progress as well --- explorer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/explorer.py b/explorer.py index 76f352e..7e9bc79 100644 --- a/explorer.py +++ b/explorer.py @@ -225,6 +225,7 @@ def multiday_check(self, initial_date): date_to_check += timedelta(days=1) def update_progress(self, prog_state): + print(prog_state) self.ui.prog_bar.setValue(prog_state) def handle_output(self, final_output): From 0b62847aef7964c839dc46a51ffd092d45ecfab5 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:39:03 +0200 Subject: [PATCH 09/20] Print progress as well --- checknmr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/checknmr.py b/checknmr.py index 42fb65a..fb3f387 100644 --- a/checknmr.py +++ b/checknmr.py @@ -362,6 +362,7 @@ def check_nmr( prog_state = 0 n_spectra = get_number_spectra(paths=check_path_list) print(n_spectra) + print(prog_bar.maximum()) logging.info(f"Total spectra in these paths: {n_spectra}") try: prog_bar.setMaximum(n_spectra) From 91948ad0fc6767f50480e69d7feb29c3575f73e4 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:39:54 +0200 Subject: [PATCH 10/20] Print progress as well --- checknmr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/checknmr.py b/checknmr.py index fb3f387..aeaa07f 100644 --- a/checknmr.py +++ b/checknmr.py @@ -362,7 +362,6 @@ def check_nmr( prog_state = 0 n_spectra = get_number_spectra(paths=check_path_list) print(n_spectra) - print(prog_bar.maximum()) logging.info(f"Total spectra in these paths: {n_spectra}") try: prog_bar.setMaximum(n_spectra) @@ -373,6 +372,7 @@ def check_nmr( except Exception: # This stops Python from hanging when the program is closed, no idea why exit() + print(prog_bar.maximum()) # Now we have a list of directories to check, start the actual search process # Needs to be slightly different depending on the spectrometer, as the contents of From f51998c974c30f511b3c250c8ebb062ac8b2735d Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:42:38 +0200 Subject: [PATCH 11/20] Print progress as well --- checknmr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/checknmr.py b/checknmr.py index aeaa07f..573cad8 100644 --- a/checknmr.py +++ b/checknmr.py @@ -441,8 +441,9 @@ def check_nmr( # Update progress bar if a callback object has been given prog_state += 1 + print(prog_state) if progress_callback is not None: - print(prog_state) + print("callback") progress_callback.emit(prog_state) else: print(f"Spectra checked: {prog_state}") From 1a316bb86a22a403319e984c58208d711b7b88f4 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:46:21 +0200 Subject: [PATCH 12/20] Fix progress --- checknmr.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/checknmr.py b/checknmr.py index 573cad8..fc85735 100644 --- a/checknmr.py +++ b/checknmr.py @@ -415,6 +415,14 @@ def check_nmr( hit = True if not hit: + # Update progress bar if a callback object has been given + prog_state += 1 + print(prog_state) + if progress_callback is not None: + print("callback") + progress_callback.emit(prog_state) + else: + print(f"Spectra checked: {prog_state}") continue else: logging.info("Spectrum matches search query!") From 78c295d4155a74da4170df03fb5b400d8de124a5 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Mon, 27 May 2024 19:48:09 +0200 Subject: [PATCH 13/20] Remove debugging print statements --- checknmr.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/checknmr.py b/checknmr.py index fc85735..5014905 100644 --- a/checknmr.py +++ b/checknmr.py @@ -361,7 +361,6 @@ def check_nmr( # Initialize progress bar prog_state = 0 n_spectra = get_number_spectra(paths=check_path_list) - print(n_spectra) logging.info(f"Total spectra in these paths: {n_spectra}") try: prog_bar.setMaximum(n_spectra) @@ -372,7 +371,6 @@ def check_nmr( except Exception: # This stops Python from hanging when the program is closed, no idea why exit() - print(prog_bar.maximum()) # Now we have a list of directories to check, start the actual search process # Needs to be slightly different depending on the spectrometer, as the contents of @@ -417,9 +415,7 @@ def check_nmr( if not hit: # Update progress bar if a callback object has been given prog_state += 1 - print(prog_state) if progress_callback is not None: - print("callback") progress_callback.emit(prog_state) else: print(f"Spectra checked: {prog_state}") @@ -449,9 +445,7 @@ def check_nmr( # Update progress bar if a callback object has been given prog_state += 1 - print(prog_state) if progress_callback is not None: - print("callback") progress_callback.emit(prog_state) else: print(f"Spectra checked: {prog_state}") From ba603aed03ca0365464a2fc6a4da561f5f2d79e5 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 11:40:31 +0200 Subject: [PATCH 14/20] Change exit() to sys.exit() --- checknmr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/checknmr.py b/checknmr.py index 5014905..5150fe1 100644 --- a/checknmr.py +++ b/checknmr.py @@ -1,6 +1,7 @@ import filecmp import logging import shutil +import sys from datetime import date, datetime from pathlib import Path @@ -370,7 +371,7 @@ def check_nmr( print(f"Total spectra to check: {n_spectra}") except Exception: # This stops Python from hanging when the program is closed, no idea why - exit() + sys.exit() # Now we have a list of directories to check, start the actual search process # Needs to be slightly different depending on the spectrometer, as the contents of From 145989f867c39be561e8f9c50e36dbddd3744b67 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 18:39:20 +0200 Subject: [PATCH 15/20] Move everything except for small script into subpackage --- mora_the_explorer.py | 84 ++++--------------- mora_the_explorer/__init__.py | 87 ++++++++++++++++++++ checknmr.py => mora_the_explorer/checknmr.py | 0 config.py => mora_the_explorer/config.py | 17 ++-- explorer.py => mora_the_explorer/explorer.py | 8 +- {ui => mora_the_explorer/ui}/__init__.py | 0 {ui => mora_the_explorer/ui}/display.py | 0 {ui => mora_the_explorer/ui}/layout.py | 4 +- {ui => mora_the_explorer/ui}/main_window.py | 4 +- {ui => mora_the_explorer/ui}/options.py | 0 worker.py => mora_the_explorer/worker.py | 0 11 files changed, 125 insertions(+), 79 deletions(-) create mode 100644 mora_the_explorer/__init__.py rename checknmr.py => mora_the_explorer/checknmr.py (100%) rename config.py => mora_the_explorer/config.py (88%) rename explorer.py => mora_the_explorer/explorer.py (98%) rename {ui => mora_the_explorer/ui}/__init__.py (100%) rename {ui => mora_the_explorer/ui}/display.py (100%) rename {ui => mora_the_explorer/ui}/layout.py (97%) rename {ui => mora_the_explorer/ui}/main_window.py (99%) rename {ui => mora_the_explorer/ui}/options.py (100%) rename worker.py => mora_the_explorer/worker.py (100%) diff --git a/mora_the_explorer.py b/mora_the_explorer.py index addf0e8..6644e88 100644 --- a/mora_the_explorer.py +++ b/mora_the_explorer.py @@ -17,60 +17,37 @@ """ import logging -import platform +import platformdirs import sys from pathlib import Path -import darkdetect - -from PySide6.QtCore import Qt -from PySide6.QtGui import QIcon, QPalette, QColor -from PySide6.QtWidgets import QApplication - -from config import Config -from explorer import Explorer -from ui.main_window import MainWindow +import mora_the_explorer def get_rsrc_dir(): """Gets the location of the program's resources, which is platform-dependent.""" # For whatever reason __file__ doesn't give the right location on a mac when a .app has # been generated with pyinstaller - if platform.system() == "Darwin" and getattr(sys, "frozen", False): + if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) else: return Path(__file__).parent -def set_dark_mode(app): - """Manually set a dark mode in Windows. - - Make dark mode less black than default because Windows dark mode looks bad.""" - dark_palette = QPalette() - dark_palette.setColor(QPalette.Window, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.WindowText, Qt.white) - dark_palette.setColor(QPalette.Base, QColor(25, 25, 25)) - dark_palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.ToolTipBase, Qt.black) - dark_palette.setColor(QPalette.ToolTipText, Qt.white) - dark_palette.setColor(QPalette.Text, Qt.white) - dark_palette.setColor(QPalette.Button, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.ButtonText, Qt.white) - dark_palette.setColor(QPalette.BrightText, Qt.red) - dark_palette.setColor(QPalette.Link, QColor(42, 130, 218)) - dark_palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) - dark_palette.setColor(QPalette.HighlightedText, Qt.black) - app.setStyle("Fusion") - app.setPalette(dark_palette) - - if __name__ == "__main__": - # Assign directory containing the various supporting files to a variable so we can pass - # it to our MainWindow and use it whenever necessary. - rsrc_dir = get_rsrc_dir() - # Set up logging - log = rsrc_dir / "log.log" + # Logs should be saved to: + # Windows: c:/Users//AppData/Local/mora_the_explorer/log.log + # macOS: /Users//Library/Logs/mora_the_explorer/log.log + # Linux: /home//.local/state/mora_the_explorer/log.log + log = Path( + platformdirs.user_log_dir( + "mora_the_explorer", + opinion=False, + ensure_exists=True, + ) + ) / "log.log" + logging.basicConfig( filename=log, filemode="w", @@ -78,32 +55,7 @@ def set_dark_mode(app): encoding="utf-8", level=logging.INFO, ) + + rsrc_dir = get_rsrc_dir() - # Load configuration - logging.info(f"Program resources located at {rsrc_dir}") - logging.info("Loading program settings...") - config = Config(rsrc_dir) - logging.info("...complete") - - logging.info("Initializing program...") - app = QApplication(sys.argv) - - if darkdetect.isDark() is True and platform.system() == "Windows": - set_dark_mode(app) - - # Create instance of MainWindow (front-end), then show it - logging.info("Initializing user interface...") - window = MainWindow(rsrc_dir, config) - window.show() - logging.info("...complete") - - # Create instance of Explorer (back-end) - # Give it our MainWindow so it can read things directly from the UI - logging.info("Initializing explorer...") - explorer = Explorer(window, rsrc_dir, config) - logging.info("...complete") - - app.setWindowIcon(QIcon(str(rsrc_dir / "explorer.ico"))) - - logging.info("Initialization complete") - app.exec() + mora_the_explorer.run(rsrc_dir) \ No newline at end of file diff --git a/mora_the_explorer/__init__.py b/mora_the_explorer/__init__.py new file mode 100644 index 0000000..3012fd8 --- /dev/null +++ b/mora_the_explorer/__init__.py @@ -0,0 +1,87 @@ +""" +Mora the Explorer checks for new NMR spectra at the Organic Chemistry department at the University of Münster. +Copyright (C) 2023 Matthew J. Milner + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import logging +import platform +import sys +from pathlib import Path + +import darkdetect + +from PySide6.QtCore import Qt +from PySide6.QtGui import QIcon, QPalette, QColor +from PySide6.QtWidgets import QApplication + +from .config import Config +from .explorer import Explorer +from .ui.main_window import MainWindow + + +def set_dark_mode(app): + """Manually set a dark mode in Windows. + + Make dark mode less black than default because Windows dark mode looks bad.""" + dark_palette = QPalette() + dark_palette.setColor(QPalette.Window, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.WindowText, Qt.white) + dark_palette.setColor(QPalette.Base, QColor(25, 25, 25)) + dark_palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ToolTipBase, Qt.black) + dark_palette.setColor(QPalette.ToolTipText, Qt.white) + dark_palette.setColor(QPalette.Text, Qt.white) + dark_palette.setColor(QPalette.Button, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ButtonText, Qt.white) + dark_palette.setColor(QPalette.BrightText, Qt.red) + dark_palette.setColor(QPalette.Link, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.HighlightedText, Qt.black) + app.setStyle("Fusion") + app.setPalette(dark_palette) + + +def run(rsrc_dir): + """Run Mora the Explorer.""" + + # Load configuration + logging.info(f"Program resources located at {rsrc_dir}") + logging.info("Loading program settings...") + config = Config(rsrc_dir) + logging.info("...complete") + + logging.info("Initializing program...") + app = QApplication(sys.argv) + + if darkdetect.isDark() is True and platform.system() == "Windows": + set_dark_mode(app) + + # Create instance of MainWindow (front-end), then show it + logging.info("Initializing user interface...") + window = MainWindow(rsrc_dir, config) + window.show() + logging.info("...complete") + + # Create instance of Explorer (back-end) + # Give it our MainWindow so it can read things directly from the UI + logging.info("Initializing explorer...") + explorer = Explorer(window, rsrc_dir, config) + logging.info("...complete") + + app.setWindowIcon(QIcon(str(rsrc_dir / "explorer.ico"))) + + logging.info("Initialization complete") + app.exec() diff --git a/checknmr.py b/mora_the_explorer/checknmr.py similarity index 100% rename from checknmr.py rename to mora_the_explorer/checknmr.py diff --git a/config.py b/mora_the_explorer/config.py similarity index 88% rename from config.py rename to mora_the_explorer/config.py index 9384db3..d941431 100644 --- a/config.py +++ b/mora_the_explorer/config.py @@ -4,7 +4,6 @@ from pathlib import Path import tomllib import tomli_w - import platformdirs @@ -23,12 +22,20 @@ def __init__(self, resource_directory): # Load user config from config.toml in user's config directory # Make one if it doesn't exist yet + + # Config should be saved to: + # Windows: c:/Users//AppData/Roaming/mora_the_explorer/config.toml + # macOS: /Users//Library/Application Support/mora_the_explorer/config.toml + # Linux: /home//.config/mora_the_explorer/config.toml + # User options used to be stored in config.json pre v1.7, so also check for that # platformdirs automatically saves the config file in the place appropriate to the os - self.user_config_file = ( - Path(platformdirs.user_config_dir("mora_the_explorer", roaming=True)) - / "config.toml" - ) + self.user_config_file = Path(platformdirs.user_config_dir( + "mora_the_explorer", + roaming=True, + ensure_exists=True, + ) + ) / "config.toml" user_config_json = self.user_config_file.with_name("config.json") if self.user_config_file.exists() is True: with open(self.user_config_file, "rb") as f: diff --git a/explorer.py b/mora_the_explorer/explorer.py similarity index 98% rename from explorer.py rename to mora_the_explorer/explorer.py index 7e9bc79..e2a6d6a 100644 --- a/explorer.py +++ b/mora_the_explorer/explorer.py @@ -8,10 +8,10 @@ from PySide6.QtCore import QTimer, QThreadPool from PySide6.QtWidgets import QLabel -from worker import Worker -from checknmr import check_nmr -from config import Config -from ui.main_window import MainWindow +from .worker import Worker +from .checknmr import check_nmr +from .config import Config +from .ui.main_window import MainWindow class Explorer: diff --git a/ui/__init__.py b/mora_the_explorer/ui/__init__.py similarity index 100% rename from ui/__init__.py rename to mora_the_explorer/ui/__init__.py diff --git a/ui/display.py b/mora_the_explorer/ui/display.py similarity index 100% rename from ui/display.py rename to mora_the_explorer/ui/display.py diff --git a/ui/layout.py b/mora_the_explorer/ui/layout.py similarity index 97% rename from ui/layout.py rename to mora_the_explorer/ui/layout.py index 54e2899..9c16a64 100644 --- a/ui/layout.py +++ b/mora_the_explorer/ui/layout.py @@ -4,8 +4,8 @@ from PySide6.QtWidgets import QPushButton, QLabel, QProgressBar, QVBoxLayout -from ui.options import OptionsLayout -from ui.display import Display +from .options import OptionsLayout +from .display import Display class Layout(QVBoxLayout): diff --git a/ui/main_window.py b/mora_the_explorer/ui/main_window.py similarity index 99% rename from ui/main_window.py rename to mora_the_explorer/ui/main_window.py index f676fdb..4419c47 100644 --- a/ui/main_window.py +++ b/mora_the_explorer/ui/main_window.py @@ -10,8 +10,8 @@ from PySide6.QtCore import QSize from PySide6.QtWidgets import QMainWindow, QWidget, QMessageBox -from config import Config -from ui.layout import Layout +from ..config import Config +from .layout import Layout class MainWindow(QMainWindow): diff --git a/ui/options.py b/mora_the_explorer/ui/options.py similarity index 100% rename from ui/options.py rename to mora_the_explorer/ui/options.py diff --git a/worker.py b/mora_the_explorer/worker.py similarity index 100% rename from worker.py rename to mora_the_explorer/worker.py From 54fa4e8c78dfbb682a1ee11ed89d3858d58f557b Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 18:42:26 +0200 Subject: [PATCH 16/20] Patch bump --- pyproject.toml | 2 +- version.txt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2298684..e31b523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mora-the-explorer" -version = "1.7.0" +version = "1.7.1" description = "A small GUI program for downloading NMR spectra at the Organic Chemistry department at the University of Münster" authors = [ { name = "Matthew J. Milner", email = "matterhorn103@proton.me" } diff --git a/version.txt b/version.txt index 2e84113..f08576d 100644 --- a/version.txt +++ b/version.txt @@ -1,11 +1,13 @@ Mora the Explorer Matt Milner -v1.7.0 +v1.7.1 License: GPLv3 Please report any bugs to milner@uni-muenster.de! Changes in this version: - Spectra measured in 2023 can be found again - Any spectra measured in previous years that haven't been archived will be found correctly in future +- Progress bar now more accurately shows progress for 400 MHz spectrometers +- Results display automatically scrolls down when new messages arrive - User config now contained in a config.toml - Internal restructuring to make future maintenance easier - Removal of deprecated ( Date: Tue, 28 May 2024 18:52:26 +0200 Subject: [PATCH 17/20] Fix naming, improve progress bar on copy --- mora_the_explorer/checknmr.py | 16 ++++++++++------ mora_the_explorer/explorer.py | 1 - 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mora_the_explorer/checknmr.py b/mora_the_explorer/checknmr.py index 5150fe1..729696e 100644 --- a/mora_the_explorer/checknmr.py +++ b/mora_the_explorer/checknmr.py @@ -217,11 +217,12 @@ def format_name( if x is not None ] ) - # Apply user choices - if inc_init is True and metadata["initials"] is not None: - name = metadata["initials"] + "-" + name - if inc_group is True and metadata["group"] is not None: - name = metadata["group"] + "-" + name + # Apply user choices, some only if NMRCheck style wasn't chosen + if nmrcheck_style is False: + if inc_init is True and metadata["initials"] is not None: + name = metadata["initials"] + "-" + name + if inc_group is True and metadata["group"] is not None: + name = metadata["group"] + "-" + name if inc_solv is True and metadata["solvent"] is not None: name = name + "-" + metadata["solvent"] # Add frequency info if available @@ -445,7 +446,10 @@ def check_nmr( ) # Update progress bar if a callback object has been given - prog_state += 1 + # Make sure there's a noticeable movement after copying a spectrum, + # otherwise it looks frozen + prog_bar.setMaximum(prog_bar.maximum() + 5) + prog_state += 5 if progress_callback is not None: progress_callback.emit(prog_state) else: diff --git a/mora_the_explorer/explorer.py b/mora_the_explorer/explorer.py index e2a6d6a..6565383 100644 --- a/mora_the_explorer/explorer.py +++ b/mora_the_explorer/explorer.py @@ -225,7 +225,6 @@ def multiday_check(self, initial_date): date_to_check += timedelta(days=1) def update_progress(self, prog_state): - print(prog_state) self.ui.prog_bar.setValue(prog_state) def handle_output(self, final_output): From dd3b26e74970edc44b11a7f1dcd84d9e5a29fec6 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 19:01:20 +0200 Subject: [PATCH 18/20] Fix visibility of only/since on Win11, fix path generation --- mora_the_explorer/checknmr.py | 5 ++--- mora_the_explorer/ui/main_window.py | 4 ++++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/mora_the_explorer/checknmr.py b/mora_the_explorer/checknmr.py index 729696e..b2a4fa4 100644 --- a/mora_the_explorer/checknmr.py +++ b/mora_the_explorer/checknmr.py @@ -61,13 +61,12 @@ def get_hf_paths(spec_paths, check_year, wild_group): # Check folders of all groups when group `nmr` uses the group wildcard if wild_group is True: group_folders = [ - x - for x in spec_paths["hf"].parent.iterdir() + x for x in spec_paths["hf"].parent.iterdir() if x.is_dir() and (x.name[0] != ".") ] check_path_list = [group_folder / check_year for group_folder in group_folders] else: - check_path_list = spec_paths["hf"] / check_year + check_path_list = [spec_paths["hf"] / check_year] return check_path_list diff --git a/mora_the_explorer/ui/main_window.py b/mora_the_explorer/ui/main_window.py index 4419c47..f6e1d95 100644 --- a/mora_the_explorer/ui/main_window.py +++ b/mora_the_explorer/ui/main_window.py @@ -214,6 +214,8 @@ def adapt_to_spec(self): self.opts.inc_solv_checkbox.setEnabled(False) self.opts.repeat_check_checkbox.setEnabled(False) self.opts.date_selector.hide() + self.opts.only_button.hide() + self.opts.since_button.hide() self.opts.today_button.setEnabled(False) self.opts.hf_date_selector.show() else: @@ -225,6 +227,8 @@ def adapt_to_spec(self): self.opts.repeat_check_checkbox.setEnabled(True) self.opts.hf_date_selector.hide() self.opts.date_selector.show() + self.opts.only_button.show() + self.opts.since_button.show() self.opts.today_button.setEnabled(True) def repeat_switched(self): From 1af1fe741f874f5deaeeaf71a67a56cc4c72c732 Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 19:05:50 +0200 Subject: [PATCH 19/20] Fix missing hf path on start --- mora_the_explorer/explorer.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/mora_the_explorer/explorer.py b/mora_the_explorer/explorer.py index 6565383..897b11b 100644 --- a/mora_the_explorer/explorer.py +++ b/mora_the_explorer/explorer.py @@ -30,6 +30,11 @@ def __init__( self.threadpool = QThreadPool() self.threadpool.setMaxThreadCount(1) + # Initialize some variables for later + self.wild_group = False + self.copied_list = [] + self.date_selected = date.today() + # Set path to mora self.mora_path = Path(config.paths[platform.system()]) self.update_path = Path(config.paths["update"]) @@ -41,15 +46,11 @@ def __init__( "300er": self.path_300er, "400er": self.path_400er, } + self.adapt_paths_to_group(self.config.options["group"]) # Check for updates self.update_check(Path(config.paths["update"])) - # Initialize some other variables for later - self.wild_group = False - self.copied_list = [] - self.date_selected = date.today() - # Timer for repeat check, starts checking function when timer runs out self.timer = QTimer() self.timer.setSingleShot(True) From 83a7dcbb14c9fcca470a03f0c987d542e1fe929b Mon Sep 17 00:00:00 2001 From: "Matthew J. Milner" Date: Tue, 28 May 2024 19:09:47 +0200 Subject: [PATCH 20/20] Fix metadata fetch for agilent --- mora_the_explorer/checknmr.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/mora_the_explorer/checknmr.py b/mora_the_explorer/checknmr.py index b2a4fa4..02a87a7 100644 --- a/mora_the_explorer/checknmr.py +++ b/mora_the_explorer/checknmr.py @@ -158,14 +158,16 @@ def get_metadata_bruker(folder: Path, mora_path) -> dict: def get_metadata_agilent(folder: Path, mora_path) -> dict: # Find out magnet strength, set to initial false value as flag - magnet_freq = "x" - while magnet_freq == "x": + magnet_freq = None + while magnet_freq is None: for subfolder in folder.iterdir(): text_file = subfolder / "text" - with open(text_file, encoding="utf-8") as f: - spectrum_info = f.readlines() - line_with_freq_split = spectrum_info[3].split(",") - magnet_freq = line_with_freq_split[0] + if text_file.exists(): + with open(text_file, encoding="utf-8") as f: + spectrum_info = f.readlines() + line_with_freq_split = spectrum_info[3].split(",") + magnet_freq = line_with_freq_split[0] + break metadata = { "server_location": str(folder.relative_to(mora_path)),