diff --git a/config.toml b/config.toml index dcc39c2..424c30b 100644 --- a/config.toml +++ b/config.toml @@ -17,6 +17,7 @@ group = "gil" dest_path = "copy full path here" inc_init = false inc_solv = false +inc_path = false # Only for nmr - set to "after" to append path, "before" to prepend nmrcheck_style = false spec = "400er" repeat_switch = false diff --git a/mora_the_explorer/__init__.py b/mora_the_explorer/__init__.py index 3012fd8..dae6b1f 100644 --- a/mora_the_explorer/__init__.py +++ b/mora_the_explorer/__init__.py @@ -54,13 +54,13 @@ def set_dark_mode(app): app.setPalette(dark_palette) -def run(rsrc_dir): +def run(rsrc_dir: Path): """Run Mora the Explorer.""" # Load configuration logging.info(f"Program resources located at {rsrc_dir}") logging.info("Loading program settings...") - config = Config(rsrc_dir) + config = Config(rsrc_dir / "config.toml") logging.info("...complete") logging.info("Initializing program...") diff --git a/mora_the_explorer/checknmr.py b/mora_the_explorer/checknmr.py index 7069062..698305d 100644 --- a/mora_the_explorer/checknmr.py +++ b/mora_the_explorer/checknmr.py @@ -212,12 +212,17 @@ def format_name( return name -def format_name_admin(folder, metadata) -> str: +def format_name_admin(folder, metadata, inc_path=False) -> 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 + # Add location details if requested + if inc_path: + location = metadata["server_location"].replace("/", "_").replace("\\", "_") + if inc_path == "before" or inc_path is True: + name = location + "_" + name + elif inc_path == "after": + name = name + "_" + location return name @@ -514,6 +519,7 @@ def check_nmr( new_folder_name = format_name_admin( folder, metadata, + inc_path=fed_options["inc_path"] ) else: new_folder_name = format_name( diff --git a/mora_the_explorer/config.py b/mora_the_explorer/config.py index dece18d..bd3eb48 100644 --- a/mora_the_explorer/config.py +++ b/mora_the_explorer/config.py @@ -8,71 +8,145 @@ class Config: - """Returns a container for the combined app and user configuration data.""" + """Returns a container for the combined app and user configuration data. + + A Config object contains two kinds of config: a `user_config` and an `app_config`. - def __init__(self, resource_directory): - self.rsrc_dir = resource_directory + Both are Python dictionaries with the same structures as `config.toml`, which + currently has the following tables of key/value pairs: - # 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"}" - ) + ```toml + [options] # or [default_options] in the defaults file + [paths] + [groups] + [groups.other] + [spectrometers.xxx] + [spectrometers.yyy] # etc. for each spectrometer + ``` - # Load user config from config.toml in user's config directory - # Make one if it doesn't exist yet + The app's resources directory contains a `config.toml` holding all the default app + settings, and a second `config.toml` gets saved to a sensible location on the user's + system to store their personal choices. + + The `user_config` usually only has the `[options]` table from `config.toml`, + containing the options the user sets, such as search options and save + destination. It may contain any of the above tables, though. Any missing entries in + the `[options]` table are filled from the `[default_options]` table in the defaults + `config.toml` file. + + The `app_config` specifies the other details for how the app should work, e.g. the + information about the available groups and spectrometers, as well as the default + user options. - # Config should be saved to: + At program start, both the app config and user config are loaded from their files, + anything in `user_config` is put into `app_config`, overwriting where appropriate, + and anything missing from `user_config["options"]` is filled from + `app_config["default_options"]`. After this, there is no further interaction + between the two configs; only `user_config` changes during runtime due to the user's + actions, while `app_config` stays static. + + Calling `Config.save()` saves `user_config` to file, not the `app_config`. + """ + + def __init__(self, app_config_file: Path): + + # Load app config from config.toml + self.app_config = self.load_config_toml(app_config_file) + logging.info(f"App configuration loaded from: {app_config_file}") + + # Load or create user config + # platformdirs automatically saves the config file in the place appropriate to + # the os, which should be: # 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, ensure_exists=True, ) ) / "config.toml" - user_config_json = self.user_config_file.with_name("config.json") + + # Load user config from config.toml in user's config directory if self.user_config_file.exists() is True: - with open(self.user_config_file, "rb") as f: - self.user_config = tomllib.load(f) + self.user_config = self.load_config_toml(self.user_config_file) logging.info(f"User configuration loaded from: {self.user_config_file}") - elif user_config_json.exists() is True: - # Load json, save as toml instead, remove old json to avoid confusion - with open(user_config_json, encoding="utf-8") as f: - old_options = json.load(f) - # Add new options - 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() + self.extend_user_config(self.user_config, self.app_config) + # Update any app settings specified in the user config + self.update_app_config(self.user_config) + + # User options used to be stored in config.json pre v1.7, so also check for it + elif self.user_config_file.with_name("config.json").exists() is True: + self.user_config = self.load_user_config_json( + self.user_config_file.with_name("config.json") + ) logging.info("Old config.json found, read, and converted to config.toml") + self.extend_user_config(self.user_config, self.app_config) + + # If no user config file exists, make one and save it else: - # Create config containing only options and any other things that should be made - # obvious to the user that they can be configured - self.user_config = { - "options": copy(self.app_config["default_options"]), - "paths": {"Linux": "overwrite with default mount point"}, - } - self.user_config_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.user_config_file, "wb") as f: - tomli_w.dump(self.user_config, f) + self.user_config = {"options": {}} + self.extend_user_config(self.user_config, self.app_config) logging.info("New default user config created with default options") + self.user_config_file.parent.mkdir(parents=True, exist_ok=True) + self.save() - # 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(): + # 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"] + self.specs = self.app_config["spectrometers"] + + + def load_config_toml(self, path: Path): + """Load a config from a TOML file.""" + + with open(path, "rb") as f: + config = tomllib.load(f) + return config + + + def load_user_config_json(self, path: Path): + """Load a user's config from a JSON file and replace it with a TOML.""" + + with open(path, encoding="utf-8") as f: + old_options = json.load(f) + # Add new options + config = { + "options": old_options, + "paths": {"Linux": "overwrite with default mount point"}, + } + with open(self.user_config_file, "wb") as f: + tomli_w.dump(config, f) + # Remove old json to avoid confusion + path.unlink() + return config + + + def extend_user_config(self, user_config, app_config): + """Make sure the user's config contains everything it needs to by default.""" + + # First just anything in the default options table + for option in app_config["default_options"]: + if option not in user_config["options"]: + user_config["options"][option] = app_config["default_options"][option] + # Then anything from other tables that needs to be present i.e. anything for + # which it should be made obvious to the user that it can be configured + if "paths" in user_config: + if "linux" in user_config["paths"]: + pass + else: + user_config["paths"]["linux"] = "overwrite with default mount point" + + + def update_app_config(self, config: dict): + """Overwrite any app config settings that are specified in the given config.""" + # NOTE: Only works properly for a couple of nesting levels + for table in config: if table in self.app_config: - for k, v in self.user_config[table].items(): + for k, v in 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" + f"Updating default app config option `[{table}] {k} = {repr(self.app_config[table][k])}` with value {repr(v)} from provided config.toml" ) # Make sure tables within tables are only updated, not overwritten if isinstance(v, dict): @@ -80,17 +154,17 @@ def __init__(self, resource_directory): 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"] - self.specs = self.app_config["spectrometers"] - def save(self): - # Save user config to file - with open(self.user_config_file, "wb") as f: + def save(self, path: Path | None = None): + """Save user config to file. + + If no path is provided, it defaults to the current value of `user_config_file`. + """ + if path is None: + path = self.user_config_file + with open(path, "wb") as f: tomli_w.dump(self.user_config, f) logging.info( - f"The following user options were saved to {self.user_config_file}:" + f"The following user options were saved to {path}:" ) logging.info(self.user_config) diff --git a/mora_the_explorer/explorer.py b/mora_the_explorer/explorer.py index 6066372..e7ce1ce 100644 --- a/mora_the_explorer/explorer.py +++ b/mora_the_explorer/explorer.py @@ -102,6 +102,12 @@ def connect_signals(self): self.opts.nmrcheck_style_checkbox.stateChanged.connect( self.main_window.nmrcheck_style_switched ) + self.opts.inc_path_checkbox.stateChanged.connect( + self.main_window.inc_path_changed + ) + self.opts.inc_path_box.currentTextChanged.connect( + self.main_window.inc_path_changed + ) self.opts.spec_buttons.buttonClicked.connect(self.main_window.spec_changed) self.opts.repeat_check_checkbox.stateChanged.connect( self.main_window.repeat_switched diff --git a/mora_the_explorer/ui/main_window.py b/mora_the_explorer/ui/main_window.py index a952744..25e4038 100644 --- a/mora_the_explorer/ui/main_window.py +++ b/mora_the_explorer/ui/main_window.py @@ -155,22 +155,27 @@ def adapt_to_group(self, group=None): self.opts.other_box.show() else: self.opts.other_box.hide() - # If nmr group has been selected, disable the naming option checkboxes - # as they will be treated as selected anyway + # If nmr group has been selected, disable the initials/solvent naming option + # checkboxes as they will be treated as selected anyway, and show the options + # for prepending/appending the path if group == "nmr": self.opts.inc_init_checkbox.setEnabled(False) - self.opts.nmrcheck_style_checkbox.setEnabled(False) + self.opts.inc_solv_checkbox.setEnabled(False) + self.opts.nmrcheck_style_checkbox.hide() + self.opts.inc_path_checkbox.show() + self.opts.inc_path_box.show() 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.opts.inc_solv_checkbox.setEnabled( + self.config.specs[self.config.options["spec"]]["allow_solvent"] + ) + self.opts.nmrcheck_style_checkbox.show() + self.opts.inc_path_checkbox.hide() + self.opts.inc_path_box.hide() self.refresh_visible_specs() def dest_path_changed(self, new_path): @@ -193,6 +198,12 @@ def inc_solv_switched(self): self.config.options["inc_solv"] = self.opts.inc_solv_checkbox.isChecked() self.opts.save_button.setEnabled(True) + def inc_path_changed(self): + if self.opts.inc_path_checkbox.isChecked(): + self.config.options["inc_path"] = self.opts.inc_path_box.currentText() + else: + self.config.options["inc_path"] = False + def nmrcheck_style_switched(self): self.config.options["nmrcheck_style"] = ( self.opts.nmrcheck_style_checkbox.isChecked() @@ -231,25 +242,6 @@ def adapt_to_spec(self, spec: str): else: self.opts.only_button.show() self.opts.since_button.show() - #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.setDisplayFormat("yyyy") - # self.opts.only_button.hide() - # self.opts.since_button.hide() - # self.opts.today_button.setEnabled(False) - #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.date_selector.setDisplayFormat("dd MMM yyyy") - # 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"] = ( diff --git a/mora_the_explorer/ui/options.py b/mora_the_explorer/ui/options.py index c717d5b..3d12ed1 100644 --- a/mora_the_explorer/ui/options.py +++ b/mora_the_explorer/ui/options.py @@ -111,7 +111,7 @@ def __init__(self, config): self.addWidget(self.dest_path_input, 3, 1) self.addWidget(self.open_button, 3, 2) - # Row 4, file naming options + # Rows 4 (and 5), file naming options self.include_label = QLabel("include:") self.include_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) @@ -122,25 +122,43 @@ def __init__(self, config): self.inc_solv_checkbox = QCheckBox("solvent") self.inc_solv_checkbox.setChecked(config.options["inc_solv"]) - if config.options["spec"] == "hf": + if config.specs[config.options["spec"]]["allow_solvent"] is False: 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 + # 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) + # Or, for nmr group, the choice of where to put the path + self.inc_path_checkbox = QCheckBox("path") + self.inc_path_checkbox.setChecked(bool(config.options["inc_path"])) + + self.inc_path_box = QComboBox() + inc_path_options = ["before", "after"] + self.inc_path_box.addItems(inc_path_options) + + if config.options["inc_path"] in inc_path_options: + self.inc_path_box.setCurrentText(config.options["inc_path"]) + else: + self.inc_path_box.setCurrentText("after") + + inc_path_layout = QHBoxLayout() + inc_path_layout.addWidget(self.inc_path_checkbox) + inc_path_layout.addWidget(self.inc_path_box) + inc_path_layout.addWidget(QLabel()) + + filename_layout = QGridLayout() + filename_layout.addWidget(self.inc_init_checkbox, 0, 0) + filename_layout.addWidget(self.inc_solv_checkbox, 0, 1) + filename_layout.addWidget(self.nmrcheck_style_checkbox, 1, 0, 1, 2) + filename_layout.addLayout(inc_path_layout, 1, 0) + + self.addWidget(self.include_label, 4, 0) + self.addLayout(filename_layout, 4, 1, 2, 1) + self.addWidget(self.in_filename_label, 4, 2) # Row 6, spectrometer selection (most gets generated by `add_spec_buttons()`) self.spec_label = QLabel("search:")