Source code for nexusLIMS.tui.apps.config.screens

"""
Screens for the NexusLIMS configuration TUI.

Provides :class:`ConfigScreen` (the main tabbed form) and
:class:`FieldDetailScreen` (popup help modal for configuration fields).
"""

import contextlib
import json
import re
from pathlib import Path
from typing import ClassVar

import pytz
from dotenv import dotenv_values
from pydantic_core import PydanticUndefined
from textual import on
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen, Screen
from textual.widgets import (
    Button,
    Footer,
    Header,
    Input,
    Label,
    Select,
    Static,
    Switch,
    TabbedContent,
    TabPane,
    Tabs,
    TextArea,
)

from nexusLIMS.cli.config import (
    _flatten_to_env,
    _write_env_file,
)
from nexusLIMS.config import EmailConfig, NemoHarvesterConfig, Settings
from nexusLIMS.tui.apps.config.validators import (
    validate_float_nonneg,
    validate_float_positive,
    validate_nemo_address,
    validate_optional_iana_timezone,
    validate_optional_int,
    validate_optional_url,
    validate_smtp_port,
)
from nexusLIMS.tui.common.base_screens import ConfirmDialog
from nexusLIMS.tui.common.validators import validate_required, validate_url
from nexusLIMS.tui.common.widgets import AutocompleteInput, FormField

# --------------------------------------------------------------------------- #
# Constants                                                                   #
# --------------------------------------------------------------------------- #

_DEFAULT_STRFTIME = "%Y-%m-%dT%H:%M:%S%z"
_DEFAULT_STRPTIME = "%Y-%m-%dT%H:%M:%S%z"


# --------------------------------------------------------------------------- #
# Helpers: pull descriptions/defaults from the Settings model                 #
# --------------------------------------------------------------------------- #


def _fdesc(name: str) -> str:
    """Return the field description from Settings for the given env var name."""
    field = Settings.model_fields.get(name)
    if field and field.description:
        return field.description
    return ""


def _fdefault(name: str) -> str:
    """Return the field default from Settings as a string, or empty string."""
    field = Settings.model_fields.get(name)
    if field is None:
        return ""
    default = field.default
    if default is PydanticUndefined or default is None:
        return ""
    if isinstance(default, list):
        return ", ".join(str(v) for v in default)
    return str(default)


def _edesc(name: str) -> str:
    """Return the field description from EmailConfig for the given field name."""
    field = EmailConfig.model_fields.get(name)
    if field and field.description:
        return field.description
    return ""


def _edefault(name: str) -> str:
    """Return the field default from EmailConfig as a string, or empty string."""
    field = EmailConfig.model_fields.get(name)
    if field is None:
        return ""
    default = field.default
    if default is PydanticUndefined or default is None:
        return ""
    return str(default)


def _md_to_rich(text: str) -> str:
    """Convert a small subset of Markdown to Rich markup for TUI display.

    Handles:
    - ``[label](url)`` → ``label (url)``
    - bare URLs (http/https not inside a markdown link) → plain text URL
    - `` `code` `` → ``[bold]code[/bold]``

    Uses a single combined regex pass so that matched spans are never
    processed a second time, avoiding broken nested markup.
    """
    # Combined pattern — alternatives are tried left-to-right, first match wins:
    #   1. backtick code span
    #   2. markdown link [label](url)
    #   3. bare http/https URL
    pattern = re.compile(
        r"`([^`]+)`"  # group 1: backtick code span
        r"|"
        r"\[([^\]]+)\]\((https?://[^\)]+)\)"  # group 2+3: markdown link
        r"|"
        r"(https?://\S+)"  # group 4: bare URL
    )

    def _replace(m: re.Match) -> str:
        if m.group(1) is not None:
            return f"[bold]{m.group(1)}[/bold]"
        if m.group(2) is not None:
            # Render as "label (url)" — avoids MarkupError with URLs in [link=...]
            return f"{m.group(2)} ({m.group(3)})"
        # Bare URL — render as plain text to avoid MarkupError with "://"
        return m.group(4)

    return pattern.sub(_replace, text)


def _fdetail(name: str) -> str:
    """Return extended detail text from Settings.json_schema_extra['detail']."""
    field = Settings.model_fields.get(name)
    if field is None:
        return ""
    jse = getattr(field, "json_schema_extra", None) or {}
    if callable(jse):
        return ""
    return _md_to_rich(jse.get("detail", ""))


def _edetail(name: str) -> str:
    """Return extended detail text from EmailConfig.json_schema_extra['detail']."""
    field = EmailConfig.model_fields.get(name)
    if field is None:
        return ""
    jse = getattr(field, "json_schema_extra", None) or {}
    if callable(jse):
        return ""
    return _md_to_rich(jse.get("detail", ""))


def _ndetail(name: str) -> str:
    """Return extended detail text from NemoHarvesterConfig for the given field."""
    field = NemoHarvesterConfig.model_fields.get(name)
    if field is None:
        return ""
    jse = getattr(field, "json_schema_extra", None) or {}
    if callable(jse):
        return ""
    return _md_to_rich(jse.get("detail", ""))


# Maps NEMO Input widget id prefixes → NemoHarvesterConfig field name.
# Used for dynamic ids like "nemo-address-1", "nemo-token-2", etc.
_NEMO_INPUT_PREFIX_TO_FIELD: dict[str, str] = {
    "nemo-address-": "address",
    "nemo-token-": "token",
    "nemo-tz-": "tz",
    "nemo-strftime-": "strftime_fmt",
    "nemo-strptime-": "strptime_fmt",
}

# Maps Input widget ids → (model_class, field_name) for detail lookup.
# Select widgets (nx-file-strategy, nx-export-strategy) and TextArea
# (nx-cert-bundle) are handled inline in action_show_field_detail.
# Switch widgets with detail text are handled inline in action_show_field_detail.
_INPUT_ID_TO_FIELD: dict[str, tuple[str, str]] = {
    "nx-instrument-data-path": ("settings", "NX_INSTRUMENT_DATA_PATH"),
    "nx-data-path": ("settings", "NX_DATA_PATH"),
    "nx-db-path": ("settings", "NX_DB_PATH"),
    "nx-log-path": ("settings", "NX_LOG_PATH"),
    "nx-records-path": ("settings", "NX_RECORDS_PATH"),
    "nx-local-profiles-path": ("settings", "NX_LOCAL_PROFILES_PATH"),
    "nx-cdcs-url": ("settings", "NX_CDCS_URL"),
    "nx-cdcs-token": ("settings", "NX_CDCS_TOKEN"),
    "nx-ignore-patterns": ("settings", "NX_IGNORE_PATTERNS"),
    "nx-file-delay-days": ("settings", "NX_FILE_DELAY_DAYS"),
    "nx-clustering-sensitivity": ("settings", "NX_CLUSTERING_SENSITIVITY"),
    "nx-elabftw-url": ("settings", "NX_ELABFTW_URL"),
    "nx-elabftw-api-key": ("settings", "NX_ELABFTW_API_KEY"),
    "nx-elabftw-category": ("settings", "NX_ELABFTW_EXPERIMENT_CATEGORY"),
    "nx-elabftw-status": ("settings", "NX_ELABFTW_EXPERIMENT_STATUS"),
    "nx-labarchives-url": ("settings", "NX_LABARCHIVES_URL"),
    "nx-labarchives-access-key-id": ("settings", "NX_LABARCHIVES_ACCESS_KEY_ID"),
    "nx-labarchives-access-password": ("settings", "NX_LABARCHIVES_ACCESS_PASSWORD"),
    "nx-labarchives-user-id": ("settings", "NX_LABARCHIVES_USER_ID"),
    "nx-labarchives-notebook-id": ("settings", "NX_LABARCHIVES_NOTEBOOK_ID"),
    "nx-email-smtp-host": ("email", "smtp_host"),
    "nx-email-smtp-port": ("email", "smtp_port"),
    "nx-email-smtp-username": ("email", "smtp_username"),
    "nx-email-smtp-password": ("email", "smtp_password"),
    "nx-email-sender": ("email", "sender"),
    "nx-email-recipients": ("email", "recipients"),
    "nx-cert-bundle-file": ("settings", "NX_CERT_BUNDLE_FILE"),
}


# --------------------------------------------------------------------------- #
# FieldDetailScreen                                                            #
# --------------------------------------------------------------------------- #


[docs] class FieldDetailScreen(ModalScreen): """ Modal popup displaying extended help text for a configuration field. Invoked by pressing F1 while an Input or Select is focused in ConfigScreen. Dismisses on Escape, F1, or the Close button. Parameters ---------- field_name : str The environment variable / field name shown as the popup title. detail_text : str The extended description to display in the scrollable body. """ CSS_PATH: ClassVar = [ Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" ] BINDINGS: ClassVar = [ ("escape", "dismiss_detail", "Close"), ("f1", "dismiss_detail", "Close"), ] def __init__(self, field_name: str, detail_text: str, **kwargs) -> None: """Initialize with the field name and detail text to display.""" super().__init__(**kwargs) self._field_name = field_name self._detail_text = detail_text
[docs] def compose(self) -> ComposeResult: """Compose the modal layout.""" with Vertical(id="field-detail-dialog"): yield Label(self._field_name, id="field-detail-title") with VerticalScroll(id="field-detail-body"): yield Static(self._detail_text, id="field-detail-text") with Horizontal(id="field-detail-footer"): yield Button( "Close (Esc)", id="field-detail-close-btn", variant="default" )
[docs] def action_dismiss_detail(self) -> None: """Dismiss this modal.""" self.dismiss()
@on(Button.Pressed, "#field-detail-close-btn") def _on_close_btn(self) -> None: self.dismiss()
# --------------------------------------------------------------------------- # # LabArchivesGetUidDialog # # --------------------------------------------------------------------------- #
[docs] class LabArchivesGetUidDialog(ModalScreen[str | None]): """Modal dialog to look up a LabArchives user ID. Prompts for the user's email and LabArchives account password/app token, calls the ``user_access_info`` API endpoint, and dismisses with the returned UID string on success or ``None`` on cancel / error. Parameters ---------- base_url : str LabArchives API base URL including the ``/api`` path (e.g. ``"https://api.labarchives.com/api"``). akid : str Access Key ID (NX_LABARCHIVES_ACCESS_KEY_ID). access_password : str HMAC signing secret (NX_LABARCHIVES_ACCESS_PASSWORD). """ CSS_PATH: ClassVar = [ Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" ] BINDINGS: ClassVar = [ ("escape", "cancel_dialog", "Cancel"), ] def __init__( self, base_url: str, akid: str, access_password: str, **kwargs ) -> None: """Initialize with API credentials.""" super().__init__(**kwargs) self._base_url = base_url self._akid = akid self._access_password = access_password
[docs] def compose(self) -> ComposeResult: """Compose the dialog layout.""" with Vertical(id="field-detail-dialog"): yield Label("Look Up LabArchives User ID", id="field-detail-title") with VerticalScroll(id="field-detail-body"): yield Static( "Enter your LabArchives login credentials to retrieve your UID.\n\n" "[bold]Email[/bold]: your LabArchives account email address.\n" "[bold]LA Password[/bold]: your LabArchives external app password " "(click your username in the top-right of the LabArchives web UI → " "[italic]External App authentication[/italic]). " "This is NOT the NX_LABARCHIVES_ACCESS_PASSWORD signing secret.", id="field-detail-text", ) yield Label("Email", classes="form-label") yield Input( placeholder="you@example.com", id="la-uid-email", ) yield Label("LabArchives Password / App Token", classes="form-label") yield Input( placeholder="your LabArchives password or app token", password=True, id="la-uid-password", ) yield Static("", id="la-uid-error", classes="form-error") with Horizontal(id="field-detail-footer"): yield Button( "Look Up", id="la-uid-lookup-btn", variant="primary", disabled=True, ) yield Button( "Cancel (Esc)", id="field-detail-close-btn", variant="default" )
@on(Input.Changed, "#la-uid-email") @on(Input.Changed, "#la-uid-password") def _update_lookup_btn(self, _event: Input.Changed) -> None: email = self.query_one("#la-uid-email", Input).value.strip() pw = self.query_one("#la-uid-password", Input).value.strip() self.query_one("#la-uid-lookup-btn", Button).disabled = not (email and pw) @on(Button.Pressed, "#la-uid-lookup-btn") def _on_lookup(self) -> None: from nexusLIMS.utils.labarchives import ( # noqa: PLC0415 LabArchivesClient, LabArchivesError, ) email = self.query_one("#la-uid-email", Input).value.strip() password = self.query_one("#la-uid-password", Input).value.strip() error_widget = self.query_one("#la-uid-error", Static) client = LabArchivesClient( base_url=self._base_url, akid=self._akid, password=self._access_password, uid="", ) try: info = client.get_user_info(email, password) except LabArchivesError as exc: error_widget.update(f"API error: {exc}") error_widget.add_class("visible") return uid = info.get("uid") if not uid: error_widget.update("No UID returned. Check your email and password.") error_widget.add_class("visible") return self.dismiss(uid) @on(Button.Pressed, "#field-detail-close-btn") def _on_cancel_btn(self) -> None: self.dismiss(None)
[docs] def action_cancel_dialog(self) -> None: """Dismiss without a result.""" self.dismiss(None)
# --------------------------------------------------------------------------- # # ConfigScreen # # --------------------------------------------------------------------------- #
[docs] class ConfigScreen(Screen): """ Main configuration screen with 7 tabbed sections. Reads an existing ``.env`` file (if present), pre-populates all fields, and writes a new ``.env`` when the user saves. Parameters ---------- env_path : pathlib.Path Path to the ``.env`` file to read/write. """ CSS_PATH: ClassVar = [ Path(__file__).parent.parent.parent / "styles" / "config" / "screens.tcss" ] BINDINGS: ClassVar = [ ("ctrl+s", "save", "Save"), ("escape", "cancel", "Cancel"), ("f1", "show_field_detail", "Field Help"), ("<", "previous_tab", "Previous tab"), (">", "next_tab", "Next tab"), ("?", "app.help", "Help"), ] def __init__(self, env_path: Path, **kwargs): """Initialize the config screen from an optional existing .env file.""" super().__init__(**kwargs) self._env_path = env_path self._existing: dict[str, str] = ( dotenv_values(env_path) if env_path.exists() else {} ) self._nemo_harvesters: dict[int, dict] = {} self._parse_nemo_harvesters() # Snapshot of the env as loaded — used for unsaved-changes detection. self._initial_env: dict[str, str] = dict(self._existing) # ---------------------------------------------------------------------- # # Helpers for reading existing env # # ---------------------------------------------------------------------- # def _get(self, key: str, default: str = "") -> str: val = self._existing.get(key, default) return val if val is not None else default def _get_bool(self, key: str, *, default: bool = False) -> bool: val = self._existing.get(key, "").lower() if val in ("true", "1", "yes"): return True if val in ("false", "0", "no"): return False return default def _parse_nemo_harvesters(self) -> None: """Populate ``self._nemo_harvesters`` from the existing env vars.""" n = 1 while f"NX_NEMO_ADDRESS_{n}" in self._existing: self._nemo_harvesters[n] = { "address": self._existing.get(f"NX_NEMO_ADDRESS_{n}", ""), "token": self._existing.get(f"NX_NEMO_TOKEN_{n}", ""), "tz": self._existing.get(f"NX_NEMO_TZ_{n}"), "strftime_fmt": self._existing.get( f"NX_NEMO_STRFTIME_FMT_{n}", _DEFAULT_STRFTIME ), "strptime_fmt": self._existing.get( f"NX_NEMO_STRPTIME_FMT_{n}", _DEFAULT_STRPTIME ), } n += 1 def _has_elabftw(self) -> bool: return bool( self._existing.get("NX_ELABFTW_URL") or self._existing.get("NX_ELABFTW_API_KEY") ) def _has_email(self) -> bool: return bool( self._existing.get("NX_EMAIL_SMTP_HOST") or self._existing.get("NX_EMAIL_SENDER") ) def _has_labarchives(self) -> bool: return bool( self._existing.get("NX_LABARCHIVES_URL") or self._existing.get("NX_LABARCHIVES_ACCESS_KEY_ID") ) # ---------------------------------------------------------------------- # # Compose # # ---------------------------------------------------------------------- #
[docs] def compose(self) -> ComposeResult: """Compose the tabbed config form layout.""" yield Header() with TabbedContent(): with TabPane("Core Paths", id="tab-core-paths"): yield from self._compose_core_paths() with TabPane("CDCS", id="tab-cdcs"): yield from self._compose_cdcs() with TabPane("File Processing", id="tab-file-processing"): yield from self._compose_file_processing() with TabPane("NEMO Harvesters", id="tab-nemo"): yield from self._compose_nemo() with TabPane("eLabFTW", id="tab-elabftw"): yield from self._compose_elabftw() with TabPane("LabArchives", id="tab-labarchives"): yield from self._compose_labarchives() with TabPane("Email", id="tab-email"): yield from self._compose_email() with TabPane("SSL / Certs", id="tab-ssl"): yield from self._compose_ssl() with Horizontal(id="config-footer-buttons"): yield Button("Save (Ctrl+S)", id="config-save-btn", variant="primary") yield Button("Cancel (Esc)", id="config-cancel-btn", variant="default") yield Footer()
# ---------------------------------------------------------------------- # # Tab content composers # # ---------------------------------------------------------------------- # def _compose_core_paths(self) -> ComposeResult: with VerticalScroll(): yield Label( "Core file paths for NexusLIMS operation", classes="tab-description", ) with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): yield FormField( "NX_INSTRUMENT_DATA_PATH", Input( value=self._get("NX_INSTRUMENT_DATA_PATH"), placeholder="/mnt/instrument_data", id="nx-instrument-data-path", ), required=True, help_text=_fdesc("NX_INSTRUMENT_DATA_PATH"), ) yield FormField( "NX_DATA_PATH", Input( value=self._get("NX_DATA_PATH"), placeholder="/mnt/nexuslims_data", id="nx-data-path", ), required=True, help_text=_fdesc("NX_DATA_PATH"), ) yield FormField( "NX_DB_PATH", Input( value=self._get("NX_DB_PATH"), placeholder="/mnt/nexuslims_data/nexuslims.db", id="nx-db-path", ), required=True, help_text=_fdesc("NX_DB_PATH"), ) with Vertical(classes="form-column"): yield FormField( "NX_LOG_PATH (optional)", Input( value=self._get("NX_LOG_PATH"), placeholder="(defaults to NX_DATA_PATH/logs/)", id="nx-log-path", ), help_text=_fdesc("NX_LOG_PATH"), ) yield FormField( "NX_RECORDS_PATH (optional)", Input( value=self._get("NX_RECORDS_PATH"), placeholder="(defaults to NX_DATA_PATH/records/)", id="nx-records-path", ), help_text=_fdesc("NX_RECORDS_PATH"), ) yield FormField( "NX_LOCAL_PROFILES_PATH (optional)", Input( value=self._get("NX_LOCAL_PROFILES_PATH"), placeholder="(leave blank if unused)", id="nx-local-profiles-path", ), help_text=_fdesc("NX_LOCAL_PROFILES_PATH"), ) def _compose_cdcs(self) -> ComposeResult: with VerticalScroll(): yield Label("CDCS front-end connection settings", classes="tab-description") with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): yield FormField( "NX_CDCS_URL", Input( value=self._get("NX_CDCS_URL"), placeholder="https://cdcs.example.com", id="nx-cdcs-url", ), required=True, help_text=_fdesc("NX_CDCS_URL"), ) with Vertical(classes="form-column"): yield FormField( "NX_CDCS_TOKEN", Input( value=self._get("NX_CDCS_TOKEN"), placeholder="your-cdcs-api-token", password=True, id="nx-cdcs-token", ), required=True, help_text=_fdesc("NX_CDCS_TOKEN"), ) def _compose_file_processing(self) -> ComposeResult: with VerticalScroll(): yield Label( "Controls file discovery and record building", classes="tab-description", ) raw_patterns = self._get("NX_IGNORE_PATTERNS") if raw_patterns: try: patterns_list = json.loads(raw_patterns) patterns_display = ", ".join(patterns_list) except (json.JSONDecodeError, TypeError): patterns_display = raw_patterns else: patterns_display = "*.mib, *.db, *.emi, *.hdr" with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): strategy_opts = [ ( "exclusive \u2014 only files with known extractors", "exclusive", ), ( "inclusive \u2014 all files (basic metadata for unknowns)", "inclusive", ), ] current_strategy = self._get( "NX_FILE_STRATEGY", _fdefault("NX_FILE_STRATEGY") ) yield FormField( "NX_FILE_STRATEGY", Select( options=strategy_opts, value=current_strategy, id="nx-file-strategy", ), help_text=_fdesc("NX_FILE_STRATEGY"), ) export_opts = [ ( "all \u2014 all destinations must succeed (recommended)", "all", ), ( "first_success \u2014 stop after first success", "first_success", ), ( "best_effort \u2014 try all, succeed if any succeed", "best_effort", ), ] current_export = self._get( "NX_EXPORT_STRATEGY", _fdefault("NX_EXPORT_STRATEGY") ) yield FormField( "NX_EXPORT_STRATEGY", Select( options=export_opts, value=current_export, id="nx-export-strategy", ), help_text=_fdesc("NX_EXPORT_STRATEGY"), ) yield FormField( "NX_IGNORE_PATTERNS", Input( value=patterns_display, placeholder=_fdefault("NX_IGNORE_PATTERNS"), id="nx-ignore-patterns", ), help_text=_fdesc("NX_IGNORE_PATTERNS"), ) with Vertical(classes="form-column"): yield FormField( "NX_FILE_DELAY_DAYS", Input( value=self._get( "NX_FILE_DELAY_DAYS", _fdefault("NX_FILE_DELAY_DAYS") ), placeholder=_fdefault("NX_FILE_DELAY_DAYS"), id="nx-file-delay-days", ), help_text=_fdesc("NX_FILE_DELAY_DAYS"), ) yield FormField( "NX_CLUSTERING_SENSITIVITY", Input( value=self._get( "NX_CLUSTERING_SENSITIVITY", _fdefault("NX_CLUSTERING_SENSITIVITY"), ), placeholder=_fdefault("NX_CLUSTERING_SENSITIVITY"), id="nx-clustering-sensitivity", ), help_text=_fdesc("NX_CLUSTERING_SENSITIVITY"), ) def _compose_nemo(self) -> ComposeResult: with VerticalScroll(): yield Label( "NEMO harvester instances — one group per NEMO server", classes="tab-description", ) yield Vertical(id="nemo-groups-container") with Horizontal(classes="nemo-action-bar"): yield Button( "+ Add NEMO Harvester", id="nemo-add-btn", variant="primary" ) # ---------------------------------------------------------------------- # # NEMO group helpers # # ---------------------------------------------------------------------- # def _nemo_group_widget(self, n: int, data: dict) -> Vertical: """Build and return a single NEMO harvester group widget.""" left_col = Vertical( FormField( "API Address", Input( value=data.get("address", ""), placeholder="https://nemo.example.com/api/", id=f"nemo-address-{n}", ), required=True, help_text="Full URL to the NEMO API root (must end with '/')", ), FormField( "API Token", Input( value=data.get("token", ""), placeholder="your-api-token-here", password=True, id=f"nemo-token-{n}", ), required=True, help_text=("Authentication token from the NEMO administration page"), ), FormField( "Timezone (optional)", AutocompleteInput( suggestions=pytz.common_timezones, value=data.get("tz") or "", placeholder=("America/New_York (leave blank to use NEMO default)"), id=f"nemo-tz-{n}", ), help_text="IANA timezone for coercing NEMO datetime strings", ), classes="form-column", ) right_col = Vertical( FormField( "strftime format (optional)", Input( value=data.get("strftime_fmt", _DEFAULT_STRFTIME), placeholder=_DEFAULT_STRFTIME, id=f"nemo-strftime-{n}", ), help_text="Python strftime format sent to the NEMO API", ), FormField( "strptime format (optional)", Input( value=data.get("strptime_fmt", _DEFAULT_STRPTIME), placeholder=_DEFAULT_STRPTIME, id=f"nemo-strptime-{n}", ), help_text=("Python strptime format for parsing NEMO API responses"), ), classes="form-column", ) return Vertical( Horizontal( Label(f"NEMO Harvester #{n}", classes="nemo-group-title"), Button( "Delete", id=f"nemo-delete-{n}", classes="nemo-delete-btn", ), classes="nemo-group-header", ), Horizontal(left_col, right_col, classes="form-columns"), id=f"nemo-group-{n}", classes="nemo-group", ) def _compose_elabftw(self) -> ComposeResult: with VerticalScroll(): yield Label( "Export experiment records to an eLabFTW instance", classes="tab-description", ) with Horizontal(classes="section-toggle-row", id="elabftw-toggle-row"): yield Label( "Enable eLabFTW integration", classes="section-toggle-label", ) yield Switch( value=self._has_elabftw(), id="elabftw-enabled", ) enabled = self._has_elabftw() with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): yield FormField( "NX_ELABFTW_URL", Input( value=self._get("NX_ELABFTW_URL"), placeholder="https://elabftw.example.com", id="nx-elabftw-url", disabled=not enabled, ), help_text=_fdesc("NX_ELABFTW_URL"), ) yield FormField( "NX_ELABFTW_API_KEY", Input( value=self._get("NX_ELABFTW_API_KEY"), placeholder="your-elabftw-api-key", password=True, id="nx-elabftw-api-key", disabled=not enabled, ), help_text=_fdesc("NX_ELABFTW_API_KEY"), ) with Vertical(classes="form-column"): yield FormField( "NX_ELABFTW_EXPERIMENT_CATEGORY (optional)", Input( value=self._get("NX_ELABFTW_EXPERIMENT_CATEGORY"), placeholder="(integer category ID)", id="nx-elabftw-category", disabled=not enabled, ), help_text=_fdesc("NX_ELABFTW_EXPERIMENT_CATEGORY"), ) yield FormField( "NX_ELABFTW_EXPERIMENT_STATUS (optional)", Input( value=self._get("NX_ELABFTW_EXPERIMENT_STATUS"), placeholder="(integer status ID)", id="nx-elabftw-status", disabled=not enabled, ), help_text=_fdesc("NX_ELABFTW_EXPERIMENT_STATUS"), ) def _compose_labarchives(self) -> ComposeResult: with VerticalScroll(): yield Label( "Export experiment records to a LabArchives notebook", classes="tab-description", ) with Horizontal(classes="section-toggle-row", id="labarchives-toggle-row"): yield Label( "Enable LabArchives integration", classes="section-toggle-label", ) yield Switch( value=self._has_labarchives(), id="labarchives-enabled", ) enabled = self._has_labarchives() _la_creds_ready = bool( self._get("NX_LABARCHIVES_URL") and self._get("NX_LABARCHIVES_ACCESS_KEY_ID") and self._get("NX_LABARCHIVES_ACCESS_PASSWORD") ) with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): yield FormField( "NX_LABARCHIVES_URL", Input( value=self._get( "NX_LABARCHIVES_URL", _fdefault("NX_LABARCHIVES_URL"), ), placeholder="https://api.labarchives.com/api", id="nx-labarchives-url", disabled=not enabled, ), help_text=_fdesc("NX_LABARCHIVES_URL"), ) yield FormField( "NX_LABARCHIVES_ACCESS_KEY_ID", Input( value=self._get("NX_LABARCHIVES_ACCESS_KEY_ID"), placeholder="your-access-key-id", id="nx-labarchives-access-key-id", disabled=not enabled, ), help_text=_fdesc("NX_LABARCHIVES_ACCESS_KEY_ID"), ) with Vertical(classes="form-column"): yield FormField( "NX_LABARCHIVES_ACCESS_PASSWORD", Input( value=self._get("NX_LABARCHIVES_ACCESS_PASSWORD"), placeholder="your-access-password", password=True, id="nx-labarchives-access-password", disabled=not enabled, ), help_text=_fdesc("NX_LABARCHIVES_ACCESS_PASSWORD"), ) with Vertical(classes="form-field"): yield Label("NX_LABARCHIVES_USER_ID", classes="field-label") yield Static( _fdesc("NX_LABARCHIVES_USER_ID"), classes="field-help" ) with Horizontal(classes="field-with-button"): yield Input( value=self._get("NX_LABARCHIVES_USER_ID"), placeholder="your-uid", id="nx-labarchives-user-id", disabled=not enabled, ) yield Button( "Get My UID", id="labarchives-get-uid-btn", variant="primary", disabled=not (enabled and _la_creds_ready), ) yield Static( "", classes="field-error", id="nx-labarchives-user-id-error", ) yield FormField( "NX_LABARCHIVES_NOTEBOOK_ID (optional)", Input( value=self._get("NX_LABARCHIVES_NOTEBOOK_ID"), placeholder="(leave blank to use Inbox)", id="nx-labarchives-notebook-id", disabled=not enabled, ), help_text=_fdesc("NX_LABARCHIVES_NOTEBOOK_ID"), ) def _compose_email(self) -> ComposeResult: with VerticalScroll(): yield Label( "Send notifications on record builder errors", classes="tab-description", ) with Horizontal(classes="section-toggle-row", id="email-toggle-row"): yield Label( "Enable email notifications", classes="section-toggle-label", ) yield Switch( value=self._has_email(), id="email-enabled", ) enabled = self._has_email() with Horizontal(classes="form-columns"): with Vertical(classes="form-column"): yield FormField( "SMTP Host", Input( value=self._get("NX_EMAIL_SMTP_HOST"), placeholder="smtp.example.com", id="nx-email-smtp-host", disabled=not enabled, ), help_text=_edesc("smtp_host"), ) yield FormField( "SMTP Port", Input( value=self._get( "NX_EMAIL_SMTP_PORT", _edefault("smtp_port") ), placeholder=_edefault("smtp_port"), id="nx-email-smtp-port", disabled=not enabled, ), help_text=_edesc("smtp_port"), ) yield FormField( "SMTP Username (optional)", Input( value=self._get("NX_EMAIL_SMTP_USERNAME"), placeholder="(leave blank if not required)", id="nx-email-smtp-username", disabled=not enabled, ), help_text=_edesc("smtp_username"), ) yield FormField( "SMTP Password (optional)", Input( value=self._get("NX_EMAIL_SMTP_PASSWORD"), placeholder="(leave blank if not required)", password=True, id="nx-email-smtp-password", disabled=not enabled, ), help_text=_edesc("smtp_password"), ) with Vertical(classes="form-column"): with Horizontal(classes="section-toggle-row"): yield Label("Use TLS", classes="section-toggle-label") yield Switch( value=self._get_bool("NX_EMAIL_USE_TLS", default=True), id="nx-email-use-tls", disabled=not enabled, ) yield FormField( "Sender Address", Input( value=self._get("NX_EMAIL_SENDER"), placeholder="nexuslims@example.com", id="nx-email-sender", disabled=not enabled, ), help_text=_edesc("sender"), ) yield FormField( "Recipients", Input( value=self._get("NX_EMAIL_RECIPIENTS"), placeholder="admin@example.com, user2@example.com", id="nx-email-recipients", disabled=not enabled, ), help_text=_edesc("recipients"), ) def _compose_ssl(self) -> ComposeResult: with VerticalScroll(): yield Label("SSL / Certificate configuration", classes="tab-description") yield FormField( "NX_CERT_BUNDLE_FILE (optional)", Input( value=self._get("NX_CERT_BUNDLE_FILE"), placeholder="/path/to/ca-bundle.crt", id="nx-cert-bundle-file", ), help_text=_fdesc("NX_CERT_BUNDLE_FILE"), ) yield Label("NX_CERT_BUNDLE (optional)", classes="field-label") yield Static( _fdesc("NX_CERT_BUNDLE"), classes="field-help", ) yield TextArea( text=self._get("NX_CERT_BUNDLE"), id="nx-cert-bundle", ) disable_ssl = self._get_bool("NX_DISABLE_SSL_VERIFY", default=False) with Horizontal(classes="section-toggle-row ssl-verify-row"): yield Label( "NX_DISABLE_SSL_VERIFY", classes="section-toggle-label", ) yield Switch( value=disable_ssl, id="nx-disable-ssl-verify", ) yield Static( "WARNING: Disabling SSL verification is insecure. " "Only use for local development with self-signed certificates.", id="ssl-verify-warning", classes="ssl-warning" + (" visible" if disable_ssl else ""), ) # ---------------------------------------------------------------------- # # Lifecycle hooks # # ---------------------------------------------------------------------- #
[docs] def on_mount(self) -> None: """Populate NEMO harvester groups and configure toggle rows after mount.""" container = self.query_one("#nemo-groups-container", Vertical) for n, data in sorted(self._nemo_harvesters.items()): container.mount(self._nemo_group_widget(n, data)) self.query_one("#elabftw-toggle-row").set_class(self._has_elabftw(), "-on") self.query_one("#labarchives-toggle-row").set_class( self._has_labarchives(), "-on" ) self.query_one("#email-toggle-row").set_class(self._has_email(), "-on")
def _next_nemo_index(self) -> int: """Return the next available NEMO harvester index.""" existing = [ int(w.id.split("-")[-1]) for w in self.query(".nemo-group") if w.id and w.id.startswith("nemo-group-") ] return max(existing, default=0) + 1 # ---------------------------------------------------------------------- # # Event handlers # # ---------------------------------------------------------------------- # @on(Button.Pressed, "#config-save-btn") def _on_save_btn(self) -> None: self.action_save() @on(Button.Pressed, "#config-cancel-btn") def _on_cancel_btn(self) -> None: self.action_cancel() @on(Button.Pressed, "#nemo-add-btn") def _on_nemo_add(self) -> None: n = self._next_nemo_index() container = self.query_one("#nemo-groups-container", Vertical) container.mount(self._nemo_group_widget(n, {})) self.app.notify(f"Added NEMO Harvester #{n}", timeout=2)
[docs] def on_button_pressed(self, event: Button.Pressed) -> None: """Handle delete buttons on individual NEMO harvester groups.""" if event.button.has_class("nemo-delete-btn"): group = event.button.parent.parent # Button → header → group if group is not None: group.remove() event.stop()
@on(Switch.Changed, "#elabftw-enabled") def _on_elabftw_toggle(self, event: Switch.Changed) -> None: enabled = event.value self.query_one("#elabftw-toggle-row").set_class(enabled, "-on") for field_id in ( "nx-elabftw-url", "nx-elabftw-api-key", "nx-elabftw-category", "nx-elabftw-status", ): with contextlib.suppress(Exception): self.query_one(f"#{field_id}", Input).disabled = not enabled @on(Switch.Changed, "#labarchives-enabled") def _on_labarchives_toggle(self, event: Switch.Changed) -> None: enabled = event.value self.query_one("#labarchives-toggle-row").set_class(enabled, "-on") for field_id in ( "nx-labarchives-url", "nx-labarchives-access-key-id", "nx-labarchives-access-password", "nx-labarchives-user-id", "nx-labarchives-notebook-id", ): with contextlib.suppress(Exception): self.query_one(f"#{field_id}", Input).disabled = not enabled self._update_la_get_uid_btn() @on(Input.Changed, "#nx-labarchives-url") @on(Input.Changed, "#nx-labarchives-access-key-id") @on(Input.Changed, "#nx-labarchives-access-password") def _on_labarchives_cred_changed(self, _event: Input.Changed) -> None: self._update_la_get_uid_btn() def _update_la_get_uid_btn(self) -> None: """Enable the 'Get My UID' button only when all three credentials are set.""" with contextlib.suppress(Exception): enabled = self.query_one("#labarchives-enabled", Switch).value url = self.query_one("#nx-labarchives-url", Input).value.strip() akid = self.query_one("#nx-labarchives-access-key-id", Input).value.strip() pw = self.query_one("#nx-labarchives-access-password", Input).value.strip() ready = enabled and bool(url and akid and pw) self.query_one("#labarchives-get-uid-btn", Button).disabled = not ready @on(Button.Pressed, "#labarchives-get-uid-btn") def _on_labarchives_get_uid(self) -> None: """Open the UID lookup dialog and populate the User ID field on success.""" with contextlib.suppress(Exception): url = self.query_one("#nx-labarchives-url", Input).value.strip() akid = self.query_one("#nx-labarchives-access-key-id", Input).value.strip() pw = self.query_one("#nx-labarchives-access-password", Input).value.strip() self.app.push_screen( LabArchivesGetUidDialog(base_url=url, akid=akid, access_password=pw), self._on_uid_lookup_result, ) def _on_uid_lookup_result(self, uid: str | None) -> None: """Populate the User ID input with the returned UID.""" if uid: with contextlib.suppress(Exception): self.query_one("#nx-labarchives-user-id", Input).value = uid self.app.notify( f"LabArchives UID retrieved: {uid}", severity="information", timeout=5 ) @on(Switch.Changed, "#email-enabled") def _on_email_toggle(self, event: Switch.Changed) -> None: enabled = event.value self.query_one("#email-toggle-row").set_class(enabled, "-on") for field_id in ( "nx-email-smtp-host", "nx-email-smtp-port", "nx-email-smtp-username", "nx-email-smtp-password", "nx-email-use-tls", "nx-email-sender", "nx-email-recipients", ): with contextlib.suppress(Exception): self.query_one(f"#{field_id}").disabled = not enabled @on(Switch.Changed, "#nx-disable-ssl-verify") def _on_ssl_verify_toggle(self, event: Switch.Changed) -> None: warning = self.query_one("#ssl-verify-warning", Static) if event.value: warning.add_class("visible") else: warning.remove_class("visible") # ---------------------------------------------------------------------- # # Actions # # ---------------------------------------------------------------------- #
[docs] def action_save(self) -> None: """Validate all fields and write the .env file.""" errors = self._validate_all() if errors: msg = f"Cannot save: {len(errors)} error(s). " + "; ".join(errors[:2]) if len(errors) > 2: msg += f" (and {len(errors) - 2} more)" self.app.notify(msg, severity="error", timeout=6) return try: config_dict = self._build_config_dict() env_vars = _flatten_to_env(config_dict) _write_env_file(env_vars, self._env_path) self.app.notify( f"Configuration saved to {self._env_path}", severity="information", timeout=4, ) self.app.exit() except Exception as exc: self.app.notify(f"Failed to save: {exc}", severity="error", timeout=6)
def _has_changes(self) -> bool: """Return True if the current form state differs from the loaded env.""" try: current_env = _flatten_to_env(self._build_config_dict()) except Exception: return True return current_env != self._initial_env
[docs] def action_cancel(self) -> None: """Exit without saving, prompting if there are unsaved changes.""" if not self._has_changes(): self.app.exit() return self.app.push_screen( ConfirmDialog( "You have unsaved changes. Exit without saving?", title="Unsaved Changes", ), self._on_cancel_confirmed, )
def _on_cancel_confirmed(self, confirmed: bool) -> None: if confirmed: self.app.exit()
[docs] def action_next_tab(self) -> None: """Activate the next tab.""" self.query_one(TabbedContent).query_one(Tabs).action_next_tab()
[docs] def action_previous_tab(self) -> None: """Activate the previous tab.""" self.query_one(TabbedContent).query_one(Tabs).action_previous_tab()
def _resolve_focused_field_detail(self, focused) -> tuple[str | None, str]: """Return ``(field_name, detail)`` for the currently focused widget.""" if isinstance(focused, Input): return self._resolve_input_field_detail(focused) if isinstance(focused, Switch): if focused.id == "nx-disable-ssl-verify": name = "NX_DISABLE_SSL_VERIFY" return name, _fdetail(name) elif isinstance(focused, TextArea): if focused.id == "nx-cert-bundle": name = "NX_CERT_BUNDLE" return name, _fdetail(name) elif isinstance(focused, Select): return self._resolve_select_field_detail(focused) return None, "" def _resolve_input_field_detail(self, focused: Input) -> tuple[str | None, str]: """Return ``(field_name, detail)`` for a focused Input widget.""" input_id = focused.id or "" mapping = _INPUT_ID_TO_FIELD.get(input_id) if mapping: model_class, field_name = mapping detail = ( _fdetail(field_name) if model_class == "settings" else _edetail(field_name) ) return field_name, detail for prefix, nemo_field in _NEMO_INPUT_PREFIX_TO_FIELD.items(): if input_id.startswith(prefix): field_name = f"NX_NEMO_{nemo_field.upper()}_N" return field_name, _ndetail(nemo_field) return None, "" def _resolve_select_field_detail(self, focused: Select) -> tuple[str | None, str]: """Return ``(field_name, detail)`` for a focused Select widget.""" select_id_map = { "nx-file-strategy": "NX_FILE_STRATEGY", "nx-export-strategy": "NX_EXPORT_STRATEGY", } name = select_id_map.get(focused.id or "") if name: return name, _fdetail(name) return None, ""
[docs] def action_show_field_detail(self) -> None: """Show extended help popup for the currently focused input or select.""" field_name, detail = self._resolve_focused_field_detail(self.screen.focused) if not field_name or not detail: if field_name: self.app.notify( f"No extended help available for {field_name}.", severity="information", timeout=2, ) return self.app.push_screen(FieldDetailScreen(field_name, detail))
# ---------------------------------------------------------------------- # # Validation helpers # # ---------------------------------------------------------------------- # def _validate_core_paths(self) -> list[str]: errors: list[str] = [] for field_id, label in [ ("nx-instrument-data-path", "NX_INSTRUMENT_DATA_PATH"), ("nx-data-path", "NX_DATA_PATH"), ("nx-db-path", "NX_DB_PATH"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() ok, msg = validate_required(val, label) if not ok: errors.append(msg) return errors def _validate_cdcs(self) -> list[str]: errors: list[str] = [] cdcs_url = self.query_one("#nx-cdcs-url", Input).value.strip() ok, msg = validate_url(cdcs_url, "NX_CDCS_URL") if not ok: errors.append(msg) cdcs_token = self.query_one("#nx-cdcs-token", Input).value.strip() ok, msg = validate_required(cdcs_token, "NX_CDCS_TOKEN") if not ok: errors.append(msg) return errors def _validate_file_processing(self) -> list[str]: errors: list[str] = [] ok, msg = validate_float_positive( self.query_one("#nx-file-delay-days", Input).value.strip(), "NX_FILE_DELAY_DAYS", ) if not ok: errors.append(msg) ok, msg = validate_float_nonneg( self.query_one("#nx-clustering-sensitivity", Input).value.strip(), "NX_CLUSTERING_SENSITIVITY", ) if not ok: errors.append(msg) return errors def _validate_elabftw(self) -> list[str]: if not self.query_one("#elabftw-enabled", Switch).value: return [] errors: list[str] = [] url = self.query_one("#nx-elabftw-url", Input).value.strip() ok, msg = validate_optional_url(url, "NX_ELABFTW_URL") if not ok: errors.append(msg) ok, msg = validate_required( self.query_one("#nx-elabftw-api-key", Input).value.strip(), "NX_ELABFTW_API_KEY", ) if not ok: errors.append(msg) for field_id, label in [ ("nx-elabftw-category", "NX_ELABFTW_EXPERIMENT_CATEGORY"), ("nx-elabftw-status", "NX_ELABFTW_EXPERIMENT_STATUS"), ]: ok, msg = validate_optional_int( self.query_one(f"#{field_id}", Input).value.strip(), label ) if not ok: errors.append(msg) return errors def _validate_labarchives(self) -> list[str]: if not self.query_one("#labarchives-enabled", Switch).value: return [] errors: list[str] = [] url = self.query_one("#nx-labarchives-url", Input).value.strip() ok, msg = validate_optional_url(url, "NX_LABARCHIVES_URL") if not ok: errors.append(msg) for field_id, label in [ ("nx-labarchives-access-key-id", "NX_LABARCHIVES_ACCESS_KEY_ID"), ("nx-labarchives-access-password", "NX_LABARCHIVES_ACCESS_PASSWORD"), ("nx-labarchives-user-id", "NX_LABARCHIVES_USER_ID"), ]: ok, msg = validate_required( self.query_one(f"#{field_id}", Input).value.strip(), label ) if not ok: errors.append(msg) return errors def _validate_email(self) -> list[str]: if not self.query_one("#email-enabled", Switch).value: return [] errors: list[str] = [] for field_id, label in [ ("nx-email-smtp-host", "SMTP Host"), ("nx-email-sender", "Email Sender"), ("nx-email-recipients", "Email Recipients"), ]: ok, msg = validate_required( self.query_one(f"#{field_id}", Input).value.strip(), label ) if not ok: errors.append(msg) ok, msg = validate_smtp_port( self.query_one("#nx-email-smtp-port", Input).value.strip() ) if not ok: errors.append(msg) return errors def _validate_nemo(self) -> list[str]: errors: list[str] = [] for group in self.query(".nemo-group"): if group.id is None or not group.id.startswith("nemo-group-"): continue n = group.id.split("-")[-1] address = self.query_one(f"#nemo-address-{n}", Input).value.strip() ok, msg = validate_nemo_address(address) if not ok: errors.append(f"Harvester #{n} API Address: {msg}") token = self.query_one(f"#nemo-token-{n}", Input).value.strip() ok, msg = validate_required(token, f"Harvester #{n} API Token") if not ok: errors.append(msg) tz_raw = self.query_one(f"#nemo-tz-{n}", Input).value.strip() if tz_raw: ok, msg = validate_optional_iana_timezone(tz_raw) if not ok: errors.append(f"Harvester #{n} Timezone: {msg}") return errors def _validate_all(self) -> list[str]: return ( self._validate_core_paths() + self._validate_cdcs() + self._validate_file_processing() + self._validate_nemo() + self._validate_elabftw() + self._validate_labarchives() + self._validate_email() ) # ---------------------------------------------------------------------- # # Config dict builder helpers # # ---------------------------------------------------------------------- # def _build_paths_config(self) -> dict: config: dict = {} for field_id, key in [ ("nx-instrument-data-path", "NX_INSTRUMENT_DATA_PATH"), ("nx-data-path", "NX_DATA_PATH"), ("nx-db-path", "NX_DB_PATH"), ("nx-log-path", "NX_LOG_PATH"), ("nx-records-path", "NX_RECORDS_PATH"), ("nx-local-profiles-path", "NX_LOCAL_PROFILES_PATH"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() if val: config[key] = val return config def _build_cdcs_config(self) -> dict: config: dict = {} for field_id, key in [ ("nx-cdcs-url", "NX_CDCS_URL"), ("nx-cdcs-token", "NX_CDCS_TOKEN"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() if val: config[key] = val return config def _build_file_config(self) -> dict: config: dict = {} strategy_val = self.query_one("#nx-file-strategy", Select).value if strategy_val and strategy_val is not Select.BLANK: config["NX_FILE_STRATEGY"] = strategy_val export_val = self.query_one("#nx-export-strategy", Select).value if export_val and export_val is not Select.BLANK: config["NX_EXPORT_STRATEGY"] = export_val delay = self.query_one("#nx-file-delay-days", Input).value.strip() if delay: config["NX_FILE_DELAY_DAYS"] = float(delay) sensitivity = self.query_one("#nx-clustering-sensitivity", Input).value.strip() if sensitivity: config["NX_CLUSTERING_SENSITIVITY"] = float(sensitivity) patterns_raw = self.query_one("#nx-ignore-patterns", Input).value.strip() if patterns_raw: patterns_list = [p.strip() for p in patterns_raw.split(",") if p.strip()] config["NX_IGNORE_PATTERNS"] = patterns_list return config def _build_elabftw_config(self) -> dict: if not self.query_one("#elabftw-enabled", Switch).value: return {} config: dict = {} for field_id, key in [ ("nx-elabftw-url", "NX_ELABFTW_URL"), ("nx-elabftw-api-key", "NX_ELABFTW_API_KEY"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() if val: config[key] = val for field_id, key in [ ("nx-elabftw-category", "NX_ELABFTW_EXPERIMENT_CATEGORY"), ("nx-elabftw-status", "NX_ELABFTW_EXPERIMENT_STATUS"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() if val: config[key] = int(val) return config def _build_labarchives_config(self) -> dict: if not self.query_one("#labarchives-enabled", Switch).value: return {} config: dict = {} for field_id, key in [ ("nx-labarchives-url", "NX_LABARCHIVES_URL"), ("nx-labarchives-access-key-id", "NX_LABARCHIVES_ACCESS_KEY_ID"), ("nx-labarchives-access-password", "NX_LABARCHIVES_ACCESS_PASSWORD"), ("nx-labarchives-user-id", "NX_LABARCHIVES_USER_ID"), ("nx-labarchives-notebook-id", "NX_LABARCHIVES_NOTEBOOK_ID"), ]: val = self.query_one(f"#{field_id}", Input).value.strip() if val: config[key] = val return config def _build_email_config(self) -> dict: if not self.query_one("#email-enabled", Switch).value: return {} smtp_host = self.query_one("#nx-email-smtp-host", Input).value.strip() smtp_port_str = self.query_one("#nx-email-smtp-port", Input).value.strip() smtp_user = self.query_one("#nx-email-smtp-username", Input).value.strip() smtp_pass = self.query_one("#nx-email-smtp-password", Input).value.strip() use_tls = self.query_one("#nx-email-use-tls", Switch).value sender = self.query_one("#nx-email-sender", Input).value.strip() recipients_raw = self.query_one("#nx-email-recipients", Input).value.strip() recipients = [r.strip() for r in recipients_raw.split(",") if r.strip()] email_inner: dict = { "smtp_host": smtp_host, "smtp_port": int(smtp_port_str) if smtp_port_str else 587, "use_tls": use_tls, "sender": sender, "recipients": recipients, } if smtp_user: email_inner["smtp_username"] = smtp_user if smtp_pass: email_inner["smtp_password"] = smtp_pass return {"email_config": email_inner} def _build_ssl_config(self) -> dict: config: dict = {} cert_file = self.query_one("#nx-cert-bundle-file", Input).value.strip() if cert_file: config["NX_CERT_BUNDLE_FILE"] = cert_file cert_bundle = self.query_one("#nx-cert-bundle", TextArea).text.strip() if cert_bundle: config["NX_CERT_BUNDLE"] = cert_bundle config["NX_DISABLE_SSL_VERIFY"] = self.query_one( "#nx-disable-ssl-verify", Switch ).value return config def _build_nemo_config(self) -> dict: """Build NEMO harvesters config from inline form groups.""" harvesters: dict = {} for i, group in enumerate(self.query(".nemo-group"), start=1): if group.id is None or not group.id.startswith("nemo-group-"): continue n = group.id.split("-")[-1] address = self.query_one(f"#nemo-address-{n}", Input).value.strip() token = self.query_one(f"#nemo-token-{n}", Input).value.strip() tz_raw = self.query_one(f"#nemo-tz-{n}", Input).value.strip() strftime = self.query_one(f"#nemo-strftime-{n}", Input).value.strip() strptime = self.query_one(f"#nemo-strptime-{n}", Input).value.strip() hvst: dict = { "address": address, "token": token, "strftime_fmt": strftime or _DEFAULT_STRFTIME, "strptime_fmt": strptime or _DEFAULT_STRPTIME, } if tz_raw: hvst["tz"] = tz_raw harvesters[str(i)] = hvst return {"nemo_harvesters": harvesters} if harvesters else {} def _build_config_dict(self) -> dict: """Build the nested config dict consumed by ``_flatten_to_env``.""" config: dict = {} config.update(self._build_paths_config()) config.update(self._build_cdcs_config()) config.update(self._build_file_config()) config.update(self._build_nemo_config()) config.update(self._build_elabftw_config()) config.update(self._build_labarchives_config()) config.update(self._build_email_config()) config.update(self._build_ssl_config()) return config