diff --git a/checknmr.py b/checknmr.py deleted file mode 100644 index 97d9163..0000000 --- a/checknmr.py +++ /dev/null @@ -1,445 +0,0 @@ -""" -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 -from datetime import date, datetime -from pathlib import Path - - - -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." - ) - # 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 - - -def check_nmr( - fed_options, - check_day, - mora_path, - spec_paths, - wild_group, - prog_bar, - progress_callback, -): - """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(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: - 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: - 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 - ) - - 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:") - 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 - 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: - try: - check_list = list(check_path.iterdir()) - except FileNotFoundError: - 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"]: - 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 - prog_state += 1 - progress_callback.emit(prog_state) - - now = datetime.now().strftime("%H:%M:%S") - completed_statement = f"check of {check_day} completed at " + now - output_list.append(completed_statement) - logging.info(completed_statement) - return output_list diff --git a/main_window.py b/main_window.py deleted file mode 100644 index aa2e0e8..0000000 --- a/main_window.py +++ /dev/null @@ -1,705 +0,0 @@ -""" -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 -import subprocess -import sys -from datetime import date, timedelta -from pathlib import Path - -import plyer - -from PySide6.QtCore import QSize, QTimer, Qt, 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 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() - - -class MainWindow(QMainWindow): - def __init__(self, resource_directory: Path, config: Config): - super().__init__() - - 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() - 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"])) - - # 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) - 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() - - # Timer for repeat check, starts checking function when timer runs out - self.timer = QTimer() - self.timer.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) - - # 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): - 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) - 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) - 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() - - # Popup to notify user that an update is available, with version info - def notify_update(self, current, available, changelog): - 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}"') - - # 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 since_function_activated(self): - 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": - QMessageBox.warning(self, "Warning", since_message) - self.repeat_check_checkbox.setEnabled(False) - self.options["repeat_switch"] = False - else: - self.repeat_check_checkbox.setEnabled(True) - self.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 len(new_initials.split()) > 1: - self.wild_group = True - self.options["initials"] = new_initials.split()[1] - else: - self.options["initials"] = "" - else: - self.options["initials"] = new_initials - else: - self.initials_entry.setMaxLength(3) - self.options["initials"] = new_initials - self.save_button.setEnabled(True) - - def group_changed(self): - if self.AK_button_group.checkedButton().text() == "other": - self.options["group"] = self.other_box.currentText() - else: - self.options["group"] = self.AK_button_group.checkedButton().text() - self.adapt_to_group() - self.save_button.setEnabled(True) - - def adapt_to_group(self): - if self.options["group"] in self.config.groups["other"]: - self.other_box.show() - path_hf = ( - self.mora_path / "500-600er" / self.config.groups["other"][self.options["group"]] - ) - else: - self.other_box.hide() - path_hf = self.mora_path / "500-600er" / self.config.groups[self.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) - 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) - # 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) - else: - self.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.options["dest_path"] = formatted_path - self.open_button.show() - self.save_button.setEnabled(True) - - def open_path(self): - if Path(self.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"]}"') - elif platform.system() == "Darwin": - subprocess.Popen(["open", self.options["dest_path"]]) - elif platform.system() == "Linux": - subprocess.Popen(["xdg-open", self.options["dest_path"]]) - - def inc_init_switched(self): - self.options["inc_init"] = self.inc_init_checkbox.isChecked() - self.save_button.setEnabled(True) - - def inc_solv_switched(self): - self.options["inc_solv"] = self.inc_solv_checkbox.isChecked() - self.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.adapt_to_spec() - - def refresh_visible_specs(self): - if self.options["group"] in ["stu", "nae", "nmr"]: - self.spec_button_list[0].show() - else: - self.spec_button_list[0].hide() - - def spec_changed(self): - self.options["spec"] = self.spec_button_dict[self.spec_button_group.checkedButton()] - self.adapt_to_spec() - self.save_button.setEnabled(True) - - def adapt_to_spec(self): - if self.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() - 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) - - def repeat_switched(self): - self.options["repeat_switch"] = self.repeat_check_checkbox.isChecked() - self.save_button.setEnabled(True) - - def repeat_delay_changed(self, new_delay): - self.options["repeat_delay"] = new_delay - self.save_button.setEnabled(True) - - def save(self): - self.config.save() - self.save_button.setEnabled(False) - - def date_changed(self): - self.date_selected = self.date_selector.date().toPython() - - def hf_date_changed(self): - self.date_selected = self.hf_date_selector.date().toPython() - - def set_date_as_today(self): - self.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": - 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": - self.single_check(self.date_selected) - elif self.since_button.isChecked() is True: - self.multiday_check(self.date_selected) - - def single_check(self, date): - self.start_check_button.setEnabled(False) - formatted_date = self.format_date(date) - # Start main checking function in worker thread - worker = Worker( - check_nmr, - self.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.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) - # 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.display_layout.addWidget(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) - # 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) - logging.info("Task complete") - - def interrupted(self): - self.timer.stop() - self.start_check_button.show() - self.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") - else: - self.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() - 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.notification.hide() diff --git a/mora_the_explorer.py b/mora_the_explorer.py index 1d1f8ea..6644e88 100644 --- a/mora_the_explorer.py +++ b/mora_the_explorer.py @@ -17,59 +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 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", @@ -77,20 +55,7 @@ def set_dark_mode(app): encoding="utf-8", level=logging.INFO, ) + + rsrc_dir = get_rsrc_dir() - # Load configuration - config = Config(rsrc_dir) - - 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 - window = MainWindow(rsrc_dir, config) - window.show() - - - app.setWindowIcon(QIcon(str(rsrc_dir / "explorer.ico"))) - 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/mora_the_explorer/checknmr.py b/mora_the_explorer/checknmr.py new file mode 100644 index 0000000..02a87a7 --- /dev/null +++ b/mora_the_explorer/checknmr.py @@ -0,0 +1,463 @@ +import filecmp +import logging +import shutil +import sys +from datetime import date, datetime +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 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 = None + while magnet_freq is None: + for subfolder in folder.iterdir(): + text_file = subfolder / "text" + 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)), + "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 + ] + ) + else: + # 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, 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 + 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, + mora_path, + spec_paths, + check_date, + wild_group, + prog_bar, + progress_callback, +): + """Main checking function for Mora the Explorer.""" + + # Some initial setup that is the same for all spectrometers + 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!") + 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!") + return output_list + spectrometer = fed_options["spec"] + + # Directory discovery + check_path_list = get_check_paths(spec_paths, spectrometer, check_date, wild_group) + + # 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) + + # 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: + print(f"Total spectra to check: {n_spectra}") + except Exception: + # This stops Python from hanging when the program is closed, no idea why + 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 + # 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: + 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 + + # 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: + # Update progress bar if a callback object has been given + prog_state += 1 + if progress_callback is not None: + progress_callback.emit(prog_state) + else: + print(f"Spectra checked: {prog_state}") + continue + else: + logging.info("Spectrum matches search query!") + + # 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, Path(fed_options["dest_path"]) / new_folder_name) + ) + + # Update progress bar if a callback object has been given + # 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: + print(f"Spectra checked: {prog_state}") + + now = datetime.now().strftime("%H:%M:%S") + 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/mora_the_explorer/config.py similarity index 77% rename from config.py rename to mora_the_explorer/config.py index 8d56c66..d941431 100644 --- a/config.py +++ b/mora_the_explorer/config.py @@ -4,27 +4,38 @@ from pathlib import Path import tomllib import tomli_w - import platformdirs 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 + + # 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: @@ -38,7 +49,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 +65,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/mora_the_explorer/explorer.py b/mora_the_explorer/explorer.py new file mode 100644 index 0000000..897b11b --- /dev/null +++ b/mora_the_explorer/explorer.py @@ -0,0 +1,275 @@ +import logging +import os +import platform +import subprocess +from datetime import date, timedelta +from pathlib import Path + +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 + + +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) + + # 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"]) + + # 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, + } + self.adapt_paths_to_group(self.config.options["group"]) + + # Check for updates + self.update_check(Path(config.paths["update"])) + + # 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.main_window.notify_update( + version_no, newest_version_no, changelog, self.update_path + ) + 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): + """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) + + 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 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, + 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) + 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.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.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.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) + # 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/mora_the_explorer/ui/__init__.py b/mora_the_explorer/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mora_the_explorer/ui/display.py b/mora_the_explorer/ui/display.py new file mode 100644 index 0000000..07be325 --- /dev/null +++ b/mora_the_explorer/ui/display.py @@ -0,0 +1,36 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QScrollArea, QVBoxLayout, QWidget, QLabel, QSizePolicy + + +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() + 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, alignment=Qt.AlignTop) + + def scroll_down(self): + self.scrollbar.setSliderPosition(self.scrollbar.maximum()) diff --git a/mora_the_explorer/ui/layout.py b/mora_the_explorer/ui/layout.py new file mode 100644 index 0000000..9c16a64 --- /dev/null +++ b/mora_the_explorer/ui/layout.py @@ -0,0 +1,61 @@ +import platform + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QPushButton, QLabel, QProgressBar, QVBoxLayout + + +from .options import OptionsLayout +from .display import Display + + +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.) + """ + + 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: + version_info = "".join(f.readlines()[:5]) + version_box = QLabel(version_info) + version_box.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") + 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") + 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() + 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) + 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() + self.notification.hide() + self.addWidget(self.notification) diff --git a/mora_the_explorer/ui/main_window.py b/mora_the_explorer/ui/main_window.py new file mode 100644 index 0000000..f6e1d95 --- /dev/null +++ b/mora_the_explorer/ui/main_window.py @@ -0,0 +1,263 @@ +import logging +import os +import platform +import sys +from datetime import date +from pathlib import Path + +import plyer + +from PySide6.QtCore import QSize +from PySide6.QtWidgets import QMainWindow, QWidget, QMessageBox + +from ..config import Config +from .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.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: + 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, 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{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 path.exists() is True: + # Extra quotes necessary because cmd.exe can't handle spaces in path names otherwise + os.system(f'start "" "{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.only_button.hide() + self.opts.since_button.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.only_button.show() + self.opts.since_button.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()) diff --git a/mora_the_explorer/ui/options.py b/mora_the_explorer/ui/options.py new file mode 100644 index 0000000..a3db443 --- /dev/null +++ b/mora_the_explorer/ui/options.py @@ -0,0 +1,240 @@ +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.spec_text.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. + + 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:") + 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) + 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.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() + 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: + self.other_box.hide() + + 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) + + 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() + + 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) + + 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") + self.inc_solv_checkbox.setChecked(config.options["inc_solv"]) + if config.options["spec"] == "hf": + self.inc_solv_checkbox.setEnabled(False) + + 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(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) + + # Row 6, spectrometer selection buttons + 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) + 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) + + 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(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) + + 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") + self.only_button.setChecked(True) + + self.since_button = QRadioButton("since") + + 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() + 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 + self.addWidget(self.hf_date_selector, 9, 1) diff --git a/mora_the_explorer/worker.py b/mora_the_explorer/worker.py new file mode 100644 index 0000000..f6fe4ea --- /dev/null +++ b/mora_the_explorer/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() 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 (