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 (